code-atlas-forge 1.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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
"""Avaliador estático de DataFrames PySpark sobre ``ast`` (lf-0112, Ph.4).
|
|
2
|
+
|
|
3
|
+
Nenhum parser regex, nenhuma execução: o walker percorre o ``ast`` do
|
|
4
|
+
``.py`` em ordem sequencial mantendo um **ambiente de bindings** —
|
|
5
|
+
``nome → sessão | dataframe | writer | str-literal | não-df |
|
|
6
|
+
desconhecido`` — com escopo por função e merge de branches em
|
|
7
|
+
``if``/``match`` (loops e ``try`` usam aproximação sequencial
|
|
8
|
+
declarada: corpo desenrolado uma vez).
|
|
9
|
+
|
|
10
|
+
Modelo de linhagem (espelha a semântica do adapter ``sql``):
|
|
11
|
+
|
|
12
|
+
- **transformação materializada** (``data_transformation``): criada
|
|
13
|
+
quando um valor-DataFrame ancora um nome (``df = …``) ou uma
|
|
14
|
+
expressão terminal executa (``spark.sql``, ``df.write…``, dfv
|
|
15
|
+
"complexo" achado em argumento de função desconhecida);
|
|
16
|
+
- ``T READS_FROM tabela|path``: ``spark.read.*``, ``spark.table``,
|
|
17
|
+
``spark.sql(SELECT …)``;
|
|
18
|
+
- ``T DERIVES_FROM T'``: encadeamento var→var (``enriched = df.join…``);
|
|
19
|
+
- ``T DERIVES_FROM to_unresolved``: receiver/arg não-resolvível —
|
|
20
|
+
linhagem **parcial**, honesta, diagnosticada;
|
|
21
|
+
- ``T WRITES_TO tabela|path|iceberg``: ``df.write.*``,
|
|
22
|
+
``df.writeTo(t).<terminal>``, ``spark.sql(INSERT/CREATE …)``;
|
|
23
|
+
- ``T TRANSFORMS tabela``: mutação in-place vinda de literal
|
|
24
|
+
``spark.sql`` (UPDATE/DELETE/MERGE — remap do adapter sql);
|
|
25
|
+
- ``sink DERIVES_FROM fonte``: colapso tabela→tabela do write
|
|
26
|
+
(mesmo desenho do CTAS no adapter sql).
|
|
27
|
+
|
|
28
|
+
Regras de honestidade (hard):
|
|
29
|
+
|
|
30
|
+
- variável-DataFrame NÃO é tabela persistida — ``data_transformation``
|
|
31
|
+
âncora ``<arquivo>#<var>@<linha>`` nunca colide com identidade de
|
|
32
|
+
``data_table`` (``catalog.namespace.name``);
|
|
33
|
+
- paths (``s3://…``) viram ``data_table`` com ``storage="path"`` e
|
|
34
|
+
``qualified_name`` = literal verbatim — nunca conflitam com tabelas
|
|
35
|
+
de catálogo;
|
|
36
|
+
- ``spark.sql(não-literal)`` → ``dynamic-sql`` + ``to_unresolved`` —
|
|
37
|
+
nenhuma dependência fabricada;
|
|
38
|
+
- receiver desconhecido → ``unresolved-dataframe`` (info) + aresta
|
|
39
|
+
``to_unresolved`` — parcial, nunca fabricado;
|
|
40
|
+
- literais de string propagam apenas via constantes ``str`` do próprio
|
|
41
|
+
arquivo (``TABLE = "db.t"``); f-strings com ``{}`` são dinâmicas;
|
|
42
|
+
- ``.py`` sem uso de Spark → ignorado em silêncio (stats somente).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import ast
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from code_atlas.contracts.enums import EdgeKind
|
|
53
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
54
|
+
from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
|
|
55
|
+
from code_atlas.domains.spark.emit import (
|
|
56
|
+
DOMAIN_ID,
|
|
57
|
+
Sink,
|
|
58
|
+
split_table_name,
|
|
59
|
+
uri_scheme,
|
|
60
|
+
)
|
|
61
|
+
from code_atlas.domains.spark.sqllit import delegate_sql_literal
|
|
62
|
+
from code_atlas.parsers.contracts import SourceSpan
|
|
63
|
+
|
|
64
|
+
# -- vocabulário PySpark reconhecido (estático, declarado) -------------------
|
|
65
|
+
|
|
66
|
+
_SESSION_NAMES = frozenset({"spark", "spark_session", "sparkSession"})
|
|
67
|
+
_SESSION_ATTRS = frozenset({"spark", "spark_session"}) # self.spark
|
|
68
|
+
_SESSION_READ_ATTRS = frozenset({"read", "readStream"})
|
|
69
|
+
_SESSION_METHODS = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"read", "readStream", "sql", "table", "createDataFrame", "range",
|
|
72
|
+
"newSession", "getActiveSession", "sparkContext", "conf",
|
|
73
|
+
"catalog", "udf", "streams", "version", "stop",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
# Métodos de sessão que definitivamente NÃO devolvem DataFrame.
|
|
77
|
+
_SESSION_NON_DF = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"sparkContext", "conf", "catalog", "udf", "streams", "version",
|
|
80
|
+
"stop", "getActiveSession", "newSession",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Operações DataFrame→DataFrame (spec: select/filter/join/groupBy/agg/
|
|
85
|
+
# union/withColumn/dropDuplicates/withColumnRenamed + equivalentes da
|
|
86
|
+
# API pública documentada).
|
|
87
|
+
_DF_TRANSFORMS = frozenset(
|
|
88
|
+
{
|
|
89
|
+
"select", "selectExpr", "filter", "where", "join", "crossJoin",
|
|
90
|
+
"joinWith", "union", "unionByName", "unionAll",
|
|
91
|
+
"intersect", "intersectAll", "exceptAll", "subtract",
|
|
92
|
+
"groupBy", "agg", "rollup", "cube",
|
|
93
|
+
"withColumn", "withColumns", "withColumnRenamed",
|
|
94
|
+
"withColumnsRenamed", "withMetadata", "withField",
|
|
95
|
+
"drop", "dropDuplicates", "drop_duplicates",
|
|
96
|
+
"dropDuplicatesWithinWatermark", "distinct",
|
|
97
|
+
"orderBy", "sort", "sortWithinPartitions", "limit", "alias",
|
|
98
|
+
"repartition", "repartitionByRange", "coalesce",
|
|
99
|
+
"cache", "persist", "unpersist", "checkpoint", "localCheckpoint",
|
|
100
|
+
"dropna", "fillna", "replace", "sample", "sampleBy",
|
|
101
|
+
"transform", "toDF", "toJSON", "describe", "summary",
|
|
102
|
+
"hint", "observe", "withWatermark", "mapInPandas",
|
|
103
|
+
"applyInPandas",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
# Transformações cujo primeiro argumento também é um DataFrame.
|
|
107
|
+
_DF_ARG_OPS = frozenset(
|
|
108
|
+
{
|
|
109
|
+
"join", "crossJoin", "joinWith", "union", "unionByName",
|
|
110
|
+
"unionAll", "exceptAll", "subtract", "intersect", "intersectAll",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
# Chamadas que definitivamente NÃO devolvem DataFrame (ações/terminais).
|
|
114
|
+
_DF_NON_DF_CALLS = frozenset(
|
|
115
|
+
{
|
|
116
|
+
"collect", "count", "show", "take", "head", "first", "tail",
|
|
117
|
+
"foreach", "foreachPartition", "reduce", "explain",
|
|
118
|
+
"printSchema", "toPandas", "toLocalIterator", "inputFiles",
|
|
119
|
+
"isLocal", "isEmpty", "countApprox", "approxQuantile",
|
|
120
|
+
"randomSplit", "countByValue",
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
_DF_NON_DF_ATTRS = frozenset(
|
|
124
|
+
{"columns", "schema", "dtypes", "rdd", "isStreaming"}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Terminais de ``df.write.*`` que escrevem em PATH.
|
|
128
|
+
_WRITER_PATH_TERMINALS = frozenset(
|
|
129
|
+
{"parquet", "csv", "json", "orc", "text", "avro", "save"}
|
|
130
|
+
)
|
|
131
|
+
# Terminais de ``df.write.*`` que escrevem em TABELA de catálogo.
|
|
132
|
+
_WRITER_TABLE_TERMINALS = frozenset({"saveAsTable", "insertInto", "jdbc"})
|
|
133
|
+
_WRITER_OPTS = frozenset(
|
|
134
|
+
{"mode", "format", "option", "options", "partitionBy",
|
|
135
|
+
"bucketBy", "sortBy"}
|
|
136
|
+
)
|
|
137
|
+
# Terminais de ``df.writeTo(t)`` (DataFrameWriterV2 — Iceberg-style).
|
|
138
|
+
_V2_TERMINALS = frozenset(
|
|
139
|
+
{"append", "overwrite", "overwritePartitions", "replace",
|
|
140
|
+
"createOrReplace", "create"}
|
|
141
|
+
)
|
|
142
|
+
_V2_OPTS = frozenset(
|
|
143
|
+
{"using", "option", "options", "tableProperty", "partitionedBy",
|
|
144
|
+
"clusteredBy"}
|
|
145
|
+
)
|
|
146
|
+
# Temp views — ``df.createOrReplaceTempView("v")`` etc.
|
|
147
|
+
_TEMP_VIEW_METHODS = frozenset(
|
|
148
|
+
{
|
|
149
|
+
"createOrReplaceTempView", "createTempView",
|
|
150
|
+
"createOrReplaceGlobalTempView", "createGlobalTempView",
|
|
151
|
+
"registerTempTable",
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
_NA_METHODS = frozenset({"drop", "fill", "replace"})
|
|
155
|
+
_TRANSPARENT_FNS = frozenset({"broadcast"})
|
|
156
|
+
# Formatos de path do DataFrameReader (``spark.read.<fmt>(path)``).
|
|
157
|
+
_READER_PATH_FORMATS = frozenset(
|
|
158
|
+
{"parquet", "csv", "json", "orc", "text", "avro"}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Gate fraco do arquivo: ops camelCase distintivas da API DataFrame
|
|
162
|
+
# (``.filter``/``.select`` minúsculos ficam de fora — pandas colide).
|
|
163
|
+
_CAMEL_DF_OPS = frozenset(
|
|
164
|
+
{
|
|
165
|
+
"groupBy", "agg", "rollup", "cube", "withColumn", "withColumns",
|
|
166
|
+
"withColumnRenamed", "withColumnsRenamed", "dropDuplicates",
|
|
167
|
+
"drop_duplicates", "dropDuplicatesWithinWatermark",
|
|
168
|
+
"unionByName", "unionAll", "selectExpr", "repartition",
|
|
169
|
+
"repartitionByRange", "coalesce", "withWatermark", "joinWith",
|
|
170
|
+
"writeTo", "saveAsTable", "insertInto",
|
|
171
|
+
"createOrReplaceTempView", "createGlobalTempView",
|
|
172
|
+
"createTempView", "registerTempTable", "exceptAll",
|
|
173
|
+
"intersectAll", "localCheckpoint", "foreachBatch",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# -- valores avaliados --------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _E:
|
|
182
|
+
"""União de resultados da avaliação estática de expressão."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _Session(_E):
|
|
186
|
+
"""SparkSession (ou objeto que produz uma)."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _SClass(_E):
|
|
190
|
+
"""Referência à classe ``SparkSession`` (ou alias importado)."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _SBuilder(_E):
|
|
194
|
+
"""``SparkSession.builder[.appName…]`` — vira sessão no getOrCreate."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class _Reader(_E):
|
|
199
|
+
"""``spark.read[.format(f)][.option…]`` antes do terminal."""
|
|
200
|
+
|
|
201
|
+
fmt: str = ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class _Str(_E):
|
|
206
|
+
"""Constante ``str`` — propagação honesta de literal por variável."""
|
|
207
|
+
|
|
208
|
+
value: str
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class _Read:
|
|
213
|
+
"""Alvo de leitura observado (identidade emitida na materialização)."""
|
|
214
|
+
|
|
215
|
+
ident_key: str # logical_key da entidade (tabela|path|view)
|
|
216
|
+
fmt: str # parquet|csv|json|table|jdbc|iceberg|…
|
|
217
|
+
span: SourceSpan | None = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class _PKey:
|
|
222
|
+
"""Pai materializado: variável já ligada a uma transformação."""
|
|
223
|
+
|
|
224
|
+
ident_key: str
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class _PAnon:
|
|
229
|
+
"""Pai anônimo: dfv intermediário dentro da mesma expressão."""
|
|
230
|
+
|
|
231
|
+
dfv: _Dfv
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class _PUnres:
|
|
236
|
+
"""Pai não-resolvível — ``to_unresolved`` honesto."""
|
|
237
|
+
|
|
238
|
+
text: str
|
|
239
|
+
span: SourceSpan | None = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_Parent = _PKey | _PAnon | _PUnres
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class _SqlLit:
|
|
247
|
+
"""Literal ``spark.sql(…)`` — delegado ao adapter sql na materialização."""
|
|
248
|
+
|
|
249
|
+
literal: str
|
|
250
|
+
span: SourceSpan | None
|
|
251
|
+
line: int
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class _Dfv(_E):
|
|
256
|
+
"""Resumo estático de um valor-DataFrame (ainda não materializado)."""
|
|
257
|
+
|
|
258
|
+
reads: list[_Read] = field(default_factory=list)
|
|
259
|
+
parents: list[_Parent] = field(default_factory=list)
|
|
260
|
+
sql: list[_SqlLit] = field(default_factory=list)
|
|
261
|
+
|
|
262
|
+
def is_trivial(self) -> bool:
|
|
263
|
+
"""Alias puro ``x = df`` — rebind sem nova transformação."""
|
|
264
|
+
return (
|
|
265
|
+
not self.reads
|
|
266
|
+
and not self.sql
|
|
267
|
+
and len(self.parents) == 1
|
|
268
|
+
and isinstance(self.parents[0], _PKey)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def is_interesting(self) -> bool:
|
|
272
|
+
"""Merece materialização própria quando achado solto."""
|
|
273
|
+
if self.reads or self.sql:
|
|
274
|
+
return True
|
|
275
|
+
return any(not isinstance(p, _PKey) for p in self.parents)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class _Writer(_E):
|
|
280
|
+
"""``<df>.write[.mode][.format…]`` — aguarda terminal."""
|
|
281
|
+
|
|
282
|
+
dfv: _Dfv | None
|
|
283
|
+
unknown_recv: str = ""
|
|
284
|
+
mode: str = ""
|
|
285
|
+
fmt: str = ""
|
|
286
|
+
span: SourceSpan | None = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class _WriterV2(_E):
|
|
291
|
+
"""``<df>.writeTo(t)[.using…]`` — aguarda terminal V2."""
|
|
292
|
+
|
|
293
|
+
dfv: _Dfv | None
|
|
294
|
+
unknown_recv: str = ""
|
|
295
|
+
target_lit: str | None = None
|
|
296
|
+
target_span: SourceSpan | None = None
|
|
297
|
+
via: str = ""
|
|
298
|
+
span: SourceSpan | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class _NaOps(_E):
|
|
303
|
+
"""``df.na`` — ``.drop()/.fill()/.replace()`` devolvem DataFrame."""
|
|
304
|
+
|
|
305
|
+
dfv: _Dfv
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _NotDf(_E):
|
|
309
|
+
"""Expressão conhecida não-DataFrame (ação, collection, writer…)."""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class _Unknown(_E):
|
|
314
|
+
"""Expressão não-classificada — ops DataFrame nela viram parcial."""
|
|
315
|
+
|
|
316
|
+
text: str
|
|
317
|
+
span: SourceSpan | None = None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass
|
|
321
|
+
class _SeqE(_E):
|
|
322
|
+
"""Tupla/lista literal avaliada — unpack de assignment."""
|
|
323
|
+
|
|
324
|
+
items: list[_E] = field(default_factory=list)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# -- bindings do ambiente -----------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class _Bind:
|
|
331
|
+
"""União de bindings nomeados em um escopo."""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _BSession(_Bind):
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass
|
|
339
|
+
class _BDf(_Bind):
|
|
340
|
+
ident_key: str
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class _BWriter(_Bind):
|
|
345
|
+
writer: _E # _Writer | _WriterV2
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class _BNotDf(_Bind):
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class _BUnknown(_Bind):
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass
|
|
357
|
+
class _BStr(_Bind):
|
|
358
|
+
"""Binding de constante ``str`` — propagada como literal."""
|
|
359
|
+
|
|
360
|
+
value: str
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class _BParamDf(_Bind):
|
|
365
|
+
"""Parâmetro anotado ``DataFrame`` — fonte opaca, honesta."""
|
|
366
|
+
|
|
367
|
+
name: str
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _Env:
|
|
371
|
+
"""Pilha de escopos ``nome → _Bind`` (o mais interno escreve)."""
|
|
372
|
+
|
|
373
|
+
def __init__(self) -> None:
|
|
374
|
+
self.scopes: list[dict[str, _Bind]] = [{}]
|
|
375
|
+
|
|
376
|
+
def push(self) -> None:
|
|
377
|
+
self.scopes.append({})
|
|
378
|
+
|
|
379
|
+
def pop(self) -> dict[str, _Bind]:
|
|
380
|
+
return self.scopes.pop()
|
|
381
|
+
|
|
382
|
+
def lookup(self, name: str) -> _Bind | None:
|
|
383
|
+
for scope in reversed(self.scopes):
|
|
384
|
+
if name in scope:
|
|
385
|
+
return scope[name]
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
def bind(self, name: str, b: _Bind) -> None:
|
|
389
|
+
self.scopes[-1][name] = b
|
|
390
|
+
|
|
391
|
+
def unbind(self, name: str) -> None:
|
|
392
|
+
self.scopes[-1][name] = _BUnknown()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# -- helpers de AST -----------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _attr_chain(node: ast.expr) -> tuple[str | None, list[str]]:
|
|
399
|
+
"""Desenrola ``a.b.c.d`` → (``a`` como Name|None, [b,c,d])."""
|
|
400
|
+
attrs: list[str] = []
|
|
401
|
+
cur = node
|
|
402
|
+
while isinstance(cur, ast.Attribute):
|
|
403
|
+
attrs.append(cur.attr)
|
|
404
|
+
cur = cur.value
|
|
405
|
+
attrs.reverse()
|
|
406
|
+
return (cur.id if isinstance(cur, ast.Name) else None), attrs
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _literal_str(node: ast.expr, *, depth: int = 0) -> str | None:
|
|
410
|
+
"""Literal de string estático; ``None`` quando dinâmico.
|
|
411
|
+
|
|
412
|
+
Cobre ``"x"``, concatenação ``"a" + "b"`` e f-string **sem**
|
|
413
|
+
placeholders. ``JoinedStr`` com ``FormattedValue`` → ``None``
|
|
414
|
+
(dinâmico — nunca fabricado).
|
|
415
|
+
"""
|
|
416
|
+
if depth > 8:
|
|
417
|
+
return None
|
|
418
|
+
if isinstance(node, ast.Constant):
|
|
419
|
+
return node.value if isinstance(node.value, str) else None
|
|
420
|
+
if isinstance(node, ast.JoinedStr):
|
|
421
|
+
parts: list[str] = []
|
|
422
|
+
for v in node.values:
|
|
423
|
+
if isinstance(v, ast.Constant) and isinstance(v.value, str):
|
|
424
|
+
parts.append(v.value)
|
|
425
|
+
else:
|
|
426
|
+
return None
|
|
427
|
+
return "".join(parts)
|
|
428
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
429
|
+
left = _literal_str(node.left, depth=depth + 1)
|
|
430
|
+
right = _literal_str(node.right, depth=depth + 1)
|
|
431
|
+
if left is not None and right is not None:
|
|
432
|
+
return left + right
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _ann_name(node: ast.expr | None) -> str:
|
|
437
|
+
"""Nome terminal de uma anotação (``pyspark.sql.DataFrame`` → DataFrame)."""
|
|
438
|
+
if node is None:
|
|
439
|
+
return ""
|
|
440
|
+
if isinstance(node, ast.Name):
|
|
441
|
+
return node.id
|
|
442
|
+
if isinstance(node, ast.Attribute):
|
|
443
|
+
return node.attr
|
|
444
|
+
if isinstance(node, ast.Constant):
|
|
445
|
+
return str(node.value)
|
|
446
|
+
if isinstance(node, ast.Subscript):
|
|
447
|
+
return _ann_name(node.value)
|
|
448
|
+
return ""
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@dataclass
|
|
452
|
+
class _Flat:
|
|
453
|
+
"""Resultado do flatten de um ``_Dfv`` (pais anônimos expandidos)."""
|
|
454
|
+
|
|
455
|
+
reads: list[_Read] = field(default_factory=list)
|
|
456
|
+
pkeys: list[str] = field(default_factory=list)
|
|
457
|
+
unres: list[tuple[str, SourceSpan | None]] = field(default_factory=list)
|
|
458
|
+
sql: list[_SqlLit] = field(default_factory=list)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# -- walker --------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class _Walker:
|
|
465
|
+
"""Percorre o ``ast`` de um ``.py`` emitindo fatos no ``Sink``."""
|
|
466
|
+
|
|
467
|
+
def __init__(
|
|
468
|
+
self, sink: Sink, path: str, sha: str, text: str
|
|
469
|
+
) -> None:
|
|
470
|
+
self.sink = sink
|
|
471
|
+
self.path = path
|
|
472
|
+
self.sha = sha
|
|
473
|
+
self.text = text
|
|
474
|
+
self.temp_views: dict[str, str] = {} # view name → ident_key
|
|
475
|
+
self.transform_idents: list[DomainIdentity] = []
|
|
476
|
+
self.app_name = ""
|
|
477
|
+
self.sparky = False
|
|
478
|
+
self.implicit_sessions: set[str] = set()
|
|
479
|
+
self.spark_classes: set[str] = set()
|
|
480
|
+
self._offs = self._line_offs(text)
|
|
481
|
+
|
|
482
|
+
# -- spans ----------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _line_offs(text: str) -> list[int]:
|
|
486
|
+
offs = [0]
|
|
487
|
+
total = 0
|
|
488
|
+
for raw in text.splitlines(keepends=True):
|
|
489
|
+
total += len(raw.encode("utf-8"))
|
|
490
|
+
offs.append(total)
|
|
491
|
+
return offs
|
|
492
|
+
|
|
493
|
+
def _span(self, node: ast.AST) -> SourceSpan | None:
|
|
494
|
+
lineno = getattr(node, "lineno", 0)
|
|
495
|
+
col = getattr(node, "col_offset", 0)
|
|
496
|
+
if not lineno or lineno > len(self._offs):
|
|
497
|
+
return None
|
|
498
|
+
end_line = getattr(node, "end_lineno", None) or lineno
|
|
499
|
+
end_col = getattr(node, "end_col_offset", None) or col
|
|
500
|
+
end_line = min(end_line, len(self._offs))
|
|
501
|
+
return SourceSpan(
|
|
502
|
+
start_byte=self._offs[lineno - 1] + col,
|
|
503
|
+
end_byte=self._offs[end_line - 1] + end_col,
|
|
504
|
+
start_line=lineno, start_col=col,
|
|
505
|
+
end_line=end_line, end_col=end_col,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _txt(self, node: ast.AST) -> str:
|
|
509
|
+
try:
|
|
510
|
+
txt = ast.unparse(node)
|
|
511
|
+
except Exception:
|
|
512
|
+
return "<expr>"
|
|
513
|
+
txt = " ".join(txt.split())
|
|
514
|
+
return txt[:120]
|
|
515
|
+
|
|
516
|
+
def _literal_arg(self, node: ast.expr, env: _Env) -> str | None:
|
|
517
|
+
"""Literal estático do argumento — direto ou via binding ``str``."""
|
|
518
|
+
lit = _literal_str(node)
|
|
519
|
+
if lit is not None:
|
|
520
|
+
return lit
|
|
521
|
+
if isinstance(node, ast.Name):
|
|
522
|
+
b = env.lookup(node.id)
|
|
523
|
+
if isinstance(b, _BStr):
|
|
524
|
+
return b.value
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
# -- ponto de entrada -------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def run(self) -> None:
|
|
530
|
+
try:
|
|
531
|
+
tree = ast.parse(self.text)
|
|
532
|
+
except (SyntaxError, ValueError, RecursionError) as exc:
|
|
533
|
+
self.sink.diag(
|
|
534
|
+
"parse-error", "error",
|
|
535
|
+
f"python ast.parse failed: {exc}", self.path,
|
|
536
|
+
)
|
|
537
|
+
self.sink.bump("parse_errors")
|
|
538
|
+
return
|
|
539
|
+
self._prescan(tree)
|
|
540
|
+
if not self.sparky:
|
|
541
|
+
self.sink.bump("non_spark_files")
|
|
542
|
+
return
|
|
543
|
+
env = _Env()
|
|
544
|
+
for name in sorted(self.implicit_sessions):
|
|
545
|
+
env.bind(name, _BSession())
|
|
546
|
+
self.sink.bump("spark_files")
|
|
547
|
+
self._stmts(tree.body, env)
|
|
548
|
+
self._emit_job()
|
|
549
|
+
|
|
550
|
+
def _emit_job(self) -> None:
|
|
551
|
+
"""``spark_job`` do artefato — âncora do pipeline + CONTAINS.
|
|
552
|
+
|
|
553
|
+
A entidade-job é o alvo semântico para vínculos de provisiona-
|
|
554
|
+
mento cross-domain (``aws_glue_job.script_location`` ↔ arquivo,
|
|
555
|
+
lf-0117) e o container honesto das transformações do arquivo.
|
|
556
|
+
|
|
557
|
+
``import pyspark`` sozinho NÃO basta — sem transformação/view o
|
|
558
|
+
arquivo declara dependência, não um job.
|
|
559
|
+
"""
|
|
560
|
+
if not self.transform_idents and not self.temp_views:
|
|
561
|
+
return
|
|
562
|
+
stem = self.path[:-3] if self.path.endswith(".py") else self.path
|
|
563
|
+
req = self.sink.request
|
|
564
|
+
ident = DomainIdentity(
|
|
565
|
+
domain=DOMAIN_ID, repository=req.repository, root=req.root,
|
|
566
|
+
environment=req.environment, qualified_name=stem,
|
|
567
|
+
)
|
|
568
|
+
evi = self.sink.evi(self.path, self.sha, None, notes="spark-job")
|
|
569
|
+
attrs = {"entry_point": self.path, "language": "python"}
|
|
570
|
+
if self.app_name:
|
|
571
|
+
attrs["app_name"] = self.app_name
|
|
572
|
+
self.sink.add_entity(
|
|
573
|
+
ident, DomainEntityKind.SPARK_JOB, stem,
|
|
574
|
+
path=self.path, content_hash=self.sha,
|
|
575
|
+
attributes=attrs, roles=("job",), evidence=(evi,),
|
|
576
|
+
)
|
|
577
|
+
for t in self.transform_idents:
|
|
578
|
+
self.sink.add_rel(
|
|
579
|
+
str(EdgeKind.CONTAINS), ident,
|
|
580
|
+
path=self.path, to_i=t, evidence=(evi,),
|
|
581
|
+
)
|
|
582
|
+
self.sink.bump("spark_jobs")
|
|
583
|
+
|
|
584
|
+
# -- gate de arquivo --------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
def _prescan(self, tree: ast.AST) -> None:
|
|
587
|
+
"""Decide se o arquivo usa Spark — sem custo de emitir nada.
|
|
588
|
+
|
|
589
|
+
Marcadores: imports ``pyspark*``, nome ``SparkSession``, padrões
|
|
590
|
+
``<nome>.read.<fmt>`` (sessão implícita de notebook) e ops
|
|
591
|
+
camelCase distintivas da API DataFrame (``.writeTo``,
|
|
592
|
+
``.write.parquet``, ``groupBy`` …).
|
|
593
|
+
"""
|
|
594
|
+
for node in ast.walk(tree):
|
|
595
|
+
if isinstance(node, ast.ImportFrom):
|
|
596
|
+
self._prescan_importfrom(node)
|
|
597
|
+
elif isinstance(node, ast.Import):
|
|
598
|
+
for a in node.names:
|
|
599
|
+
if a.name == "pyspark" or a.name.startswith("pyspark."):
|
|
600
|
+
self.sparky = True
|
|
601
|
+
elif isinstance(node, ast.Name) and node.id == "SparkSession":
|
|
602
|
+
self.sparky = True
|
|
603
|
+
elif isinstance(node, ast.Call) and isinstance(
|
|
604
|
+
node.func, ast.Attribute
|
|
605
|
+
):
|
|
606
|
+
self._prescan_call(node)
|
|
607
|
+
|
|
608
|
+
def _prescan_importfrom(self, node: ast.ImportFrom) -> None:
|
|
609
|
+
mod = node.module or ""
|
|
610
|
+
if not (mod == "pyspark" or mod.startswith("pyspark.")):
|
|
611
|
+
return
|
|
612
|
+
self.sparky = True
|
|
613
|
+
for a in node.names:
|
|
614
|
+
if a.name == "SparkSession":
|
|
615
|
+
self.spark_classes.add(a.asname or a.name)
|
|
616
|
+
|
|
617
|
+
def _prescan_call(self, node: ast.Call) -> None:
|
|
618
|
+
"""Marcadores de ``<recv>.<cadeia>(…)`` no gate do arquivo."""
|
|
619
|
+
base, attrs = _attr_chain(node.func)
|
|
620
|
+
if not attrs:
|
|
621
|
+
return
|
|
622
|
+
if base in _SESSION_NAMES and attrs[0] in _SESSION_METHODS:
|
|
623
|
+
self.sparky = True
|
|
624
|
+
if base is not None:
|
|
625
|
+
self.implicit_sessions.add(base)
|
|
626
|
+
elif attrs[0] in _SESSION_READ_ATTRS and len(attrs) >= 2:
|
|
627
|
+
# ``x.read.table(…)`` — leitor só existe em sessão
|
|
628
|
+
self.sparky = True
|
|
629
|
+
if base is not None:
|
|
630
|
+
self.implicit_sessions.add(base)
|
|
631
|
+
if (
|
|
632
|
+
attrs[0] in ("write", "writeStream")
|
|
633
|
+
and attrs[-1]
|
|
634
|
+
in (_WRITER_PATH_TERMINALS | _WRITER_TABLE_TERMINALS)
|
|
635
|
+
):
|
|
636
|
+
self.sparky = True
|
|
637
|
+
if attrs[-1] in _CAMEL_DF_OPS:
|
|
638
|
+
self.sparky = True
|
|
639
|
+
|
|
640
|
+
# -- statements -------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def _stmts(self, body: list[ast.stmt], env: _Env) -> None:
|
|
643
|
+
for st in body:
|
|
644
|
+
self._stmt(st, env)
|
|
645
|
+
|
|
646
|
+
def _stmt(self, st: ast.stmt, env: _Env) -> None:
|
|
647
|
+
if isinstance(st, ast.Assign):
|
|
648
|
+
value = self._eval(st.value, env)
|
|
649
|
+
bound = self._bind_targets(st.targets, value, env)
|
|
650
|
+
if not bound:
|
|
651
|
+
self._after_eval(value, st)
|
|
652
|
+
elif isinstance(st, ast.AnnAssign):
|
|
653
|
+
if st.value is not None:
|
|
654
|
+
value = self._eval(st.value, env)
|
|
655
|
+
bound = self._bind_targets([st.target], value, env)
|
|
656
|
+
if not bound:
|
|
657
|
+
self._after_eval(value, st)
|
|
658
|
+
elif isinstance(st, ast.AugAssign):
|
|
659
|
+
self._eval(st.value, env)
|
|
660
|
+
if isinstance(st.target, ast.Name):
|
|
661
|
+
env.unbind(st.target.id)
|
|
662
|
+
elif isinstance(st, ast.Expr):
|
|
663
|
+
self._after_eval(self._eval(st.value, env), st)
|
|
664
|
+
else:
|
|
665
|
+
handler = _STMT_DISPATCH.get(type(st))
|
|
666
|
+
if handler is not None:
|
|
667
|
+
handler(self, st, env)
|
|
668
|
+
# Import/ImportFrom/Global/Nonlocal/Pass/Break/Continue — nada
|
|
669
|
+
|
|
670
|
+
# -- handlers de statement (``_STMT_DISPATCH``) ---------------------------
|
|
671
|
+
|
|
672
|
+
def _st_class(self, st: ast.ClassDef, env: _Env) -> None:
|
|
673
|
+
for d in st.decorator_list:
|
|
674
|
+
self._after_eval(self._eval(d, env), d)
|
|
675
|
+
for b in st.bases:
|
|
676
|
+
self._after_eval(self._eval(b, env), b)
|
|
677
|
+
env.push()
|
|
678
|
+
self._stmts(st.body, env)
|
|
679
|
+
env.pop()
|
|
680
|
+
|
|
681
|
+
def _st_if(self, st: ast.If, env: _Env) -> None:
|
|
682
|
+
self._after_eval(self._eval(st.test, env), st.test)
|
|
683
|
+
self._branches([st.body, st.orelse], env, st.lineno)
|
|
684
|
+
|
|
685
|
+
def _st_for(self, st: ast.For | ast.AsyncFor, env: _Env) -> None:
|
|
686
|
+
self._after_eval(self._eval(st.iter, env), st.iter)
|
|
687
|
+
self._bind_loop_target(st.target, env)
|
|
688
|
+
# aproximação sequencial declarada: corpo desenrolado 1x
|
|
689
|
+
self._stmts(st.body, env)
|
|
690
|
+
self._stmts(st.orelse, env)
|
|
691
|
+
|
|
692
|
+
def _st_while(self, st: ast.While, env: _Env) -> None:
|
|
693
|
+
self._after_eval(self._eval(st.test, env), st.test)
|
|
694
|
+
self._stmts(st.body, env)
|
|
695
|
+
self._stmts(st.orelse, env)
|
|
696
|
+
|
|
697
|
+
def _st_with(self, st: ast.With | ast.AsyncWith, env: _Env) -> None:
|
|
698
|
+
for item in st.items:
|
|
699
|
+
v = self._eval(item.context_expr, env)
|
|
700
|
+
if item.optional_vars is not None:
|
|
701
|
+
self._bind_targets([item.optional_vars], v, env)
|
|
702
|
+
self._stmts(st.body, env)
|
|
703
|
+
|
|
704
|
+
def _st_try(self, st: ast.Try | ast.TryStar, env: _Env) -> None:
|
|
705
|
+
self._stmts(st.body, env)
|
|
706
|
+
for h in st.handlers:
|
|
707
|
+
self._stmts(h.body, env)
|
|
708
|
+
self._stmts(st.orelse, env)
|
|
709
|
+
self._stmts(st.finalbody, env)
|
|
710
|
+
|
|
711
|
+
def _st_return(self, st: ast.Return, env: _Env) -> None:
|
|
712
|
+
if st.value is not None:
|
|
713
|
+
self._after_eval(self._eval(st.value, env), st.value)
|
|
714
|
+
|
|
715
|
+
def _st_raise(self, st: ast.Raise, env: _Env) -> None:
|
|
716
|
+
if st.exc is not None:
|
|
717
|
+
self._after_eval(self._eval(st.exc, env), st.exc)
|
|
718
|
+
|
|
719
|
+
def _st_delete(self, st: ast.Delete, env: _Env) -> None:
|
|
720
|
+
for t in st.targets:
|
|
721
|
+
if isinstance(t, ast.Name):
|
|
722
|
+
env.unbind(t.id)
|
|
723
|
+
|
|
724
|
+
def _st_match(self, st: ast.Match, env: _Env) -> None:
|
|
725
|
+
self._after_eval(self._eval(st.subject, env), st.subject)
|
|
726
|
+
# match pode ser não-exaustivo — trilha vazia conta
|
|
727
|
+
self._branches([c.body for c in st.cases] + [[]], env, st.lineno)
|
|
728
|
+
|
|
729
|
+
def _st_assert(self, st: ast.Assert, env: _Env) -> None:
|
|
730
|
+
self._after_eval(self._eval(st.test, env), st.test)
|
|
731
|
+
|
|
732
|
+
def _branches(
|
|
733
|
+
self, bodies: list[list[ast.stmt]], env: _Env, lineno: int
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Processa branches em escopos-filho e funde bindings.
|
|
736
|
+
|
|
737
|
+
Cada branch pode vincular o mesmo nome a transformações
|
|
738
|
+
diferentes — o merge cria uma transformação
|
|
739
|
+
``<nome>@merge@<linha>`` que DERIVES_FROM todas as alternativas,
|
|
740
|
+
nunca descartando uma leitura. Branch que não vincula o nome
|
|
741
|
+
mantém o binding do escopo pai como candidato.
|
|
742
|
+
"""
|
|
743
|
+
childs: list[dict[str, _Bind]] = []
|
|
744
|
+
for body in bodies:
|
|
745
|
+
env.push()
|
|
746
|
+
self._stmts(body, env)
|
|
747
|
+
childs.append(env.pop())
|
|
748
|
+
parent = env.scopes[-1]
|
|
749
|
+
names: list[str] = []
|
|
750
|
+
for ch in childs:
|
|
751
|
+
for n in ch:
|
|
752
|
+
if n not in names:
|
|
753
|
+
names.append(n)
|
|
754
|
+
for name in names:
|
|
755
|
+
candidates: list[_Bind | None] = [
|
|
756
|
+
ch[name] for ch in childs if name in ch
|
|
757
|
+
]
|
|
758
|
+
covered = sum(1 for ch in childs if name in ch)
|
|
759
|
+
if covered < len(childs) and name in parent:
|
|
760
|
+
candidates.append(parent[name])
|
|
761
|
+
merged = self._merge_binding(name, candidates, lineno)
|
|
762
|
+
if merged is not None:
|
|
763
|
+
parent[name] = merged
|
|
764
|
+
elif name in parent:
|
|
765
|
+
del parent[name]
|
|
766
|
+
|
|
767
|
+
def _merge_binding(
|
|
768
|
+
self, name: str, candidates: list[_Bind | None], lineno: int
|
|
769
|
+
) -> _Bind | None:
|
|
770
|
+
"""Fusão de bindings de branches — honesta, nunca resolved-na-sorte."""
|
|
771
|
+
bound = [b for b in candidates if b is not None]
|
|
772
|
+
if not bound:
|
|
773
|
+
return None
|
|
774
|
+
dfkeys: list[str] = []
|
|
775
|
+
for b in bound:
|
|
776
|
+
if isinstance(b, _BDf) and b.ident_key not in dfkeys:
|
|
777
|
+
dfkeys.append(b.ident_key)
|
|
778
|
+
if len(dfkeys) == 1:
|
|
779
|
+
return _BDf(dfkeys[0])
|
|
780
|
+
if len(dfkeys) > 1:
|
|
781
|
+
dfv = _Dfv(parents=[_PKey(k) for k in dfkeys])
|
|
782
|
+
ident = self._materialize(
|
|
783
|
+
dfv, f"{name}@merge@{lineno}", None
|
|
784
|
+
)
|
|
785
|
+
return _BDf(ident.logical_key())
|
|
786
|
+
for cls in (_BSession, _BWriter, _BParamDf):
|
|
787
|
+
for b in bound:
|
|
788
|
+
if isinstance(b, cls):
|
|
789
|
+
return b
|
|
790
|
+
if any(isinstance(b, _BUnknown) for b in bound):
|
|
791
|
+
return _BUnknown()
|
|
792
|
+
return bound[-1]
|
|
793
|
+
|
|
794
|
+
# -- assignments -------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
def _bind_targets(
|
|
797
|
+
self, targets: list[ast.expr], value: _E, env: _Env
|
|
798
|
+
) -> bool:
|
|
799
|
+
"""Liga RHS avaliado aos targets. True quando algo vinculou."""
|
|
800
|
+
bound_any = False
|
|
801
|
+
for t in targets:
|
|
802
|
+
if isinstance(t, ast.Name):
|
|
803
|
+
self._bind_name(t.id, value, env, t)
|
|
804
|
+
bound_any = True
|
|
805
|
+
elif isinstance(t, ast.Starred) and isinstance(
|
|
806
|
+
t.value, ast.Name
|
|
807
|
+
):
|
|
808
|
+
env.bind(t.value.id, _BUnknown())
|
|
809
|
+
bound_any = True
|
|
810
|
+
elif isinstance(t, (ast.Tuple, ast.List)):
|
|
811
|
+
if isinstance(value, _SeqE) and len(t.elts) == len(
|
|
812
|
+
value.items
|
|
813
|
+
):
|
|
814
|
+
for el, item in zip(
|
|
815
|
+
t.elts, value.items, strict=True
|
|
816
|
+
):
|
|
817
|
+
if isinstance(el, ast.Name):
|
|
818
|
+
self._bind_name(el.id, item, env, el)
|
|
819
|
+
bound_any = True
|
|
820
|
+
else:
|
|
821
|
+
for el in t.elts:
|
|
822
|
+
if isinstance(el, ast.Name):
|
|
823
|
+
env.bind(el.id, _BUnknown())
|
|
824
|
+
bound_any = True
|
|
825
|
+
# Attribute/Subscript targets — fora do env de nomes; o
|
|
826
|
+
# caller materializa o valor via ``_after_eval``.
|
|
827
|
+
return bound_any
|
|
828
|
+
|
|
829
|
+
def _bind_loop_target(self, target: ast.expr, env: _Env) -> None:
|
|
830
|
+
for t in ast.walk(target):
|
|
831
|
+
if isinstance(t, ast.Name):
|
|
832
|
+
env.bind(t.id, _BUnknown())
|
|
833
|
+
|
|
834
|
+
def _bind_name(
|
|
835
|
+
self, name: str, value: _E, env: _Env, node: ast.expr
|
|
836
|
+
) -> None:
|
|
837
|
+
if isinstance(value, _Dfv):
|
|
838
|
+
if value.is_trivial():
|
|
839
|
+
key_parent = value.parents[0]
|
|
840
|
+
assert isinstance(key_parent, _PKey)
|
|
841
|
+
env.bind(name, _BDf(key_parent.ident_key))
|
|
842
|
+
return
|
|
843
|
+
ident = self._materialize(
|
|
844
|
+
value, f"{name}@{node.lineno}", self._span(node)
|
|
845
|
+
)
|
|
846
|
+
env.bind(name, _BDf(ident.logical_key()))
|
|
847
|
+
elif isinstance(value, _Session):
|
|
848
|
+
env.bind(name, _BSession())
|
|
849
|
+
elif isinstance(value, (_Writer, _WriterV2)):
|
|
850
|
+
env.bind(name, _BWriter(value))
|
|
851
|
+
elif isinstance(value, _Str):
|
|
852
|
+
env.bind(name, _BStr(value.value))
|
|
853
|
+
elif isinstance(value, _NotDf):
|
|
854
|
+
env.bind(name, _BNotDf())
|
|
855
|
+
else:
|
|
856
|
+
env.bind(name, _BUnknown())
|
|
857
|
+
|
|
858
|
+
# -- functions -------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
def _function(
|
|
861
|
+
self, st: ast.FunctionDef | ast.AsyncFunctionDef, env: _Env
|
|
862
|
+
) -> None:
|
|
863
|
+
for d in st.decorator_list:
|
|
864
|
+
self._after_eval(self._eval(d, env), d)
|
|
865
|
+
args = st.args
|
|
866
|
+
for d in args.defaults:
|
|
867
|
+
self._after_eval(self._eval(d, env), d)
|
|
868
|
+
for kd in args.kw_defaults:
|
|
869
|
+
if kd is not None:
|
|
870
|
+
self._after_eval(self._eval(kd, env), kd)
|
|
871
|
+
env.push()
|
|
872
|
+
params: list[ast.arg] = [
|
|
873
|
+
*args.posonlyargs, *args.args, *args.kwonlyargs,
|
|
874
|
+
]
|
|
875
|
+
if args.vararg is not None:
|
|
876
|
+
params.append(args.vararg)
|
|
877
|
+
if args.kwarg is not None:
|
|
878
|
+
params.append(args.kwarg)
|
|
879
|
+
for p in params:
|
|
880
|
+
ann = _ann_name(p.annotation)
|
|
881
|
+
if p.arg in _SESSION_NAMES or ann == "SparkSession":
|
|
882
|
+
env.bind(p.arg, _BSession())
|
|
883
|
+
elif ann == "DataFrame":
|
|
884
|
+
env.bind(p.arg, _BParamDf(p.arg))
|
|
885
|
+
self._stmts(st.body, env)
|
|
886
|
+
env.pop()
|
|
887
|
+
|
|
888
|
+
# -- materialização / emissão --------------------------------------------
|
|
889
|
+
|
|
890
|
+
def _flatten(self, dfv: _Dfv, out: _Flat) -> None:
|
|
891
|
+
out.reads.extend(dfv.reads)
|
|
892
|
+
out.sql.extend(dfv.sql)
|
|
893
|
+
for p in dfv.parents:
|
|
894
|
+
if isinstance(p, _PKey):
|
|
895
|
+
if p.ident_key not in out.pkeys:
|
|
896
|
+
out.pkeys.append(p.ident_key)
|
|
897
|
+
elif isinstance(p, _PAnon):
|
|
898
|
+
self._flatten(p.dfv, out)
|
|
899
|
+
else:
|
|
900
|
+
out.unres.append((p.text, p.span))
|
|
901
|
+
|
|
902
|
+
def _ident_by_key(self, key: str) -> DomainIdentity:
|
|
903
|
+
for acc in self.sink.entities.values():
|
|
904
|
+
if acc.entity.identity.logical_key() == key:
|
|
905
|
+
return acc.entity.identity
|
|
906
|
+
raise KeyError(key)
|
|
907
|
+
|
|
908
|
+
def _expand_view_sources(self, keys: set[str]) -> set[str]:
|
|
909
|
+
"""Expande temp views registradas para as fontes terminais."""
|
|
910
|
+
out: set[str] = set()
|
|
911
|
+
for k in keys:
|
|
912
|
+
vs = self.sink.view_sources.get(k)
|
|
913
|
+
out.update(vs if vs else {k})
|
|
914
|
+
return out
|
|
915
|
+
|
|
916
|
+
def _materialize(
|
|
917
|
+
self, dfv: _Dfv, anchor: str, span: SourceSpan | None
|
|
918
|
+
) -> DomainIdentity:
|
|
919
|
+
"""Cria o ``data_transformation`` + arestas; devolve a identidade."""
|
|
920
|
+
if dfv.is_trivial():
|
|
921
|
+
pk = dfv.parents[0]
|
|
922
|
+
assert isinstance(pk, _PKey)
|
|
923
|
+
return self._ident_by_key(pk.ident_key)
|
|
924
|
+
flat = _Flat()
|
|
925
|
+
self._flatten(dfv, flat)
|
|
926
|
+
ident = self.sink.transform_ident(self.path, anchor)
|
|
927
|
+
key = ident.logical_key()
|
|
928
|
+
evi = self.sink.evi(
|
|
929
|
+
self.path, self.sha, span, notes=f"df:{anchor}"
|
|
930
|
+
)
|
|
931
|
+
operation = (
|
|
932
|
+
"sql" if flat.sql
|
|
933
|
+
else "read" if flat.reads and not flat.pkeys
|
|
934
|
+
else "transform"
|
|
935
|
+
)
|
|
936
|
+
variable = "" if anchor.startswith("expr@") else anchor.split(
|
|
937
|
+
"@", 1
|
|
938
|
+
)[0]
|
|
939
|
+
attrs = {
|
|
940
|
+
"artifact": self.path,
|
|
941
|
+
"variable": variable,
|
|
942
|
+
"operation": operation,
|
|
943
|
+
"line": str(anchor.rsplit("@", 1)[-1]),
|
|
944
|
+
}
|
|
945
|
+
self.sink.add_entity(
|
|
946
|
+
ident, DomainEntityKind.DATA_TRANSFORMATION, anchor,
|
|
947
|
+
path=self.path, span=span, content_hash=self.sha,
|
|
948
|
+
attributes=attrs, evidence=(evi,),
|
|
949
|
+
)
|
|
950
|
+
src_keys: set[str] = set()
|
|
951
|
+
for r in flat.reads:
|
|
952
|
+
src_keys.add(r.ident_key)
|
|
953
|
+
self.sink.add_rel(
|
|
954
|
+
DomainRelationKind.READS_FROM.value, ident,
|
|
955
|
+
path=self.path, to_i=self._ident_by_key(r.ident_key),
|
|
956
|
+
span=r.span, evidence=(evi,),
|
|
957
|
+
)
|
|
958
|
+
for pkey in flat.pkeys:
|
|
959
|
+
if pkey == key:
|
|
960
|
+
continue # auto-aresta não é linhagem
|
|
961
|
+
self.sink.add_rel(
|
|
962
|
+
DomainRelationKind.DERIVES_FROM.value, ident,
|
|
963
|
+
path=self.path,
|
|
964
|
+
to_i=self._ident_by_key(pkey),
|
|
965
|
+
evidence=(evi,),
|
|
966
|
+
)
|
|
967
|
+
src_keys.update(self.sink.sources.get(pkey, set()))
|
|
968
|
+
partial = False
|
|
969
|
+
for text, pspan in flat.unres:
|
|
970
|
+
partial = True
|
|
971
|
+
self.sink.add_rel(
|
|
972
|
+
DomainRelationKind.DERIVES_FROM.value, ident,
|
|
973
|
+
path=self.path, to_text=text,
|
|
974
|
+
span=pspan or span, evidence=(evi,),
|
|
975
|
+
)
|
|
976
|
+
for lit in flat.sql:
|
|
977
|
+
sql_src, sql_unres = delegate_sql_literal(
|
|
978
|
+
self.sink, lit, self.path, self.sha,
|
|
979
|
+
call_evi=evi, call_span=span, t_ident=ident,
|
|
980
|
+
)
|
|
981
|
+
src_keys.update(sql_src)
|
|
982
|
+
partial = partial or sql_unres
|
|
983
|
+
if partial:
|
|
984
|
+
self.sink.set_entity_attr(key, "lineage", "partial")
|
|
985
|
+
self.sink.sources[key] = self._expand_view_sources(src_keys)
|
|
986
|
+
self.transform_idents.append(ident)
|
|
987
|
+
self.sink.bump("transformations")
|
|
988
|
+
return ident
|
|
989
|
+
|
|
990
|
+
# -- avaliação de expressões ----------------------------------------------
|
|
991
|
+
|
|
992
|
+
def _eval(self, node: ast.expr, env: _Env) -> _E:
|
|
993
|
+
handler = _EVAL_DISPATCH.get(type(node))
|
|
994
|
+
if handler is not None:
|
|
995
|
+
return handler(self, node, env)
|
|
996
|
+
# ``"a" + "b"`` e afins foldam em literal — propagação honesta
|
|
997
|
+
lit = _literal_str(node)
|
|
998
|
+
if lit is not None:
|
|
999
|
+
return _Str(lit)
|
|
1000
|
+
# BinOp/Compare/BoolOp/UnaryOp/Lambda/Slice/FormattedValue e
|
|
1001
|
+
# demais — avalia filhos para efeitos colaterais.
|
|
1002
|
+
for child in ast.iter_child_nodes(node):
|
|
1003
|
+
if isinstance(child, ast.expr):
|
|
1004
|
+
self._after_eval(self._eval(child, env), child)
|
|
1005
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1006
|
+
|
|
1007
|
+
# -- handlers de expressão (``_EVAL_DISPATCH``) ----------------------------
|
|
1008
|
+
|
|
1009
|
+
def _e_name(self, node: ast.Name, env: _Env) -> _E:
|
|
1010
|
+
b = env.lookup(node.id)
|
|
1011
|
+
if isinstance(b, _BSession):
|
|
1012
|
+
return _Session()
|
|
1013
|
+
if isinstance(b, _BDf):
|
|
1014
|
+
return _Dfv(parents=[_PKey(b.ident_key)])
|
|
1015
|
+
if isinstance(b, _BWriter):
|
|
1016
|
+
return b.writer
|
|
1017
|
+
if isinstance(b, _BParamDf):
|
|
1018
|
+
return _Dfv(
|
|
1019
|
+
parents=[_PUnres(f"param:{node.id}", self._span(node))]
|
|
1020
|
+
)
|
|
1021
|
+
if isinstance(b, _BStr):
|
|
1022
|
+
return _Str(b.value)
|
|
1023
|
+
if isinstance(b, _BNotDf):
|
|
1024
|
+
return _NotDf()
|
|
1025
|
+
if node.id in self.spark_classes:
|
|
1026
|
+
return _SClass()
|
|
1027
|
+
return _Unknown(node.id, self._span(node))
|
|
1028
|
+
|
|
1029
|
+
def _e_walrus(self, node: ast.NamedExpr, env: _Env) -> _E:
|
|
1030
|
+
v = self._eval(node.value, env)
|
|
1031
|
+
if isinstance(node.target, ast.Name):
|
|
1032
|
+
self._bind_name(node.target.id, v, env, node.target)
|
|
1033
|
+
return v
|
|
1034
|
+
|
|
1035
|
+
def _e_ifexp(self, node: ast.IfExp, env: _Env) -> _E:
|
|
1036
|
+
self._eval(node.test, env)
|
|
1037
|
+
a = self._eval(node.body, env)
|
|
1038
|
+
b = self._eval(node.orelse, env)
|
|
1039
|
+
return self._merge_ifexp(a, b, node)
|
|
1040
|
+
|
|
1041
|
+
def _e_seq(
|
|
1042
|
+
self, node: ast.Tuple | ast.List | ast.Set, env: _Env
|
|
1043
|
+
) -> _E:
|
|
1044
|
+
return _SeqE([self._eval(el, env) for el in node.elts])
|
|
1045
|
+
|
|
1046
|
+
def _e_dict(self, node: ast.Dict, env: _Env) -> _E:
|
|
1047
|
+
for v in node.values:
|
|
1048
|
+
self._after_eval(self._eval(v, env), v)
|
|
1049
|
+
for k in node.keys:
|
|
1050
|
+
if k is not None:
|
|
1051
|
+
self._after_eval(self._eval(k, env), k)
|
|
1052
|
+
return _NotDf()
|
|
1053
|
+
|
|
1054
|
+
def _e_const(self, node: ast.Constant, env: _Env) -> _E:
|
|
1055
|
+
del env
|
|
1056
|
+
if isinstance(node.value, str):
|
|
1057
|
+
return _Str(node.value)
|
|
1058
|
+
return _NotDf()
|
|
1059
|
+
|
|
1060
|
+
def _e_fstr(self, node: ast.JoinedStr, env: _Env) -> _E:
|
|
1061
|
+
for v in node.values:
|
|
1062
|
+
if isinstance(v, ast.FormattedValue):
|
|
1063
|
+
self._after_eval(self._eval(v.value, env), v.value)
|
|
1064
|
+
# f-string sem placeholders é literal estático
|
|
1065
|
+
lit = _literal_str(node)
|
|
1066
|
+
return _Str(lit) if lit is not None else _NotDf()
|
|
1067
|
+
|
|
1068
|
+
def _e_sub(self, node: ast.Subscript, env: _Env) -> _E:
|
|
1069
|
+
self._after_eval(self._eval(node.value, env), node.value)
|
|
1070
|
+
self._after_eval(self._eval(node.slice, env), node.slice)
|
|
1071
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1072
|
+
|
|
1073
|
+
def _e_unwrap(
|
|
1074
|
+
self, node: ast.Await | ast.Starred | ast.YieldFrom, env: _Env
|
|
1075
|
+
) -> _E:
|
|
1076
|
+
# o valor interno é quem conta
|
|
1077
|
+
return self._eval(node.value, env)
|
|
1078
|
+
|
|
1079
|
+
def _e_yield(self, node: ast.Yield, env: _Env) -> _E:
|
|
1080
|
+
if node.value is not None:
|
|
1081
|
+
return self._eval(node.value, env)
|
|
1082
|
+
return _NotDf()
|
|
1083
|
+
|
|
1084
|
+
def _e_comp(
|
|
1085
|
+
self,
|
|
1086
|
+
node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
|
|
1087
|
+
env: _Env,
|
|
1088
|
+
) -> _E:
|
|
1089
|
+
self._comprehension(node, env)
|
|
1090
|
+
return _NotDf()
|
|
1091
|
+
|
|
1092
|
+
def _merge_ifexp(self, a: _E, b: _E, node: ast.IfExp) -> _E:
|
|
1093
|
+
da, db = isinstance(a, _Dfv), isinstance(b, _Dfv)
|
|
1094
|
+
if da and db:
|
|
1095
|
+
assert isinstance(a, _Dfv) and isinstance(b, _Dfv)
|
|
1096
|
+
return _Dfv(parents=[_PAnon(a), _PAnon(b)])
|
|
1097
|
+
if da:
|
|
1098
|
+
assert isinstance(a, _Dfv)
|
|
1099
|
+
return _Dfv(
|
|
1100
|
+
parents=[
|
|
1101
|
+
_PAnon(a), _PUnres(self._txt(node.orelse), None)
|
|
1102
|
+
]
|
|
1103
|
+
)
|
|
1104
|
+
if db:
|
|
1105
|
+
assert isinstance(b, _Dfv)
|
|
1106
|
+
return _Dfv(
|
|
1107
|
+
parents=[
|
|
1108
|
+
_PAnon(b), _PUnres(self._txt(node.body), None)
|
|
1109
|
+
]
|
|
1110
|
+
)
|
|
1111
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1112
|
+
|
|
1113
|
+
def _comprehension(self, node: ast.expr, env: _Env) -> None:
|
|
1114
|
+
for g in node.generators: # type: ignore[attr-defined]
|
|
1115
|
+
self._after_eval(self._eval(g.iter, env), g.iter)
|
|
1116
|
+
for cond in g.ifs:
|
|
1117
|
+
self._after_eval(self._eval(cond, env), cond)
|
|
1118
|
+
if isinstance(node, ast.DictComp):
|
|
1119
|
+
self._after_eval(self._eval(node.key, env), node.key)
|
|
1120
|
+
self._after_eval(self._eval(node.value, env), node.value)
|
|
1121
|
+
else:
|
|
1122
|
+
elt = getattr(node, "elt", None)
|
|
1123
|
+
if elt is not None:
|
|
1124
|
+
self._after_eval(self._eval(elt, env), elt)
|
|
1125
|
+
|
|
1126
|
+
def _attribute(self, node: ast.Attribute, env: _Env) -> _E:
|
|
1127
|
+
# self.spark / cls.spark — sessão por convenção de atributo
|
|
1128
|
+
if (
|
|
1129
|
+
node.attr in _SESSION_ATTRS
|
|
1130
|
+
and isinstance(node.value, ast.Name)
|
|
1131
|
+
and node.value.id in ("self", "cls")
|
|
1132
|
+
):
|
|
1133
|
+
return _Session()
|
|
1134
|
+
base = self._eval(node.value, env)
|
|
1135
|
+
if isinstance(base, _Session):
|
|
1136
|
+
if node.attr in _SESSION_READ_ATTRS:
|
|
1137
|
+
return _Reader()
|
|
1138
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1139
|
+
if isinstance(base, _SClass):
|
|
1140
|
+
return (
|
|
1141
|
+
_SBuilder()
|
|
1142
|
+
if node.attr == "builder"
|
|
1143
|
+
else _Unknown(self._txt(node), self._span(node))
|
|
1144
|
+
)
|
|
1145
|
+
if isinstance(base, _SBuilder):
|
|
1146
|
+
return base
|
|
1147
|
+
if isinstance(base, _Dfv):
|
|
1148
|
+
return self._dfv_attr(base, node)
|
|
1149
|
+
if isinstance(base, _Unknown):
|
|
1150
|
+
return self._unknown_attr(base, node)
|
|
1151
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1152
|
+
|
|
1153
|
+
def _dfv_attr(self, base: _Dfv, node: ast.Attribute) -> _E:
|
|
1154
|
+
if node.attr in ("write", "writeStream"):
|
|
1155
|
+
return _Writer(dfv=base, span=self._span(node))
|
|
1156
|
+
if node.attr == "na":
|
|
1157
|
+
return _NaOps(base)
|
|
1158
|
+
if node.attr in _DF_NON_DF_ATTRS:
|
|
1159
|
+
return _NotDf()
|
|
1160
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1161
|
+
|
|
1162
|
+
def _unknown_attr(self, base: _Unknown, node: ast.Attribute) -> _E:
|
|
1163
|
+
if node.attr in ("write", "writeStream"):
|
|
1164
|
+
return _Writer(
|
|
1165
|
+
dfv=None, unknown_recv=base.text,
|
|
1166
|
+
span=self._span(node),
|
|
1167
|
+
)
|
|
1168
|
+
return _Unknown(
|
|
1169
|
+
f"{base.text}.{node.attr}", self._span(node)
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# -- calls ---------------------------------------------------------------
|
|
1173
|
+
|
|
1174
|
+
def _call(self, node: ast.Call, env: _Env) -> _E:
|
|
1175
|
+
fn = node.func
|
|
1176
|
+
if isinstance(fn, ast.Attribute):
|
|
1177
|
+
recv = self._eval(fn.value, env)
|
|
1178
|
+
return self._method(recv, fn.attr, node, env)
|
|
1179
|
+
if isinstance(fn, ast.Name):
|
|
1180
|
+
if fn.id in self.spark_classes:
|
|
1181
|
+
return _SClass()
|
|
1182
|
+
# função transparente: broadcast(df) → df
|
|
1183
|
+
if fn.id in _TRANSPARENT_FNS and node.args:
|
|
1184
|
+
return self._eval(node.args[0], env)
|
|
1185
|
+
self._sweep_args(node, env)
|
|
1186
|
+
return _Unknown(self._txt(fn), self._span(fn))
|
|
1187
|
+
# callee exótico — ainda assim varre os argumentos
|
|
1188
|
+
self._sweep_args(node, env)
|
|
1189
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1190
|
+
|
|
1191
|
+
def _sweep_args(self, node: ast.Call, env: _Env) -> None:
|
|
1192
|
+
"""Avalia args de call desconhecido — efeitos colaterais contam."""
|
|
1193
|
+
for a in node.args:
|
|
1194
|
+
self._after_eval(self._eval(a, env), a)
|
|
1195
|
+
for kw in node.keywords:
|
|
1196
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1197
|
+
|
|
1198
|
+
def _method(
|
|
1199
|
+
self, recv: _E, method: str, node: ast.Call, env: _Env
|
|
1200
|
+
) -> _E:
|
|
1201
|
+
handler = _METHOD_RECV.get(type(recv))
|
|
1202
|
+
if handler is not None:
|
|
1203
|
+
return handler(self, recv, method, node, env)
|
|
1204
|
+
# _NotDf/_Str/_SeqE — chamada sobre não-DF: nada a propagar
|
|
1205
|
+
self._sweep_args(node, env)
|
|
1206
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1207
|
+
|
|
1208
|
+
def _m_sclass(
|
|
1209
|
+
self, recv: _SClass, method: str, node: ast.Call, env: _Env
|
|
1210
|
+
) -> _E:
|
|
1211
|
+
del recv
|
|
1212
|
+
if method == "builder":
|
|
1213
|
+
return _SBuilder()
|
|
1214
|
+
if method in ("getActiveSession", "active", "newSession"):
|
|
1215
|
+
return _Session()
|
|
1216
|
+
self._sweep_args(node, env)
|
|
1217
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1218
|
+
|
|
1219
|
+
def _m_sbuilder(
|
|
1220
|
+
self, recv: _SBuilder, method: str, node: ast.Call, env: _Env
|
|
1221
|
+
) -> _E:
|
|
1222
|
+
if method == "getOrCreate":
|
|
1223
|
+
return _Session()
|
|
1224
|
+
if method == "appName" and node.args:
|
|
1225
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1226
|
+
if lit:
|
|
1227
|
+
self.app_name = lit.strip()
|
|
1228
|
+
self._sweep_args(node, env)
|
|
1229
|
+
return recv
|
|
1230
|
+
|
|
1231
|
+
def _m_naops(
|
|
1232
|
+
self, recv: _NaOps, method: str, node: ast.Call, env: _Env
|
|
1233
|
+
) -> _E:
|
|
1234
|
+
self._sweep_args(node, env)
|
|
1235
|
+
if method in _NA_METHODS:
|
|
1236
|
+
return recv.dfv
|
|
1237
|
+
return _Unknown(self._txt(node), self._span(node))
|
|
1238
|
+
|
|
1239
|
+
# -- métodos de sessão ------------------------------------------------------
|
|
1240
|
+
|
|
1241
|
+
def _session_method(
|
|
1242
|
+
self, recv: _Session, method: str, node: ast.Call, env: _Env
|
|
1243
|
+
) -> _E:
|
|
1244
|
+
del recv
|
|
1245
|
+
if method in _SESSION_READ_ATTRS:
|
|
1246
|
+
self._sweep_args(node, env)
|
|
1247
|
+
return _Reader()
|
|
1248
|
+
if method == "sql":
|
|
1249
|
+
return self._spark_sql(node, env)
|
|
1250
|
+
if method == "table":
|
|
1251
|
+
return self._read_table(node, env, via="spark.table")
|
|
1252
|
+
if method in ("createDataFrame", "range"):
|
|
1253
|
+
self._sweep_args(node, env)
|
|
1254
|
+
return _Dfv() # dados literais/locais — sem upstream
|
|
1255
|
+
if method in _SESSION_NON_DF:
|
|
1256
|
+
self._sweep_args(node, env)
|
|
1257
|
+
return _NotDf()
|
|
1258
|
+
self._sweep_args(node, env)
|
|
1259
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1260
|
+
|
|
1261
|
+
def _spark_sql(self, node: ast.Call, env: _Env) -> _E:
|
|
1262
|
+
if not node.args:
|
|
1263
|
+
self.sink.diag(
|
|
1264
|
+
"empty-sql", "info",
|
|
1265
|
+
"spark.sql() without positional query", self.path,
|
|
1266
|
+
)
|
|
1267
|
+
self._sweep_args(node, env)
|
|
1268
|
+
return _Dfv()
|
|
1269
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1270
|
+
span = self._span(node)
|
|
1271
|
+
if lit is None:
|
|
1272
|
+
self.sink.diag(
|
|
1273
|
+
"dynamic-sql", "warning",
|
|
1274
|
+
"spark.sql() argument is not a static string literal "
|
|
1275
|
+
"— no table deps fabricated", self.path,
|
|
1276
|
+
)
|
|
1277
|
+
self.sink.bump("sql_dynamic")
|
|
1278
|
+
self._sweep_args(node, env)
|
|
1279
|
+
return _Dfv(
|
|
1280
|
+
parents=[
|
|
1281
|
+
_PUnres(f"sql:{self._txt(node.args[0])}", span)
|
|
1282
|
+
]
|
|
1283
|
+
)
|
|
1284
|
+
self.sink.bump("sql_literals")
|
|
1285
|
+
self._sweep_args(node, env)
|
|
1286
|
+
return _Dfv(
|
|
1287
|
+
sql=[_SqlLit(literal=lit, span=span, line=node.lineno)]
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
def _read_table(self, node: ast.Call, env: _Env, *, via: str) -> _E:
|
|
1291
|
+
"""``spark.table(t)`` / ``spark.read.table(t)`` — catálogo."""
|
|
1292
|
+
self._sweep_args(node, env)
|
|
1293
|
+
if not node.args:
|
|
1294
|
+
self.sink.diag(
|
|
1295
|
+
"missing-source", "info",
|
|
1296
|
+
f"{via}() without table argument", self.path,
|
|
1297
|
+
)
|
|
1298
|
+
return _Dfv()
|
|
1299
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1300
|
+
span = self._span(node.args[0]) or self._span(node)
|
|
1301
|
+
if lit is None:
|
|
1302
|
+
self.sink.diag(
|
|
1303
|
+
"dynamic-source", "warning",
|
|
1304
|
+
f"{via}() table is not a static literal — "
|
|
1305
|
+
"unresolved read recorded", self.path,
|
|
1306
|
+
)
|
|
1307
|
+
return _Dfv(
|
|
1308
|
+
parents=[_PUnres(self._txt(node.args[0]), span)]
|
|
1309
|
+
)
|
|
1310
|
+
parts = split_table_name(lit)
|
|
1311
|
+
if parts is None:
|
|
1312
|
+
self.sink.diag(
|
|
1313
|
+
"dynamic-source", "warning",
|
|
1314
|
+
f"{via}() table name is dynamic: {lit!r}", self.path,
|
|
1315
|
+
)
|
|
1316
|
+
return _Dfv(
|
|
1317
|
+
parents=[_PUnres(f"table:{lit}", span)]
|
|
1318
|
+
)
|
|
1319
|
+
# temp view registrada neste arquivo sombreia o catálogo —
|
|
1320
|
+
# mantém a aresta de leitura na view (que deriva das fontes)
|
|
1321
|
+
tv_key = self.temp_views.get(".".join(parts))
|
|
1322
|
+
if tv_key is not None:
|
|
1323
|
+
return _Dfv(
|
|
1324
|
+
reads=[_Read(ident_key=tv_key, fmt="tempview", span=span)]
|
|
1325
|
+
)
|
|
1326
|
+
ident = self.sink.table_ident(parts)
|
|
1327
|
+
self._emit_table(
|
|
1328
|
+
ident, parts[-1], via=via, roles=("read",),
|
|
1329
|
+
written=lit, span=span,
|
|
1330
|
+
)
|
|
1331
|
+
return _Dfv(
|
|
1332
|
+
reads=[_Read(ident_key=ident.logical_key(), fmt="table",
|
|
1333
|
+
span=span)]
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
# -- métodos de DataFrameReader ------------------------------------------------
|
|
1337
|
+
|
|
1338
|
+
def _reader_method(
|
|
1339
|
+
self, recv: _Reader, method: str, node: ast.Call, env: _Env
|
|
1340
|
+
) -> _E:
|
|
1341
|
+
if method == "format":
|
|
1342
|
+
fmt = self._literal_arg(node.args[0], env) if node.args else None
|
|
1343
|
+
self._sweep_args(node, env)
|
|
1344
|
+
return _Reader(fmt=fmt or recv.fmt)
|
|
1345
|
+
if method in ("schema", "option", "options"):
|
|
1346
|
+
self._sweep_args(node, env)
|
|
1347
|
+
return recv
|
|
1348
|
+
if method == "table":
|
|
1349
|
+
return self._read_table(node, env, via="spark.read.table")
|
|
1350
|
+
if method in _READER_PATH_FORMATS or method == "load":
|
|
1351
|
+
return self._read_path(node, env, recv, method)
|
|
1352
|
+
if method == "jdbc":
|
|
1353
|
+
return self._read_jdbc(node, env)
|
|
1354
|
+
self._sweep_args(node, env)
|
|
1355
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1356
|
+
|
|
1357
|
+
def _read_path(
|
|
1358
|
+
self, node: ast.Call, env: _Env, recv: _Reader, method: str
|
|
1359
|
+
) -> _E:
|
|
1360
|
+
"""``spark.read.parquet|csv|json|…(path)`` e ``.format(f).load(path)``."""
|
|
1361
|
+
self._sweep_args(node, env)
|
|
1362
|
+
fmt = recv.fmt if method == "load" else method
|
|
1363
|
+
if not node.args:
|
|
1364
|
+
self.sink.diag(
|
|
1365
|
+
"missing-source", "info",
|
|
1366
|
+
f"spark.read.{method}() without path — source "
|
|
1367
|
+
"recorded as unresolved", self.path,
|
|
1368
|
+
)
|
|
1369
|
+
return _Dfv(
|
|
1370
|
+
parents=[_PUnres(f"read:{method}()", self._span(node))]
|
|
1371
|
+
)
|
|
1372
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1373
|
+
span = self._span(node.args[0]) or self._span(node)
|
|
1374
|
+
if lit is None:
|
|
1375
|
+
self.sink.diag(
|
|
1376
|
+
"dynamic-source", "warning",
|
|
1377
|
+
f"spark.read.{method}() path is not a static "
|
|
1378
|
+
"literal — unresolved read recorded", self.path,
|
|
1379
|
+
)
|
|
1380
|
+
return _Dfv(
|
|
1381
|
+
parents=[_PUnres(self._txt(node.args[0]), span)]
|
|
1382
|
+
)
|
|
1383
|
+
ident = self.sink.path_ident(lit)
|
|
1384
|
+
self.sink.add_entity(
|
|
1385
|
+
ident, DomainEntityKind.DATA_TABLE, lit.strip(),
|
|
1386
|
+
path=self.path, span=span, content_hash=self.sha,
|
|
1387
|
+
attributes={
|
|
1388
|
+
"storage": "path", "format": fmt,
|
|
1389
|
+
"uri_scheme": uri_scheme(lit),
|
|
1390
|
+
"written_name": lit.strip(), "via": f"spark.read.{method}",
|
|
1391
|
+
},
|
|
1392
|
+
roles=("read",),
|
|
1393
|
+
evidence=(self.sink.evi(
|
|
1394
|
+
self.path, self.sha, span, notes=f"read:{method}"
|
|
1395
|
+
),),
|
|
1396
|
+
)
|
|
1397
|
+
return _Dfv(
|
|
1398
|
+
reads=[_Read(ident_key=ident.logical_key(), fmt=fmt, span=span)]
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
def _read_jdbc(self, node: ast.Call, env: _Env) -> _E:
|
|
1402
|
+
"""``spark.read.jdbc(url, table)`` — o catálogo é o 2º argumento."""
|
|
1403
|
+
self._sweep_args(node, env)
|
|
1404
|
+
if len(node.args) < 2:
|
|
1405
|
+
self.sink.diag(
|
|
1406
|
+
"missing-source", "info",
|
|
1407
|
+
"spark.read.jdbc() without table argument", self.path,
|
|
1408
|
+
)
|
|
1409
|
+
return _Dfv()
|
|
1410
|
+
lit = self._literal_arg(node.args[1], env)
|
|
1411
|
+
span = self._span(node.args[1]) or self._span(node)
|
|
1412
|
+
if lit is None:
|
|
1413
|
+
self.sink.diag(
|
|
1414
|
+
"dynamic-source", "warning",
|
|
1415
|
+
"spark.read.jdbc() table is not a static literal",
|
|
1416
|
+
self.path,
|
|
1417
|
+
)
|
|
1418
|
+
return _Dfv(
|
|
1419
|
+
parents=[_PUnres(self._txt(node.args[1]), span)]
|
|
1420
|
+
)
|
|
1421
|
+
parts = split_table_name(lit)
|
|
1422
|
+
if parts is None:
|
|
1423
|
+
return _Dfv(parents=[_PUnres(f"table:{lit}", span)])
|
|
1424
|
+
ident = self.sink.table_ident(parts)
|
|
1425
|
+
self._emit_table(
|
|
1426
|
+
ident, parts[-1], via="spark.read.jdbc", roles=("read",),
|
|
1427
|
+
written=lit, span=span,
|
|
1428
|
+
)
|
|
1429
|
+
return _Dfv(
|
|
1430
|
+
reads=[_Read(ident_key=ident.logical_key(), fmt="jdbc",
|
|
1431
|
+
span=span)]
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
# -- métodos de DataFrame ---------------------------------------------------
|
|
1435
|
+
|
|
1436
|
+
def _df_method(
|
|
1437
|
+
self, recv: _Dfv, method: str, node: ast.Call, env: _Env
|
|
1438
|
+
) -> _E:
|
|
1439
|
+
if method == "writeTo":
|
|
1440
|
+
return self._write_to(recv, node, env)
|
|
1441
|
+
if method in _TEMP_VIEW_METHODS:
|
|
1442
|
+
return self._temp_view(recv, method, node, env)
|
|
1443
|
+
if method in _DF_TRANSFORMS:
|
|
1444
|
+
parents: list[_Parent] = [_PAnon(recv)]
|
|
1445
|
+
for i, a in enumerate(node.args):
|
|
1446
|
+
e = self._eval(a, env)
|
|
1447
|
+
if i == 0 and method in _DF_ARG_OPS:
|
|
1448
|
+
if isinstance(e, _Dfv):
|
|
1449
|
+
parents.append(_PAnon(e))
|
|
1450
|
+
elif isinstance(e, _Unknown):
|
|
1451
|
+
parents.append(_PUnres(e.text, e.span))
|
|
1452
|
+
else:
|
|
1453
|
+
self._after_eval(e, a)
|
|
1454
|
+
else:
|
|
1455
|
+
self._after_eval(e, a)
|
|
1456
|
+
for kw in node.keywords:
|
|
1457
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1458
|
+
return _Dfv(parents=parents)
|
|
1459
|
+
if method in _DF_NON_DF_CALLS:
|
|
1460
|
+
self._sweep_args(node, env)
|
|
1461
|
+
return _NotDf()
|
|
1462
|
+
# método desconhecido sobre DF conhecido — honesto: não sabemos
|
|
1463
|
+
self._sweep_args(node, env)
|
|
1464
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1465
|
+
|
|
1466
|
+
def _temp_view(
|
|
1467
|
+
self, recv: _Dfv, method: str, node: ast.Call, env: _Env
|
|
1468
|
+
) -> _E:
|
|
1469
|
+
self._sweep_args(node, env)
|
|
1470
|
+
if not node.args:
|
|
1471
|
+
return _NotDf()
|
|
1472
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1473
|
+
if lit is None:
|
|
1474
|
+
self.sink.diag(
|
|
1475
|
+
"dynamic-source", "warning",
|
|
1476
|
+
f"df.{method}() view name is not a static literal",
|
|
1477
|
+
self.path,
|
|
1478
|
+
)
|
|
1479
|
+
return _NotDf()
|
|
1480
|
+
name = lit.strip()
|
|
1481
|
+
if not name:
|
|
1482
|
+
return _NotDf()
|
|
1483
|
+
global_view = "Global" in method
|
|
1484
|
+
parts = ("global_temp", name) if global_view else (name,)
|
|
1485
|
+
flat = _Flat()
|
|
1486
|
+
self._flatten(recv, flat)
|
|
1487
|
+
ident = self.sink.table_ident(parts)
|
|
1488
|
+
evi = self.sink.evi(
|
|
1489
|
+
self.path, self.sha, self._span(node), notes=f"tempview:{name}"
|
|
1490
|
+
)
|
|
1491
|
+
self.sink.add_entity(
|
|
1492
|
+
ident, DomainEntityKind.DATA_VIEW, name,
|
|
1493
|
+
path=self.path, span=self._span(node),
|
|
1494
|
+
content_hash=self.sha,
|
|
1495
|
+
attributes={
|
|
1496
|
+
"temporary": "true", "via": f"df.{method}",
|
|
1497
|
+
"written_name": name,
|
|
1498
|
+
},
|
|
1499
|
+
roles=("declare",), evidence=(evi,),
|
|
1500
|
+
)
|
|
1501
|
+
self.temp_views[".".join(parts)] = ident.logical_key()
|
|
1502
|
+
vsrc: set[str] = set()
|
|
1503
|
+
for r in flat.reads:
|
|
1504
|
+
vsrc.add(r.ident_key)
|
|
1505
|
+
self.sink.add_rel(
|
|
1506
|
+
DomainRelationKind.DERIVES_FROM.value, ident,
|
|
1507
|
+
path=self.path, to_i=self._ident_by_key(r.ident_key),
|
|
1508
|
+
evidence=(evi,),
|
|
1509
|
+
)
|
|
1510
|
+
for pk in flat.pkeys:
|
|
1511
|
+
if pk == ident.logical_key():
|
|
1512
|
+
continue
|
|
1513
|
+
self.sink.add_rel(
|
|
1514
|
+
DomainRelationKind.DERIVES_FROM.value, ident,
|
|
1515
|
+
path=self.path, to_i=self._ident_by_key(pk),
|
|
1516
|
+
evidence=(evi,),
|
|
1517
|
+
)
|
|
1518
|
+
vsrc.update(self.sink.sources.get(pk, set()))
|
|
1519
|
+
self.sink.view_sources[ident.logical_key()] = vsrc
|
|
1520
|
+
self.sink.bump("temp_views")
|
|
1521
|
+
return _NotDf()
|
|
1522
|
+
|
|
1523
|
+
# -- writes -----------------------------------------------------------------
|
|
1524
|
+
|
|
1525
|
+
def _writer_method(
|
|
1526
|
+
self, recv: _Writer, method: str, node: ast.Call, env: _Env
|
|
1527
|
+
) -> _E:
|
|
1528
|
+
if method == "mode":
|
|
1529
|
+
lit = (
|
|
1530
|
+
self._literal_arg(node.args[0], env)
|
|
1531
|
+
if node.args else None
|
|
1532
|
+
)
|
|
1533
|
+
self._sweep_args(node, env)
|
|
1534
|
+
return _Writer(
|
|
1535
|
+
dfv=recv.dfv, unknown_recv=recv.unknown_recv,
|
|
1536
|
+
mode=lit or recv.mode, fmt=recv.fmt, span=recv.span,
|
|
1537
|
+
)
|
|
1538
|
+
if method == "format":
|
|
1539
|
+
lit = (
|
|
1540
|
+
self._literal_arg(node.args[0], env)
|
|
1541
|
+
if node.args else None
|
|
1542
|
+
)
|
|
1543
|
+
self._sweep_args(node, env)
|
|
1544
|
+
return _Writer(
|
|
1545
|
+
dfv=recv.dfv, unknown_recv=recv.unknown_recv,
|
|
1546
|
+
mode=recv.mode, fmt=lit or recv.fmt, span=recv.span,
|
|
1547
|
+
)
|
|
1548
|
+
if method in _WRITER_OPTS:
|
|
1549
|
+
self._sweep_args(node, env)
|
|
1550
|
+
return recv
|
|
1551
|
+
if method in _WRITER_PATH_TERMINALS:
|
|
1552
|
+
self._emit_path_write(recv, node, env, method)
|
|
1553
|
+
return _NotDf()
|
|
1554
|
+
if method in _WRITER_TABLE_TERMINALS:
|
|
1555
|
+
self._emit_table_write(recv, node, env, method)
|
|
1556
|
+
return _NotDf()
|
|
1557
|
+
self._sweep_args(node, env)
|
|
1558
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1559
|
+
|
|
1560
|
+
def _v2_method(
|
|
1561
|
+
self, recv: _WriterV2, method: str, node: ast.Call, env: _Env
|
|
1562
|
+
) -> _E:
|
|
1563
|
+
if method in _V2_OPTS:
|
|
1564
|
+
via = recv.via
|
|
1565
|
+
if method == "using" and node.args:
|
|
1566
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1567
|
+
if lit:
|
|
1568
|
+
via = lit
|
|
1569
|
+
self._sweep_args(node, env)
|
|
1570
|
+
return _WriterV2(
|
|
1571
|
+
dfv=recv.dfv, unknown_recv=recv.unknown_recv,
|
|
1572
|
+
target_lit=recv.target_lit,
|
|
1573
|
+
target_span=recv.target_span, via=via, span=recv.span,
|
|
1574
|
+
)
|
|
1575
|
+
if method in _V2_TERMINALS:
|
|
1576
|
+
self._emit_v2_write(recv, node, env, method)
|
|
1577
|
+
return _NotDf()
|
|
1578
|
+
self._sweep_args(node, env)
|
|
1579
|
+
return _Unknown(self._txt(node.func), self._span(node))
|
|
1580
|
+
|
|
1581
|
+
def _write_to(self, recv: _Dfv, node: ast.Call, env: _Env) -> _E:
|
|
1582
|
+
"""``df.writeTo("cat.t")`` — alvo é o 1º argumento."""
|
|
1583
|
+
target_lit: str | None = None
|
|
1584
|
+
target_span: SourceSpan | None = None
|
|
1585
|
+
if node.args:
|
|
1586
|
+
target_lit = self._literal_arg(node.args[0], env)
|
|
1587
|
+
target_span = self._span(node.args[0])
|
|
1588
|
+
for a in node.args[1:]:
|
|
1589
|
+
self._after_eval(self._eval(a, env), a)
|
|
1590
|
+
for kw in node.keywords:
|
|
1591
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1592
|
+
return _WriterV2(
|
|
1593
|
+
dfv=recv, target_lit=target_lit, target_span=target_span,
|
|
1594
|
+
span=self._span(node),
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
def _emit_write_base(
|
|
1598
|
+
self, dfv: _Dfv | None, unknown_recv: str, node: ast.Call
|
|
1599
|
+
) -> tuple[DomainIdentity, set[str]]:
|
|
1600
|
+
"""Materializa o receiver do write → (identidade T, fontes)."""
|
|
1601
|
+
span = self._span(node)
|
|
1602
|
+
if dfv is not None:
|
|
1603
|
+
ident = self._materialize(dfv, f"expr@{node.lineno}", span)
|
|
1604
|
+
return ident, self.sink.sources.get(ident.logical_key(), set())
|
|
1605
|
+
self.sink.diag(
|
|
1606
|
+
"unresolved-dataframe", "info",
|
|
1607
|
+
f"write on unresolved receiver {unknown_recv!r} — "
|
|
1608
|
+
"partial lineage", self.path,
|
|
1609
|
+
)
|
|
1610
|
+
ident = self.sink.transform_ident(
|
|
1611
|
+
self.path, f"expr@{node.lineno}"
|
|
1612
|
+
)
|
|
1613
|
+
evi = self.sink.evi(
|
|
1614
|
+
self.path, self.sha, span, notes="df:write"
|
|
1615
|
+
)
|
|
1616
|
+
self.sink.add_entity(
|
|
1617
|
+
ident, DomainEntityKind.DATA_TRANSFORMATION,
|
|
1618
|
+
f"expr@{node.lineno}", path=self.path, span=span,
|
|
1619
|
+
content_hash=self.sha,
|
|
1620
|
+
attributes={
|
|
1621
|
+
"artifact": self.path, "operation": "write",
|
|
1622
|
+
"line": str(node.lineno), "lineage": "partial",
|
|
1623
|
+
},
|
|
1624
|
+
evidence=(evi,),
|
|
1625
|
+
)
|
|
1626
|
+
self.sink.add_rel(
|
|
1627
|
+
DomainRelationKind.DERIVES_FROM.value, ident,
|
|
1628
|
+
path=self.path, to_text=unknown_recv,
|
|
1629
|
+
span=span, evidence=(evi,),
|
|
1630
|
+
)
|
|
1631
|
+
self.transform_idents.append(ident)
|
|
1632
|
+
return ident, set()
|
|
1633
|
+
|
|
1634
|
+
def _emit_path_write(
|
|
1635
|
+
self, recv: _Writer, node: ast.Call, env: _Env, method: str
|
|
1636
|
+
) -> None:
|
|
1637
|
+
for a in node.args[1:]:
|
|
1638
|
+
self._after_eval(self._eval(a, env), a)
|
|
1639
|
+
for kw in node.keywords:
|
|
1640
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1641
|
+
tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
|
|
1642
|
+
span = self._span(node)
|
|
1643
|
+
evi = self.sink.evi(self.path, self.sha, span, notes="df:write")
|
|
1644
|
+
if not node.args:
|
|
1645
|
+
self.sink.diag(
|
|
1646
|
+
"missing-target", "info",
|
|
1647
|
+
f"df.write.{method}() without target", self.path,
|
|
1648
|
+
)
|
|
1649
|
+
return
|
|
1650
|
+
lit = self._literal_arg(node.args[0], env)
|
|
1651
|
+
if lit is None:
|
|
1652
|
+
self.sink.diag(
|
|
1653
|
+
"dynamic-write-target", "warning",
|
|
1654
|
+
f"df.write.{method}() target is not a static literal",
|
|
1655
|
+
self.path,
|
|
1656
|
+
)
|
|
1657
|
+
self.sink.add_rel(
|
|
1658
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1659
|
+
path=self.path, to_text=self._txt(node.args[0]),
|
|
1660
|
+
span=span, evidence=(evi,),
|
|
1661
|
+
)
|
|
1662
|
+
return
|
|
1663
|
+
fmt = method if method != "save" else (recv.fmt or "save")
|
|
1664
|
+
ident = self.sink.path_ident(lit)
|
|
1665
|
+
arg_span = self._span(node.args[0]) or span
|
|
1666
|
+
self.sink.add_entity(
|
|
1667
|
+
ident, DomainEntityKind.DATA_TABLE, lit.strip(),
|
|
1668
|
+
path=self.path, span=arg_span, content_hash=self.sha,
|
|
1669
|
+
attributes={
|
|
1670
|
+
"storage": "path", "format": fmt,
|
|
1671
|
+
"uri_scheme": uri_scheme(lit),
|
|
1672
|
+
"written_name": lit.strip(), "via": f"df.write.{method}",
|
|
1673
|
+
},
|
|
1674
|
+
roles=("write",),
|
|
1675
|
+
evidence=(self.sink.evi(
|
|
1676
|
+
self.path, self.sha, arg_span, notes="df:write.target"
|
|
1677
|
+
),),
|
|
1678
|
+
)
|
|
1679
|
+
self._write_edges(tid, ident, srcs, span, evi)
|
|
1680
|
+
|
|
1681
|
+
def _emit_table_write(
|
|
1682
|
+
self, recv: _Writer, node: ast.Call, env: _Env, method: str
|
|
1683
|
+
) -> None:
|
|
1684
|
+
# jdbc(url, table) usa o 2º argumento; demais usam o 1º
|
|
1685
|
+
arg_idx = 1 if method == "jdbc" else 0
|
|
1686
|
+
for i, a in enumerate(node.args):
|
|
1687
|
+
if i != arg_idx:
|
|
1688
|
+
self._after_eval(self._eval(a, env), a)
|
|
1689
|
+
for kw in node.keywords:
|
|
1690
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1691
|
+
tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
|
|
1692
|
+
span = self._span(node)
|
|
1693
|
+
evi = self.sink.evi(self.path, self.sha, span, notes="df:write")
|
|
1694
|
+
if len(node.args) <= arg_idx:
|
|
1695
|
+
self.sink.diag(
|
|
1696
|
+
"missing-target", "info",
|
|
1697
|
+
f"df.write.{method}() without target", self.path,
|
|
1698
|
+
)
|
|
1699
|
+
return
|
|
1700
|
+
lit = self._literal_arg(node.args[arg_idx], env)
|
|
1701
|
+
if lit is None:
|
|
1702
|
+
self.sink.diag(
|
|
1703
|
+
"dynamic-write-target", "warning",
|
|
1704
|
+
f"df.write.{method}() target is not a static literal",
|
|
1705
|
+
self.path,
|
|
1706
|
+
)
|
|
1707
|
+
self.sink.add_rel(
|
|
1708
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1709
|
+
path=self.path,
|
|
1710
|
+
to_text=self._txt(node.args[arg_idx]),
|
|
1711
|
+
span=span, evidence=(evi,),
|
|
1712
|
+
)
|
|
1713
|
+
return
|
|
1714
|
+
parts = split_table_name(lit)
|
|
1715
|
+
if parts is None:
|
|
1716
|
+
self.sink.add_rel(
|
|
1717
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1718
|
+
path=self.path, to_text=f"table:{lit}",
|
|
1719
|
+
span=span, evidence=(evi,),
|
|
1720
|
+
)
|
|
1721
|
+
return
|
|
1722
|
+
ident = self.sink.table_ident(parts)
|
|
1723
|
+
arg_span = self._span(node.args[arg_idx]) or span
|
|
1724
|
+
self._emit_table(
|
|
1725
|
+
ident, parts[-1], via=f"df.write.{method}", roles=("write",),
|
|
1726
|
+
mode=recv.mode, written=lit, span=arg_span,
|
|
1727
|
+
)
|
|
1728
|
+
self._write_edges(tid, ident, srcs, span, evi)
|
|
1729
|
+
|
|
1730
|
+
def _emit_v2_write(
|
|
1731
|
+
self, recv: _WriterV2, node: ast.Call, env: _Env, method: str
|
|
1732
|
+
) -> None:
|
|
1733
|
+
self._sweep_args(node, env)
|
|
1734
|
+
tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
|
|
1735
|
+
span = self._span(node)
|
|
1736
|
+
evi = self.sink.evi(self.path, self.sha, span, notes="df:writeTo")
|
|
1737
|
+
lit = recv.target_lit
|
|
1738
|
+
if lit is None:
|
|
1739
|
+
self.sink.diag(
|
|
1740
|
+
"dynamic-write-target", "warning",
|
|
1741
|
+
f"df.writeTo().{method}() target is not a static "
|
|
1742
|
+
"literal", self.path,
|
|
1743
|
+
)
|
|
1744
|
+
self.sink.add_rel(
|
|
1745
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1746
|
+
path=self.path, to_text="writeTo(<dynamic>)",
|
|
1747
|
+
span=span, evidence=(evi,),
|
|
1748
|
+
)
|
|
1749
|
+
return
|
|
1750
|
+
parts = split_table_name(lit)
|
|
1751
|
+
if parts is None:
|
|
1752
|
+
self.sink.add_rel(
|
|
1753
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1754
|
+
path=self.path, to_text=f"table:{lit}",
|
|
1755
|
+
span=span, evidence=(evi,),
|
|
1756
|
+
)
|
|
1757
|
+
return
|
|
1758
|
+
ident = self.sink.table_ident(parts)
|
|
1759
|
+
tgt_span = recv.target_span or span
|
|
1760
|
+
self.sink.add_entity(
|
|
1761
|
+
ident, DomainEntityKind.ICEBERG_TABLE, parts[-1],
|
|
1762
|
+
path=self.path, span=tgt_span,
|
|
1763
|
+
content_hash=self.sha,
|
|
1764
|
+
attributes={
|
|
1765
|
+
"api": "df.writeTo", "written_name": lit.strip(),
|
|
1766
|
+
"using": recv.via, "op": method,
|
|
1767
|
+
},
|
|
1768
|
+
roles=("write",),
|
|
1769
|
+
evidence=(self.sink.evi(
|
|
1770
|
+
self.path, self.sha, tgt_span, notes="df:writeTo.target"
|
|
1771
|
+
),),
|
|
1772
|
+
)
|
|
1773
|
+
self._write_edges(tid, ident, srcs, span, evi)
|
|
1774
|
+
|
|
1775
|
+
def _write_edges(
|
|
1776
|
+
self,
|
|
1777
|
+
tid: DomainIdentity,
|
|
1778
|
+
sink_ident: DomainIdentity,
|
|
1779
|
+
srcs: set[str],
|
|
1780
|
+
span: SourceSpan | None,
|
|
1781
|
+
evi: int,
|
|
1782
|
+
) -> None:
|
|
1783
|
+
self.sink.add_rel(
|
|
1784
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1785
|
+
path=self.path, to_i=sink_ident,
|
|
1786
|
+
span=span, evidence=(evi,),
|
|
1787
|
+
)
|
|
1788
|
+
skey = sink_ident.logical_key()
|
|
1789
|
+
for sk in sorted(srcs):
|
|
1790
|
+
if sk == skey:
|
|
1791
|
+
continue # auto-derivação não é aresta
|
|
1792
|
+
self.sink.add_rel(
|
|
1793
|
+
DomainRelationKind.DERIVES_FROM.value, sink_ident,
|
|
1794
|
+
path=self.path, to_i=self._ident_by_key(sk),
|
|
1795
|
+
span=span, evidence=(evi,),
|
|
1796
|
+
)
|
|
1797
|
+
self.sink.bump("writes")
|
|
1798
|
+
|
|
1799
|
+
# -- desconhecido ------------------------------------------------------------
|
|
1800
|
+
|
|
1801
|
+
def _unknown_method(
|
|
1802
|
+
self, recv: _Unknown, method: str, node: ast.Call, env: _Env
|
|
1803
|
+
) -> _E:
|
|
1804
|
+
if method == "writeTo":
|
|
1805
|
+
target_lit = (
|
|
1806
|
+
self._literal_arg(node.args[0], env)
|
|
1807
|
+
if node.args else None
|
|
1808
|
+
)
|
|
1809
|
+
for a in node.args[1:]:
|
|
1810
|
+
self._after_eval(self._eval(a, env), a)
|
|
1811
|
+
for kw in node.keywords:
|
|
1812
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1813
|
+
return _WriterV2(
|
|
1814
|
+
dfv=None, unknown_recv=recv.text,
|
|
1815
|
+
target_lit=target_lit,
|
|
1816
|
+
target_span=(
|
|
1817
|
+
self._span(node.args[0]) if node.args else None
|
|
1818
|
+
),
|
|
1819
|
+
span=self._span(node),
|
|
1820
|
+
)
|
|
1821
|
+
if method in _DF_TRANSFORMS:
|
|
1822
|
+
self.sink.diag(
|
|
1823
|
+
"unresolved-dataframe", "info",
|
|
1824
|
+
f"DataFrame op {method!r} on unresolved receiver "
|
|
1825
|
+
f"{recv.text!r} — partial lineage", self.path,
|
|
1826
|
+
)
|
|
1827
|
+
parents: list[_Parent] = [_PUnres(recv.text, recv.span)]
|
|
1828
|
+
for i, a in enumerate(node.args):
|
|
1829
|
+
e = self._eval(a, env)
|
|
1830
|
+
if i == 0 and method in _DF_ARG_OPS:
|
|
1831
|
+
if isinstance(e, _Dfv):
|
|
1832
|
+
parents.append(_PAnon(e))
|
|
1833
|
+
elif isinstance(e, _Unknown):
|
|
1834
|
+
parents.append(_PUnres(e.text, e.span))
|
|
1835
|
+
else:
|
|
1836
|
+
self._after_eval(e, a)
|
|
1837
|
+
else:
|
|
1838
|
+
self._after_eval(e, a)
|
|
1839
|
+
for kw in node.keywords:
|
|
1840
|
+
self._after_eval(self._eval(kw.value, env), kw.value)
|
|
1841
|
+
return _Dfv(parents=parents)
|
|
1842
|
+
self._sweep_args(node, env)
|
|
1843
|
+
return _Unknown(
|
|
1844
|
+
f"{recv.text}.{method}", self._span(node)
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
# -- pós-avaliação --------------------------------------------------------------
|
|
1848
|
+
|
|
1849
|
+
def _after_eval(self, value: _E, node: ast.AST | None) -> None:
|
|
1850
|
+
"""Materializa valores-DF "interessantes" achados soltos."""
|
|
1851
|
+
if isinstance(value, _Dfv):
|
|
1852
|
+
if value.is_interesting():
|
|
1853
|
+
line = getattr(node, "lineno", 0) if node is not None else 0
|
|
1854
|
+
self._materialize(
|
|
1855
|
+
value, f"expr@{line}",
|
|
1856
|
+
self._span(node) if node is not None else None,
|
|
1857
|
+
)
|
|
1858
|
+
elif isinstance(value, (_Writer, _WriterV2)):
|
|
1859
|
+
self.sink.diag(
|
|
1860
|
+
"write-never-executed", "info",
|
|
1861
|
+
"DataFrameWriter built but no terminal op observed "
|
|
1862
|
+
"— no write emitted", self.path,
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
# -- emissão compartilhada -------------------------------------------------
|
|
1866
|
+
|
|
1867
|
+
def _emit_table(
|
|
1868
|
+
self, ident: DomainIdentity, name: str, *, via: str,
|
|
1869
|
+
roles: tuple[str, ...], written: str,
|
|
1870
|
+
span: SourceSpan | None,
|
|
1871
|
+
mode: str = "",
|
|
1872
|
+
) -> None:
|
|
1873
|
+
attrs = {
|
|
1874
|
+
"storage": "catalog", "via": via, "written_name": written,
|
|
1875
|
+
}
|
|
1876
|
+
if mode:
|
|
1877
|
+
attrs["write_mode"] = mode
|
|
1878
|
+
self.sink.add_entity(
|
|
1879
|
+
ident, DomainEntityKind.DATA_TABLE, name,
|
|
1880
|
+
path=self.path, span=span, content_hash=self.sha,
|
|
1881
|
+
attributes=attrs, roles=roles,
|
|
1882
|
+
evidence=(self.sink.evi(self.path, self.sha, span,
|
|
1883
|
+
notes=f"table:{name}"),),
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
# -- tabelas de dispatch -----------------------------------------------------
|
|
1888
|
+
# ``Any`` no parâmetro do nó (mesmo desenho de ``sql.extract._STMT_HANDLERS``):
|
|
1889
|
+
# a assinatura uniforme permite narrowings diferentes por handler.
|
|
1890
|
+
|
|
1891
|
+
_STMT_DISPATCH: dict[type[ast.stmt], Callable[[_Walker, Any, _Env], None]] = {
|
|
1892
|
+
ast.FunctionDef: _Walker._function,
|
|
1893
|
+
ast.AsyncFunctionDef: _Walker._function,
|
|
1894
|
+
ast.ClassDef: _Walker._st_class,
|
|
1895
|
+
ast.If: _Walker._st_if,
|
|
1896
|
+
ast.For: _Walker._st_for,
|
|
1897
|
+
ast.AsyncFor: _Walker._st_for,
|
|
1898
|
+
ast.While: _Walker._st_while,
|
|
1899
|
+
ast.With: _Walker._st_with,
|
|
1900
|
+
ast.AsyncWith: _Walker._st_with,
|
|
1901
|
+
ast.Try: _Walker._st_try,
|
|
1902
|
+
ast.TryStar: _Walker._st_try,
|
|
1903
|
+
ast.Return: _Walker._st_return,
|
|
1904
|
+
ast.Raise: _Walker._st_raise,
|
|
1905
|
+
ast.Delete: _Walker._st_delete,
|
|
1906
|
+
ast.Match: _Walker._st_match,
|
|
1907
|
+
ast.Assert: _Walker._st_assert,
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
_EVAL_DISPATCH: dict[type[ast.expr], Callable[[_Walker, Any, _Env], _E]] = {
|
|
1911
|
+
ast.Call: _Walker._call,
|
|
1912
|
+
ast.Name: _Walker._e_name,
|
|
1913
|
+
ast.Attribute: _Walker._attribute,
|
|
1914
|
+
ast.NamedExpr: _Walker._e_walrus,
|
|
1915
|
+
ast.IfExp: _Walker._e_ifexp,
|
|
1916
|
+
ast.Tuple: _Walker._e_seq,
|
|
1917
|
+
ast.List: _Walker._e_seq,
|
|
1918
|
+
ast.Set: _Walker._e_seq,
|
|
1919
|
+
ast.Dict: _Walker._e_dict,
|
|
1920
|
+
ast.Constant: _Walker._e_const,
|
|
1921
|
+
ast.JoinedStr: _Walker._e_fstr,
|
|
1922
|
+
ast.Subscript: _Walker._e_sub,
|
|
1923
|
+
ast.Await: _Walker._e_unwrap,
|
|
1924
|
+
ast.Starred: _Walker._e_unwrap,
|
|
1925
|
+
ast.YieldFrom: _Walker._e_unwrap,
|
|
1926
|
+
ast.Yield: _Walker._e_yield,
|
|
1927
|
+
ast.ListComp: _Walker._e_comp,
|
|
1928
|
+
ast.SetComp: _Walker._e_comp,
|
|
1929
|
+
ast.DictComp: _Walker._e_comp,
|
|
1930
|
+
ast.GeneratorExp: _Walker._e_comp,
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
_METHOD_RECV: dict[type[_E], Callable[[_Walker, Any, str, ast.Call, _Env], _E]] = {
|
|
1934
|
+
_Session: _Walker._session_method,
|
|
1935
|
+
_SClass: _Walker._m_sclass,
|
|
1936
|
+
_SBuilder: _Walker._m_sbuilder,
|
|
1937
|
+
_Reader: _Walker._reader_method,
|
|
1938
|
+
_Dfv: _Walker._df_method,
|
|
1939
|
+
_NaOps: _Walker._m_naops,
|
|
1940
|
+
_Writer: _Walker._writer_method,
|
|
1941
|
+
_WriterV2: _Walker._v2_method,
|
|
1942
|
+
_Unknown: _Walker._unknown_method,
|
|
1943
|
+
}
|