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,2220 @@
|
|
|
1
|
+
"""Extração estática de scripts ``.py`` AWS-data (lf-0113, Phase 5).
|
|
2
|
+
|
|
3
|
+
Análise por ``ast`` (stdlib) — nenhuma execução do script, nenhum SDK:
|
|
4
|
+
|
|
5
|
+
- **Glue**: ``GlueContext``/``awsglue`` marcam o script;
|
|
6
|
+
``create_dynamic_frame.from_catalog``/``getDataFrame`` →
|
|
7
|
+
``READS_FROM`` de ``data_table`` de catálogo; ``from_options``/
|
|
8
|
+
``getSource`` → ``READS_FROM`` de path ``s3://`` (ou referência
|
|
9
|
+
externa a conexão nomeada); ``write_dynamic_frame.*``/
|
|
10
|
+
``write_from_options``/``getSink`` → ``WRITES_TO``;
|
|
11
|
+
``job.init``/``job.commit``/``getResolvedOptions`` viram atributos
|
|
12
|
+
(bookmarks/args observados — nomes de opções, nunca valores
|
|
13
|
+
resolvidos em runtime);
|
|
14
|
+
- **Spark/Iceberg**: ``spark.sql(<literal>)``/``session.sql``/``execute``
|
|
15
|
+
com literal SQL → linhagem via ``sqlbridge`` (transformações
|
|
16
|
+
``path@L<linha>#<stmt>``); ``df.writeTo("c.n.t")`` seguido de
|
|
17
|
+
``.using("iceberg")`` ou catálogo ``spark.sql.catalog.<c>`` com impl
|
|
18
|
+
iceberg → ``iceberg_table``; ``insertInto``/``saveAsTable``/
|
|
19
|
+
``spark.table``/``spark.read.<fmt>(s3|nome)`` → arestas diretas;
|
|
20
|
+
``MERGE``/``INSERT``/``UPDATE``/``DELETE`` dentro de literais vêm do
|
|
21
|
+
parser sql;
|
|
22
|
+
- **Athena SDK**: ``start_query_execution(QueryString=<literal>)`` →
|
|
23
|
+
mesmo pipeline SQL com ``USE`` sintético só quando
|
|
24
|
+
``QueryExecutionContext`` declara Database/Catalog; ``OutputLocation``
|
|
25
|
+
→ ``WRITES_TO`` s3;
|
|
26
|
+
- **IAM**: ``create_job``/literal ``arn:aws:iam::…:role/x`` →
|
|
27
|
+
``iam_role`` entity + ``ASSUMES_ROLE`` (external — o role vive na
|
|
28
|
+
conta, fora do snapshot); ARN solto sem contexto de papel →
|
|
29
|
+
``REFERENCES`` external;
|
|
30
|
+
- **S3**: literal ``s3://`` fora de call reconhecida → ``REFERENCES``
|
|
31
|
+
(direção desconhecida — nunca inventar leitura/escrita);
|
|
32
|
+
- identidade conta/região: ``catalog_id`` literal vira
|
|
33
|
+
``aws:glue:<acct>``; segmentos ausentes ficam vazios — identidades
|
|
34
|
+
sem conta NÃO colidem com as que a têm.
|
|
35
|
+
|
|
36
|
+
Scripts sem marcadores AWS-data → diagnóstico ``not-aws-data-artifact``
|
|
37
|
+
e zero fatos. ``SyntaxError`` → ``py-parse-error`` + varredura
|
|
38
|
+
degradada por ``tokenize`` apenas de literais ``s3://``/ARN (modo
|
|
39
|
+
degradado declarado, nunca estrutura inventada).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import ast
|
|
45
|
+
import io
|
|
46
|
+
import posixpath
|
|
47
|
+
import re
|
|
48
|
+
import tokenize
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from pathlib import PurePosixPath
|
|
51
|
+
|
|
52
|
+
from code_atlas.contracts.enums import ResolutionStatus
|
|
53
|
+
from code_atlas.domains.aws.common import (
|
|
54
|
+
ARN_RE,
|
|
55
|
+
ICEBERG_META,
|
|
56
|
+
aws_catalog,
|
|
57
|
+
is_iam_role_arn,
|
|
58
|
+
is_iceberg_impl,
|
|
59
|
+
line_starts,
|
|
60
|
+
node_span,
|
|
61
|
+
parse_arn,
|
|
62
|
+
parse_s3_uri,
|
|
63
|
+
redact_if_sensitive,
|
|
64
|
+
split_table_name,
|
|
65
|
+
)
|
|
66
|
+
from code_atlas.domains.aws.emit import Sink
|
|
67
|
+
from code_atlas.domains.aws.sqlbridge import (
|
|
68
|
+
extract_sql_fragment,
|
|
69
|
+
literal_span_remap,
|
|
70
|
+
s3_entity,
|
|
71
|
+
)
|
|
72
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
73
|
+
from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
|
|
74
|
+
from code_atlas.domains.sql.adapter import looks_like_sql
|
|
75
|
+
from code_atlas.parsers.contracts import SourceSpan
|
|
76
|
+
|
|
77
|
+
_READS = str(DomainRelationKind.READS_FROM)
|
|
78
|
+
_WRITES = str(DomainRelationKind.WRITES_TO)
|
|
79
|
+
_REFS = str(DomainRelationKind.REFERENCES)
|
|
80
|
+
_ASSUMES = str(DomainRelationKind.ASSUMES_ROLE)
|
|
81
|
+
|
|
82
|
+
# Cadeias de atributos que denotam leitura/escrita no catálogo Glue.
|
|
83
|
+
_READ_CATALOG_SUFFIXES = (
|
|
84
|
+
"create_dynamic_frame.from_catalog",
|
|
85
|
+
"create_dynamic_frame_from_catalog",
|
|
86
|
+
"getDataFrame",
|
|
87
|
+
"get_data_frame",
|
|
88
|
+
)
|
|
89
|
+
_READ_OPTIONS_SUFFIXES = (
|
|
90
|
+
"create_dynamic_frame.from_options",
|
|
91
|
+
"create_dynamic_frame_from_options",
|
|
92
|
+
"getSource",
|
|
93
|
+
"get_source",
|
|
94
|
+
)
|
|
95
|
+
_WRITE_CATALOG_SUFFIXES = (
|
|
96
|
+
"write_dynamic_frame.from_catalog",
|
|
97
|
+
"write_dynamic_frame_from_catalog",
|
|
98
|
+
)
|
|
99
|
+
_WRITE_OPTIONS_SUFFIXES = (
|
|
100
|
+
"write_dynamic_frame.from_options",
|
|
101
|
+
"write_dynamic_frame_from_options",
|
|
102
|
+
"write_from_options",
|
|
103
|
+
"write.from_options",
|
|
104
|
+
"getSink",
|
|
105
|
+
"get_sink",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# receivers aceitos para ``X.sql(...)``/``X.table(...)`` etc. — reduz
|
|
109
|
+
# falsos positivos de ``session.sql`` fora do contexto Spark.
|
|
110
|
+
_SPARK_ROOTS = re.compile(
|
|
111
|
+
r"^(spark|sparkSession|spark_session|sqlContext|sql_context"
|
|
112
|
+
r"|sparkSessionBuilder|glueContext|glue_context|glue_context"
|
|
113
|
+
r"|sparkContext|sc|sess|session|emr|sparky)$",
|
|
114
|
+
re.IGNORECASE,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
_ICEBERG_FORMATS = frozenset({"iceberg"})
|
|
118
|
+
_SPARK_READ_FMTS = frozenset({"load", "parquet", "json", "csv", "orc", "avro", "text"})
|
|
119
|
+
_WRITE_TERMINALS = frozenset(
|
|
120
|
+
{
|
|
121
|
+
"append",
|
|
122
|
+
"overwritePartitions",
|
|
123
|
+
"overwrite",
|
|
124
|
+
"replace",
|
|
125
|
+
"create",
|
|
126
|
+
"createOrReplace",
|
|
127
|
+
"insertInto",
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class _PyFacts:
|
|
134
|
+
"""Observações de primeira passada de um script ``.py``."""
|
|
135
|
+
|
|
136
|
+
tree: ast.AST | None = None
|
|
137
|
+
parents: dict[int, ast.AST] = field(default_factory=dict)
|
|
138
|
+
calls: list[ast.Call] = field(default_factory=list)
|
|
139
|
+
assigns: dict[str, str] = field(default_factory=dict)
|
|
140
|
+
str_nodes: list[ast.Constant] = field(default_factory=list)
|
|
141
|
+
markers: set[str] = field(default_factory=set)
|
|
142
|
+
spark_vars: set[str] = field(default_factory=set)
|
|
143
|
+
declared_catalogs: dict[str, str] = field(default_factory=dict)
|
|
144
|
+
job_inits: list[ast.Call] = field(default_factory=list)
|
|
145
|
+
job_commits: list[ast.Call] = field(default_factory=list)
|
|
146
|
+
resolved_options: list[tuple[str, ...]] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _dotted(node: ast.AST) -> str:
|
|
150
|
+
"""Caminho pontilhado do callee/receiver (``a.b.c`` → ``"a.b.c"``).
|
|
151
|
+
|
|
152
|
+
Desce por ``Call.func`` — ``spark.read.format("x").load`` forma a
|
|
153
|
+
cadeia ``spark.read.format.load``.
|
|
154
|
+
"""
|
|
155
|
+
parts: list[str] = []
|
|
156
|
+
cur: ast.AST = node
|
|
157
|
+
seen = 0
|
|
158
|
+
while seen < 32:
|
|
159
|
+
seen += 1
|
|
160
|
+
if isinstance(cur, ast.Attribute):
|
|
161
|
+
parts.append(cur.attr)
|
|
162
|
+
cur = cur.value
|
|
163
|
+
continue
|
|
164
|
+
if isinstance(cur, ast.Call):
|
|
165
|
+
cur = cur.func
|
|
166
|
+
continue
|
|
167
|
+
if isinstance(cur, ast.Name):
|
|
168
|
+
parts.append(cur.id)
|
|
169
|
+
break
|
|
170
|
+
parts.reverse()
|
|
171
|
+
return ".".join(parts)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _call_root(call: ast.Call) -> str:
|
|
175
|
+
"""Nome raiz do receiver — ``spark.read.format("x").load`` → spark."""
|
|
176
|
+
func = call.func
|
|
177
|
+
if isinstance(func, ast.Attribute):
|
|
178
|
+
return _dotted(func.value).split(".")[0]
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _str_lit(node: ast.AST | None) -> str | None:
|
|
183
|
+
"""Valor de ``ast.Constant`` str; ``None`` para JoinedStr/dinâmico."""
|
|
184
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
185
|
+
return node.value
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _kw(call: ast.Call, name: str) -> ast.AST | None:
|
|
190
|
+
for kw in call.keywords:
|
|
191
|
+
if kw.arg == name:
|
|
192
|
+
return kw.value
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _kw_lit(call: ast.Call, name: str) -> str | None:
|
|
197
|
+
return _str_lit(_kw(call, name))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pos_lit(call: ast.Call, i: int) -> str | None:
|
|
201
|
+
if i < len(call.args):
|
|
202
|
+
return _str_lit(call.args[i])
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _pos_or_kw(call: ast.Call, i: int, name: str) -> str | None:
|
|
207
|
+
"""kwarg ``name`` ou posicional ``i`` — assinaturas Glue/Spark."""
|
|
208
|
+
v = _kw_lit(call, name)
|
|
209
|
+
return v if v is not None else _pos_lit(call, i)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _literal(node: ast.AST | None) -> object | None:
|
|
213
|
+
"""``ast.literal_eval`` seguro — ``None`` para não-literais."""
|
|
214
|
+
if node is None:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
result: object = ast.literal_eval(node)
|
|
218
|
+
return result
|
|
219
|
+
except (ValueError, TypeError, SyntaxError, MemoryError):
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _dict_lit(node: ast.AST | None) -> dict[str, object] | None:
|
|
224
|
+
val = _literal(node)
|
|
225
|
+
if isinstance(val, dict):
|
|
226
|
+
return {str(k): v for k, v in val.items()}
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _Scanner(ast.NodeVisitor):
|
|
231
|
+
"""Primeira passada: coleta calls/strings/marcadores."""
|
|
232
|
+
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
self.fx = _PyFacts()
|
|
235
|
+
|
|
236
|
+
def visit(self, node: ast.AST) -> None:
|
|
237
|
+
for child in ast.iter_child_nodes(node):
|
|
238
|
+
self.fx.parents[id(child)] = node
|
|
239
|
+
super().visit(node)
|
|
240
|
+
|
|
241
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
242
|
+
for a in node.names:
|
|
243
|
+
if a.name.split(".")[0] in ("awsglue", "pyspark", "boto3"):
|
|
244
|
+
self.fx.markers.add(
|
|
245
|
+
{"awsglue": "glue", "pyspark": "spark", "boto3": "aws-sdk"}[
|
|
246
|
+
a.name.split(".")[0]
|
|
247
|
+
]
|
|
248
|
+
)
|
|
249
|
+
self.generic_visit(node)
|
|
250
|
+
|
|
251
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
252
|
+
mod = node.module or ""
|
|
253
|
+
if mod.startswith("awsglue"):
|
|
254
|
+
self.fx.markers.add("glue")
|
|
255
|
+
if mod.startswith("pyspark"):
|
|
256
|
+
self.fx.markers.add("spark")
|
|
257
|
+
if mod.startswith("boto3"):
|
|
258
|
+
self.fx.markers.add("aws-sdk")
|
|
259
|
+
self.generic_visit(node)
|
|
260
|
+
|
|
261
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
262
|
+
if isinstance(node.value, ast.Call):
|
|
263
|
+
dotted = _dotted(node.value.func)
|
|
264
|
+
for tgt in node.targets:
|
|
265
|
+
if isinstance(tgt, ast.Name):
|
|
266
|
+
self.fx.assigns[tgt.id] = dotted
|
|
267
|
+
if dotted.endswith("GlueContext") or ("awsglue" in dotted):
|
|
268
|
+
self.fx.markers.add("glue")
|
|
269
|
+
self.fx.spark_vars.add(tgt.id)
|
|
270
|
+
if "SparkSession" in dotted:
|
|
271
|
+
self.fx.markers.add("spark")
|
|
272
|
+
self.fx.spark_vars.add(tgt.id)
|
|
273
|
+
elif isinstance(node.value, ast.Attribute):
|
|
274
|
+
dotted = _dotted(node.value)
|
|
275
|
+
for tgt in node.targets:
|
|
276
|
+
if isinstance(tgt, ast.Name):
|
|
277
|
+
self.fx.assigns[tgt.id] = dotted
|
|
278
|
+
if dotted.endswith("spark_session"):
|
|
279
|
+
self.fx.spark_vars.add(tgt.id)
|
|
280
|
+
self.fx.markers.add("spark")
|
|
281
|
+
self.generic_visit(node)
|
|
282
|
+
|
|
283
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
284
|
+
self.fx.calls.append(node)
|
|
285
|
+
dotted = _dotted(node.func)
|
|
286
|
+
last = dotted.rsplit(".", 1)[-1]
|
|
287
|
+
if last in ("GlueContext", "Job"):
|
|
288
|
+
self.fx.markers.add("glue")
|
|
289
|
+
if last in ("SparkSession", "getOrCreate"):
|
|
290
|
+
self.fx.markers.add("spark")
|
|
291
|
+
if last == "getResolvedOptions":
|
|
292
|
+
self.fx.markers.add("glue")
|
|
293
|
+
self._options(node)
|
|
294
|
+
if last in ("sql", "writeTo") or (
|
|
295
|
+
last in ("read", "table", "write")
|
|
296
|
+
and (
|
|
297
|
+
_call_root(node) in self.fx.spark_vars or bool(_SPARK_ROOTS.match(_call_root(node)))
|
|
298
|
+
)
|
|
299
|
+
):
|
|
300
|
+
self.fx.markers.add("spark")
|
|
301
|
+
if last in (
|
|
302
|
+
"start_query_execution",
|
|
303
|
+
"create_job",
|
|
304
|
+
"update_job",
|
|
305
|
+
"add_steps",
|
|
306
|
+
"run_job_flow",
|
|
307
|
+
):
|
|
308
|
+
self.fx.markers.add("aws-sdk")
|
|
309
|
+
if last == "init" and self._is_job_call(node):
|
|
310
|
+
self.fx.job_inits.append(node)
|
|
311
|
+
self.fx.markers.add("glue")
|
|
312
|
+
if last in ("commit", "run") and self._is_job_call(node):
|
|
313
|
+
self.fx.job_commits.append(node)
|
|
314
|
+
self.fx.markers.add("glue")
|
|
315
|
+
self.generic_visit(node)
|
|
316
|
+
|
|
317
|
+
def _is_job_call(self, node: ast.Call) -> bool:
|
|
318
|
+
"""``job.init/commit/run`` — receiver ``job``/``Job``-bound."""
|
|
319
|
+
if not isinstance(node.func, ast.Attribute):
|
|
320
|
+
return False
|
|
321
|
+
root = _dotted(node.func.value).split(".")[0]
|
|
322
|
+
return (
|
|
323
|
+
root in self.fx.spark_vars
|
|
324
|
+
or self.fx.assigns.get(root, "").endswith("Job")
|
|
325
|
+
or root == "job"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _options(self, node: ast.Call) -> None:
|
|
329
|
+
"""``getResolvedOptions(sys.argv, [...])`` → nomes observados."""
|
|
330
|
+
if len(node.args) >= 2:
|
|
331
|
+
lst = _literal(node.args[1])
|
|
332
|
+
if isinstance(lst, (list, tuple)):
|
|
333
|
+
self.fx.resolved_options.append(tuple(str(v) for v in lst))
|
|
334
|
+
|
|
335
|
+
def visit_Constant(self, node: ast.Constant) -> None:
|
|
336
|
+
if isinstance(node.value, str):
|
|
337
|
+
self.fx.str_nodes.append(node)
|
|
338
|
+
self.generic_visit(node)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def extract_py(
|
|
342
|
+
sink: Sink,
|
|
343
|
+
*,
|
|
344
|
+
path: str,
|
|
345
|
+
text: str,
|
|
346
|
+
sha: str,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Extrai um script ``.py`` — fatos AWS-data evidence-first."""
|
|
349
|
+
starts = line_starts(text)
|
|
350
|
+
try:
|
|
351
|
+
tree = ast.parse(text)
|
|
352
|
+
except (SyntaxError, ValueError) as exc:
|
|
353
|
+
sink.diag(
|
|
354
|
+
"py-parse-error",
|
|
355
|
+
"error",
|
|
356
|
+
f"Python does not parse: {exc}",
|
|
357
|
+
path,
|
|
358
|
+
)
|
|
359
|
+
_degraded_scan(sink, path=path, text=text, sha=sha, starts=starts)
|
|
360
|
+
return
|
|
361
|
+
sc = _Scanner()
|
|
362
|
+
sc.visit(tree)
|
|
363
|
+
facts = sc.fx
|
|
364
|
+
facts.tree = tree
|
|
365
|
+
|
|
366
|
+
if not facts.markers and not _has_aws_observations(facts):
|
|
367
|
+
sink.diag(
|
|
368
|
+
"not-aws-data-artifact",
|
|
369
|
+
"info",
|
|
370
|
+
"no Glue/Spark/Athena markers — skipped",
|
|
371
|
+
path,
|
|
372
|
+
)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
# catálogos declarados precisam preceder as arestas que os usam
|
|
376
|
+
_collect_catalogs(sink, facts, path, sha, starts)
|
|
377
|
+
job = _script_entity(sink, facts, path=path, sha=sha, starts=starts)
|
|
378
|
+
used_strings: set[int] = set()
|
|
379
|
+
|
|
380
|
+
for call in facts.calls:
|
|
381
|
+
_handle_call(
|
|
382
|
+
sink,
|
|
383
|
+
facts,
|
|
384
|
+
call,
|
|
385
|
+
job,
|
|
386
|
+
path=path,
|
|
387
|
+
text=text,
|
|
388
|
+
sha=sha,
|
|
389
|
+
starts=starts,
|
|
390
|
+
used=used_strings,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
_job_call_args(
|
|
394
|
+
sink,
|
|
395
|
+
facts,
|
|
396
|
+
job,
|
|
397
|
+
path=path,
|
|
398
|
+
sha=sha,
|
|
399
|
+
starts=starts,
|
|
400
|
+
used=used_strings,
|
|
401
|
+
)
|
|
402
|
+
_sweep_literals(
|
|
403
|
+
sink,
|
|
404
|
+
facts,
|
|
405
|
+
job,
|
|
406
|
+
path=path,
|
|
407
|
+
sha=sha,
|
|
408
|
+
starts=starts,
|
|
409
|
+
used=used_strings,
|
|
410
|
+
)
|
|
411
|
+
sink.bump("py_files")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _has_aws_observations(facts: _PyFacts) -> bool:
|
|
415
|
+
"""Sem marcadores: ainda observável se há literal s3/arn ou call SDK."""
|
|
416
|
+
for node in facts.str_nodes:
|
|
417
|
+
v = node.value
|
|
418
|
+
if isinstance(v, str) and (parse_s3_uri(v) is not None or v.startswith("arn:aws:")):
|
|
419
|
+
return True
|
|
420
|
+
return any(
|
|
421
|
+
_dotted(c.func).rsplit(".", 1)[-1] in ("start_query_execution", "create_job")
|
|
422
|
+
for c in facts.calls
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _script_entity(
|
|
427
|
+
sink: Sink,
|
|
428
|
+
facts: _PyFacts,
|
|
429
|
+
*,
|
|
430
|
+
path: str,
|
|
431
|
+
sha: str,
|
|
432
|
+
starts: tuple[int, ...],
|
|
433
|
+
) -> DomainIdentity:
|
|
434
|
+
"""Entidade-âncora do script — kind honesto pelo marcador dominante."""
|
|
435
|
+
namespace = posixpath.dirname(path)
|
|
436
|
+
stem = PurePosixPath(path).stem or PurePosixPath(path).name
|
|
437
|
+
if "glue" in facts.markers:
|
|
438
|
+
kind = DomainEntityKind.GLUE_JOB
|
|
439
|
+
elif "spark" in facts.markers:
|
|
440
|
+
kind = DomainEntityKind.SPARK_JOB
|
|
441
|
+
else:
|
|
442
|
+
kind = DomainEntityKind.DATA_PIPELINE
|
|
443
|
+
attrs: dict[str, str] = {
|
|
444
|
+
"artifact_kind": "py-script",
|
|
445
|
+
"script": PurePosixPath(path).name,
|
|
446
|
+
}
|
|
447
|
+
if facts.job_inits:
|
|
448
|
+
attrs["job_init"] = "true"
|
|
449
|
+
if facts.job_commits:
|
|
450
|
+
attrs["job_commit_or_run"] = "true"
|
|
451
|
+
if facts.resolved_options:
|
|
452
|
+
names: list[str] = []
|
|
453
|
+
for tup in facts.resolved_options:
|
|
454
|
+
names.extend(tup)
|
|
455
|
+
attrs["job_option_names"] = ",".join(sorted(set(names)))
|
|
456
|
+
if "JOB_BOOKMARK_OPTION" in {n.lstrip("-") for n in names}:
|
|
457
|
+
attrs["job_bookmark_option_listed"] = "true"
|
|
458
|
+
return sink.entity(
|
|
459
|
+
kind=kind,
|
|
460
|
+
name=stem,
|
|
461
|
+
qualified_name=stem,
|
|
462
|
+
namespace=namespace,
|
|
463
|
+
path=path,
|
|
464
|
+
span=None,
|
|
465
|
+
content_hash=sha,
|
|
466
|
+
attributes=attrs,
|
|
467
|
+
evidence=(sink.evi(path, sha, None, notes="script"),),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# -- catálogos declarados ---------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _register_catalog(
|
|
475
|
+
sink: Sink,
|
|
476
|
+
name: str,
|
|
477
|
+
impl: str,
|
|
478
|
+
*,
|
|
479
|
+
path: str,
|
|
480
|
+
sha: str,
|
|
481
|
+
span: SourceSpan | None,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""``spark.sql.catalog.<name>`` → ``data_catalog`` + registro."""
|
|
484
|
+
attrs = {"declared_by": path}
|
|
485
|
+
if impl:
|
|
486
|
+
attrs["catalog_impl"] = impl
|
|
487
|
+
if is_iceberg_impl(impl):
|
|
488
|
+
attrs["engine"] = "iceberg"
|
|
489
|
+
ident = sink.entity(
|
|
490
|
+
kind=DomainEntityKind.DATA_CATALOG,
|
|
491
|
+
name=name,
|
|
492
|
+
qualified_name=name,
|
|
493
|
+
path=path,
|
|
494
|
+
span=span,
|
|
495
|
+
content_hash=sha,
|
|
496
|
+
attributes=attrs,
|
|
497
|
+
evidence=(sink.evi(path, sha, span, notes="catalog-decl"),),
|
|
498
|
+
)
|
|
499
|
+
sink.register_catalog(name, ident)
|
|
500
|
+
sink.bump("catalogs")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _collect_catalogs(
|
|
504
|
+
sink: Sink,
|
|
505
|
+
facts: _PyFacts,
|
|
506
|
+
path: str,
|
|
507
|
+
sha: str,
|
|
508
|
+
starts: tuple[int, ...],
|
|
509
|
+
) -> None:
|
|
510
|
+
"""``conf.set("spark.sql.catalog.<n>", "<impl>")`` → catálogos."""
|
|
511
|
+
for call in facts.calls:
|
|
512
|
+
dotted = _dotted(call.func)
|
|
513
|
+
if not dotted.endswith("conf.set") and not dotted.endswith("setConf"):
|
|
514
|
+
continue
|
|
515
|
+
key = _pos_lit(call, 0) or _kw_lit(call, "key")
|
|
516
|
+
val = _pos_lit(call, 1) or _kw_lit(call, "value")
|
|
517
|
+
if not key or not key.startswith("spark.sql.catalog."):
|
|
518
|
+
continue
|
|
519
|
+
rest = key[len("spark.sql.catalog.") :]
|
|
520
|
+
cat, _, prop = rest.partition(".")
|
|
521
|
+
span = node_span(starts, call)
|
|
522
|
+
if not prop:
|
|
523
|
+
_register_catalog(
|
|
524
|
+
sink,
|
|
525
|
+
cat,
|
|
526
|
+
val or "",
|
|
527
|
+
path=path,
|
|
528
|
+
sha=sha,
|
|
529
|
+
span=span,
|
|
530
|
+
)
|
|
531
|
+
facts.declared_catalogs[cat] = val or ""
|
|
532
|
+
if val and is_iceberg_impl(val):
|
|
533
|
+
facts.markers.add("iceberg")
|
|
534
|
+
continue
|
|
535
|
+
# propriedade de catálogo já declarado (ex.: ``.warehouse``)
|
|
536
|
+
cat_ident = sink.catalogs.get(cat)
|
|
537
|
+
if cat_ident is not None and val and prop == "warehouse" and parse_s3_uri(val) is not None:
|
|
538
|
+
evi = sink.evi(path, sha, span, notes="catalog-warehouse")
|
|
539
|
+
s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
|
|
540
|
+
if s3 is not None:
|
|
541
|
+
sink.rel(
|
|
542
|
+
_REFS,
|
|
543
|
+
path,
|
|
544
|
+
cat_ident,
|
|
545
|
+
to_identity=s3,
|
|
546
|
+
span=span,
|
|
547
|
+
evidence=(evi,),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _is_spark_receiver(facts: _PyFacts, call: ast.Call) -> bool:
|
|
552
|
+
root = _call_root(call)
|
|
553
|
+
return (
|
|
554
|
+
root in facts.spark_vars
|
|
555
|
+
or bool(_SPARK_ROOTS.match(root))
|
|
556
|
+
or facts.assigns.get(root, "").endswith(("GlueContext",))
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _table_kind(catalog: str, iceberg: bool) -> DomainEntityKind:
|
|
561
|
+
return DomainEntityKind.ICEBERG_TABLE if iceberg else DomainEntityKind.DATA_TABLE
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _table_entity(
|
|
565
|
+
sink: Sink,
|
|
566
|
+
written: str,
|
|
567
|
+
*,
|
|
568
|
+
iceberg: bool,
|
|
569
|
+
path: str,
|
|
570
|
+
sha: str,
|
|
571
|
+
span: SourceSpan | None,
|
|
572
|
+
evi: int,
|
|
573
|
+
) -> DomainIdentity | None:
|
|
574
|
+
"""``cat.db.t`` → ``data_table``/``iceberg_table``; meta-suffix ok."""
|
|
575
|
+
cat, ns, name = split_table_name(written)
|
|
576
|
+
if not name:
|
|
577
|
+
return None
|
|
578
|
+
attrs = {"written_name": written}
|
|
579
|
+
if name.lower() in ICEBERG_META and ns:
|
|
580
|
+
attrs["iceberg_meta_table"] = name.lower()
|
|
581
|
+
iceberg = True
|
|
582
|
+
return sink.entity(
|
|
583
|
+
kind=_table_kind(cat, iceberg),
|
|
584
|
+
name=name,
|
|
585
|
+
qualified_name=name,
|
|
586
|
+
catalog=cat,
|
|
587
|
+
namespace=ns,
|
|
588
|
+
path=path,
|
|
589
|
+
span=span,
|
|
590
|
+
content_hash=sha,
|
|
591
|
+
attributes=attrs,
|
|
592
|
+
evidence=(evi,),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _catalog_ref_for(
|
|
597
|
+
sink: Sink,
|
|
598
|
+
written: str,
|
|
599
|
+
subject: DomainIdentity,
|
|
600
|
+
path: str,
|
|
601
|
+
span: SourceSpan | None,
|
|
602
|
+
evi: int,
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Catálogo nomeado de um ``cat.db.t`` → ``REFERENCES`` adiada."""
|
|
605
|
+
cat = split_table_name(written)[0]
|
|
606
|
+
if cat:
|
|
607
|
+
sink.catalog_ref(subject, cat, path, span, evi)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# -- handlers por call ------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _catalog_read(
|
|
614
|
+
sink: Sink,
|
|
615
|
+
call: ast.Call,
|
|
616
|
+
subject: DomainIdentity,
|
|
617
|
+
*,
|
|
618
|
+
path: str,
|
|
619
|
+
sha: str,
|
|
620
|
+
starts: tuple[int, ...],
|
|
621
|
+
) -> None:
|
|
622
|
+
"""``from_catalog``/``getDataFrame`` — leitura de tabela Glue."""
|
|
623
|
+
database = _pos_or_kw(call, 0, "database")
|
|
624
|
+
table = _pos_or_kw(call, 1, "table_name") or _kw_lit(call, "table")
|
|
625
|
+
catalog_id = _kw_lit(call, "catalog_id") or _kw_lit(call, "CatalogId")
|
|
626
|
+
span = node_span(starts, call)
|
|
627
|
+
evi = sink.evi(path, sha, span, notes="glue-catalog-read")
|
|
628
|
+
catalog = aws_catalog("glue", catalog_id or "")
|
|
629
|
+
if catalog_id:
|
|
630
|
+
_declare_glue_catalog(sink, catalog, subject, path=path, sha=sha, span=span)
|
|
631
|
+
if not database or not table:
|
|
632
|
+
sink.diag(
|
|
633
|
+
"dynamic-catalog-ref",
|
|
634
|
+
"warning",
|
|
635
|
+
"catalog read with non-literal database/table — relation kept unresolved",
|
|
636
|
+
path,
|
|
637
|
+
)
|
|
638
|
+
sink.rel(
|
|
639
|
+
_READS,
|
|
640
|
+
path,
|
|
641
|
+
subject,
|
|
642
|
+
to_unresolved="<dynamic>",
|
|
643
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
644
|
+
span=span,
|
|
645
|
+
evidence=(evi,),
|
|
646
|
+
)
|
|
647
|
+
return
|
|
648
|
+
ident = sink.entity(
|
|
649
|
+
kind=DomainEntityKind.DATA_TABLE,
|
|
650
|
+
name=table,
|
|
651
|
+
qualified_name=table,
|
|
652
|
+
catalog=catalog,
|
|
653
|
+
namespace=database,
|
|
654
|
+
path=path,
|
|
655
|
+
span=span,
|
|
656
|
+
content_hash=sha,
|
|
657
|
+
attributes={
|
|
658
|
+
"written_name": f"{database}.{table}",
|
|
659
|
+
"source": "glue-catalog",
|
|
660
|
+
},
|
|
661
|
+
evidence=(evi,),
|
|
662
|
+
)
|
|
663
|
+
sink.rel(
|
|
664
|
+
_READS,
|
|
665
|
+
path,
|
|
666
|
+
subject,
|
|
667
|
+
to_identity=ident,
|
|
668
|
+
span=span,
|
|
669
|
+
evidence=(evi,),
|
|
670
|
+
)
|
|
671
|
+
sink.bump("glue_reads")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _declare_glue_catalog(
|
|
675
|
+
sink: Sink,
|
|
676
|
+
catalog: str,
|
|
677
|
+
subject: DomainIdentity,
|
|
678
|
+
*,
|
|
679
|
+
path: str,
|
|
680
|
+
sha: str,
|
|
681
|
+
span: SourceSpan | None,
|
|
682
|
+
) -> None:
|
|
683
|
+
"""``catalog_id`` explícito → ``data_catalog`` + REFERENCES."""
|
|
684
|
+
ident = sink.entity(
|
|
685
|
+
kind=DomainEntityKind.DATA_CATALOG,
|
|
686
|
+
name=catalog,
|
|
687
|
+
qualified_name=catalog,
|
|
688
|
+
path=path,
|
|
689
|
+
span=span,
|
|
690
|
+
content_hash=sha,
|
|
691
|
+
attributes={"source": "glue-catalog-id"},
|
|
692
|
+
evidence=(sink.evi(path, sha, span, notes="glue-catalog-id"),),
|
|
693
|
+
)
|
|
694
|
+
sink.register_catalog(catalog, ident)
|
|
695
|
+
sink.rel(
|
|
696
|
+
_REFS,
|
|
697
|
+
path,
|
|
698
|
+
subject,
|
|
699
|
+
to_identity=ident,
|
|
700
|
+
span=span,
|
|
701
|
+
evidence=(sink.evi(path, sha, span, notes="catalog-ref"),),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _catalog_write(
|
|
706
|
+
sink: Sink,
|
|
707
|
+
call: ast.Call,
|
|
708
|
+
subject: DomainIdentity,
|
|
709
|
+
*,
|
|
710
|
+
path: str,
|
|
711
|
+
sha: str,
|
|
712
|
+
starts: tuple[int, ...],
|
|
713
|
+
) -> None:
|
|
714
|
+
"""``write_dynamic_frame.from_catalog`` — escrita em tabela Glue."""
|
|
715
|
+
database = _kw_lit(call, "database")
|
|
716
|
+
table = _kw_lit(call, "table_name") or _kw_lit(call, "table")
|
|
717
|
+
if database is None:
|
|
718
|
+
database = _pos_lit(call, 1) or _pos_lit(call, 0)
|
|
719
|
+
if table is None:
|
|
720
|
+
table = _pos_lit(call, 2)
|
|
721
|
+
catalog_id = _kw_lit(call, "catalog_id") or _kw_lit(call, "CatalogId")
|
|
722
|
+
span = node_span(starts, call)
|
|
723
|
+
evi = sink.evi(path, sha, span, notes="glue-catalog-write")
|
|
724
|
+
catalog = aws_catalog("glue", catalog_id or "")
|
|
725
|
+
if catalog_id:
|
|
726
|
+
_declare_glue_catalog(sink, catalog, subject, path=path, sha=sha, span=span)
|
|
727
|
+
if not database or not table:
|
|
728
|
+
sink.diag(
|
|
729
|
+
"dynamic-catalog-ref",
|
|
730
|
+
"warning",
|
|
731
|
+
"catalog write with non-literal database/table",
|
|
732
|
+
path,
|
|
733
|
+
)
|
|
734
|
+
sink.rel(
|
|
735
|
+
_WRITES,
|
|
736
|
+
path,
|
|
737
|
+
subject,
|
|
738
|
+
to_unresolved="<dynamic>",
|
|
739
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
740
|
+
span=span,
|
|
741
|
+
evidence=(evi,),
|
|
742
|
+
)
|
|
743
|
+
return
|
|
744
|
+
ident = sink.entity(
|
|
745
|
+
kind=DomainEntityKind.DATA_TABLE,
|
|
746
|
+
name=table,
|
|
747
|
+
qualified_name=table,
|
|
748
|
+
catalog=catalog,
|
|
749
|
+
namespace=database,
|
|
750
|
+
path=path,
|
|
751
|
+
span=span,
|
|
752
|
+
content_hash=sha,
|
|
753
|
+
attributes={
|
|
754
|
+
"written_name": f"{database}.{table}",
|
|
755
|
+
"source": "glue-catalog",
|
|
756
|
+
},
|
|
757
|
+
evidence=(evi,),
|
|
758
|
+
)
|
|
759
|
+
sink.rel(
|
|
760
|
+
_WRITES,
|
|
761
|
+
path,
|
|
762
|
+
subject,
|
|
763
|
+
to_identity=ident,
|
|
764
|
+
span=span,
|
|
765
|
+
evidence=(evi,),
|
|
766
|
+
)
|
|
767
|
+
sink.bump("glue_writes")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _conn_type_of(call: ast.Call) -> str | None:
|
|
771
|
+
"""``connection_type=`` kw ou posicional ``write_from_options``."""
|
|
772
|
+
conn_type = _kw_lit(call, "connection_type")
|
|
773
|
+
if conn_type is not None:
|
|
774
|
+
return conn_type
|
|
775
|
+
for i in range(len(call.args)):
|
|
776
|
+
v = _pos_lit(call, i)
|
|
777
|
+
if isinstance(v, str) and v.lower() in _CONN_TYPES:
|
|
778
|
+
return v
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _conn_opts_dict(call: ast.Call) -> dict[str, object] | None:
|
|
783
|
+
"""``connection_options=`` literal dict — kw ou 1º dict posicional."""
|
|
784
|
+
opts = _dict_lit(_kw(call, "connection_options"))
|
|
785
|
+
if opts is None:
|
|
786
|
+
opts = _dict_lit(_kw(call, "connectionOptions"))
|
|
787
|
+
if opts is None:
|
|
788
|
+
for a in call.args:
|
|
789
|
+
d = _dict_lit(a)
|
|
790
|
+
if d is not None:
|
|
791
|
+
opts = d
|
|
792
|
+
break
|
|
793
|
+
return opts
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _connection_fallback(
|
|
797
|
+
sink: Sink,
|
|
798
|
+
call: ast.Call,
|
|
799
|
+
subject: DomainIdentity,
|
|
800
|
+
*,
|
|
801
|
+
direction: str,
|
|
802
|
+
conn_type: str | None,
|
|
803
|
+
opts: dict[str, object] | None,
|
|
804
|
+
path: str,
|
|
805
|
+
span: SourceSpan | None,
|
|
806
|
+
evi: int,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Alvos não-literais → diagnóstico/aresta honesta."""
|
|
809
|
+
if conn_type is None or opts is None:
|
|
810
|
+
sink.diag(
|
|
811
|
+
"dynamic-connection",
|
|
812
|
+
"warning",
|
|
813
|
+
"connection options not a literal dict — targets unknown",
|
|
814
|
+
path,
|
|
815
|
+
)
|
|
816
|
+
return
|
|
817
|
+
if conn_type:
|
|
818
|
+
sink.rel(
|
|
819
|
+
_REFS,
|
|
820
|
+
path,
|
|
821
|
+
subject,
|
|
822
|
+
to_unresolved=f"glue-connection:{conn_type}",
|
|
823
|
+
status=ResolutionStatus.EXTERNAL,
|
|
824
|
+
span=span,
|
|
825
|
+
evidence=(evi,),
|
|
826
|
+
)
|
|
827
|
+
return
|
|
828
|
+
sink.rel(
|
|
829
|
+
direction,
|
|
830
|
+
path,
|
|
831
|
+
subject,
|
|
832
|
+
to_unresolved="<dynamic>",
|
|
833
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
834
|
+
span=span,
|
|
835
|
+
evidence=(evi,),
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _connection_options(
|
|
840
|
+
sink: Sink,
|
|
841
|
+
call: ast.Call,
|
|
842
|
+
subject: DomainIdentity,
|
|
843
|
+
*,
|
|
844
|
+
direction: str,
|
|
845
|
+
path: str,
|
|
846
|
+
sha: str,
|
|
847
|
+
starts: tuple[int, ...],
|
|
848
|
+
) -> None:
|
|
849
|
+
"""``from_options``/``write_from_options`` — s3/jdbc/connection."""
|
|
850
|
+
span = node_span(starts, call)
|
|
851
|
+
evi = sink.evi(path, sha, span, notes="connection-options")
|
|
852
|
+
conn_type = _conn_type_of(call)
|
|
853
|
+
opts = _conn_opts_dict(call)
|
|
854
|
+
conn_name = _kw_lit(call, "connection_name") or _kw_lit(call, "connectionName")
|
|
855
|
+
handled = False
|
|
856
|
+
if opts:
|
|
857
|
+
handled = _emit_option_targets(
|
|
858
|
+
sink,
|
|
859
|
+
conn_type,
|
|
860
|
+
opts,
|
|
861
|
+
subject,
|
|
862
|
+
direction=direction,
|
|
863
|
+
path=path,
|
|
864
|
+
sha=sha,
|
|
865
|
+
span=span,
|
|
866
|
+
evi=evi,
|
|
867
|
+
)
|
|
868
|
+
if conn_name:
|
|
869
|
+
sink.rel(
|
|
870
|
+
_REFS,
|
|
871
|
+
path,
|
|
872
|
+
subject,
|
|
873
|
+
to_unresolved=f"glue-connection:{conn_name}",
|
|
874
|
+
status=ResolutionStatus.EXTERNAL,
|
|
875
|
+
span=span,
|
|
876
|
+
evidence=(evi,),
|
|
877
|
+
)
|
|
878
|
+
handled = True
|
|
879
|
+
if not handled:
|
|
880
|
+
_connection_fallback(
|
|
881
|
+
sink,
|
|
882
|
+
call,
|
|
883
|
+
subject,
|
|
884
|
+
direction=direction,
|
|
885
|
+
conn_type=conn_type,
|
|
886
|
+
opts=opts,
|
|
887
|
+
path=path,
|
|
888
|
+
span=span,
|
|
889
|
+
evi=evi,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
_CONN_TYPES = frozenset(
|
|
894
|
+
{
|
|
895
|
+
"s3",
|
|
896
|
+
"dynamodb",
|
|
897
|
+
"jdbc",
|
|
898
|
+
"mysql",
|
|
899
|
+
"postgresql",
|
|
900
|
+
"oracle",
|
|
901
|
+
"sqlserver",
|
|
902
|
+
"redshift",
|
|
903
|
+
"mongodb",
|
|
904
|
+
"documentdb",
|
|
905
|
+
"kinesis",
|
|
906
|
+
"kafka",
|
|
907
|
+
"marketplace",
|
|
908
|
+
"custom.jdbc",
|
|
909
|
+
"snowflake",
|
|
910
|
+
"bigquery",
|
|
911
|
+
"opensearch",
|
|
912
|
+
"saphana",
|
|
913
|
+
"teradata",
|
|
914
|
+
"vertica",
|
|
915
|
+
"netezza",
|
|
916
|
+
"azure",
|
|
917
|
+
"cosmos",
|
|
918
|
+
"db2",
|
|
919
|
+
"informix",
|
|
920
|
+
"sybase",
|
|
921
|
+
"excel",
|
|
922
|
+
"athena",
|
|
923
|
+
"salesforce",
|
|
924
|
+
"servicenow",
|
|
925
|
+
}
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _emit_option_targets(
|
|
930
|
+
sink: Sink,
|
|
931
|
+
conn_type: str | None,
|
|
932
|
+
opts: dict[str, object],
|
|
933
|
+
subject: DomainIdentity,
|
|
934
|
+
*,
|
|
935
|
+
direction: str,
|
|
936
|
+
path: str,
|
|
937
|
+
sha: str,
|
|
938
|
+
span: SourceSpan | None,
|
|
939
|
+
evi: int,
|
|
940
|
+
) -> bool:
|
|
941
|
+
"""Emite arestas ``direction`` para alvos de ``connection_options``."""
|
|
942
|
+
handled = False
|
|
943
|
+
raw_paths = opts.get("paths", opts.get("path"))
|
|
944
|
+
paths: list[str] = []
|
|
945
|
+
if isinstance(raw_paths, str):
|
|
946
|
+
paths = [raw_paths]
|
|
947
|
+
elif isinstance(raw_paths, (list, tuple)):
|
|
948
|
+
paths = [str(p) for p in raw_paths]
|
|
949
|
+
for p in paths:
|
|
950
|
+
ident = s3_entity(sink, p, path=path, sha=sha, span=span, evi=evi)
|
|
951
|
+
if ident is None:
|
|
952
|
+
sink.diag(
|
|
953
|
+
"dynamic-s3-uri",
|
|
954
|
+
"warning",
|
|
955
|
+
"s3 path in connection_options is not a literal URI",
|
|
956
|
+
path,
|
|
957
|
+
)
|
|
958
|
+
continue
|
|
959
|
+
sink.rel(
|
|
960
|
+
direction,
|
|
961
|
+
path,
|
|
962
|
+
subject,
|
|
963
|
+
to_identity=ident,
|
|
964
|
+
span=span,
|
|
965
|
+
evidence=(evi,),
|
|
966
|
+
)
|
|
967
|
+
sink.bump("s3_paths")
|
|
968
|
+
handled = True
|
|
969
|
+
if conn_type == "dynamodb":
|
|
970
|
+
for key in ("dynamodb.input.tableName", "dynamodb.output.tableName"):
|
|
971
|
+
val = opts.get(key)
|
|
972
|
+
if isinstance(val, str) and val:
|
|
973
|
+
sink.rel(
|
|
974
|
+
_REFS,
|
|
975
|
+
path,
|
|
976
|
+
subject,
|
|
977
|
+
to_unresolved=f"dynamodb:{val}",
|
|
978
|
+
status=ResolutionStatus.EXTERNAL,
|
|
979
|
+
span=span,
|
|
980
|
+
evidence=(evi,),
|
|
981
|
+
)
|
|
982
|
+
handled = True
|
|
983
|
+
return handled
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _sql_literal_call(
|
|
987
|
+
sink: Sink,
|
|
988
|
+
call: ast.Call,
|
|
989
|
+
subject: DomainIdentity,
|
|
990
|
+
*,
|
|
991
|
+
arg_node: ast.AST | None,
|
|
992
|
+
path: str,
|
|
993
|
+
text: str,
|
|
994
|
+
sha: str,
|
|
995
|
+
starts: tuple[int, ...],
|
|
996
|
+
use_catalog: str = "",
|
|
997
|
+
use_schema: str = "",
|
|
998
|
+
notes: str = "sql-literal",
|
|
999
|
+
) -> None:
|
|
1000
|
+
"""Literal SQL → ``sqlbridge``; dinâmico → diagnóstico honesto."""
|
|
1001
|
+
span = node_span(starts, call)
|
|
1002
|
+
evi = sink.evi(path, sha, span, notes=notes)
|
|
1003
|
+
if arg_node is None:
|
|
1004
|
+
return
|
|
1005
|
+
if isinstance(arg_node, ast.JoinedStr) or (not isinstance(arg_node, ast.Constant)):
|
|
1006
|
+
sink.diag(
|
|
1007
|
+
"dynamic-sql-literal",
|
|
1008
|
+
"warning",
|
|
1009
|
+
"SQL argument is not a string literal — never parsed",
|
|
1010
|
+
path,
|
|
1011
|
+
)
|
|
1012
|
+
sink.rel(
|
|
1013
|
+
"REFERENCES",
|
|
1014
|
+
path,
|
|
1015
|
+
subject,
|
|
1016
|
+
to_unresolved="<dynamic-sql>",
|
|
1017
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
1018
|
+
span=span,
|
|
1019
|
+
evidence=(evi,),
|
|
1020
|
+
)
|
|
1021
|
+
return
|
|
1022
|
+
sql = arg_node.value
|
|
1023
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
1024
|
+
return
|
|
1025
|
+
# remap: corpo do literal contíguo no fonte → offsets reais
|
|
1026
|
+
body_start, contiguous = _literal_body_start(text, arg_node, sql)
|
|
1027
|
+
remap = literal_span_remap(
|
|
1028
|
+
file_text=text,
|
|
1029
|
+
line_starts=starts,
|
|
1030
|
+
body_char_start=body_start if body_start is not None else 0,
|
|
1031
|
+
contiguous=contiguous and body_start is not None,
|
|
1032
|
+
fallback=node_span(starts, arg_node) or span,
|
|
1033
|
+
)
|
|
1034
|
+
frag = extract_sql_fragment(
|
|
1035
|
+
sink,
|
|
1036
|
+
sql_text=sql,
|
|
1037
|
+
path=path,
|
|
1038
|
+
sha=sha,
|
|
1039
|
+
transform_tag=f"L{arg_node.lineno}",
|
|
1040
|
+
span_remap=remap,
|
|
1041
|
+
use_catalog=use_catalog,
|
|
1042
|
+
use_schema=use_schema,
|
|
1043
|
+
fallback_anchor=subject,
|
|
1044
|
+
)
|
|
1045
|
+
# a script contém literalmente os statements — CONTAINS honesto
|
|
1046
|
+
for tident in frag.transform_idents:
|
|
1047
|
+
sink.rel(
|
|
1048
|
+
"CONTAINS",
|
|
1049
|
+
path,
|
|
1050
|
+
subject,
|
|
1051
|
+
to_identity=tident,
|
|
1052
|
+
span=span,
|
|
1053
|
+
evidence=(evi,),
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _literal_body_start(text: str, node: ast.AST, decoded: str) -> tuple[int | None, bool]:
|
|
1058
|
+
"""Início do corpo decodificado do literal no fonte + contiguidade.
|
|
1059
|
+
|
|
1060
|
+
Retorna ``(char_start, contiguous)``; ``contiguous=False`` quando
|
|
1061
|
+
escapes/concatenação impedem mapear offsets internos — o caller cai
|
|
1062
|
+
para o span do literal inteiro (nunca offset errado).
|
|
1063
|
+
"""
|
|
1064
|
+
lineno = getattr(node, "lineno", None)
|
|
1065
|
+
col = getattr(node, "col_offset", None)
|
|
1066
|
+
if lineno is None or col is None:
|
|
1067
|
+
return None, False
|
|
1068
|
+
lines = text.splitlines(keepends=True)
|
|
1069
|
+
if lineno < 1 or lineno > len(lines):
|
|
1070
|
+
return None, False
|
|
1071
|
+
line_start_char = sum(len(ln) for ln in lines[: lineno - 1])
|
|
1072
|
+
# col_offset é byte offset na linha → converter para char
|
|
1073
|
+
line = lines[lineno - 1]
|
|
1074
|
+
char_col = len(line.encode("utf-8")[:col].decode("utf-8", "replace"))
|
|
1075
|
+
start = line_start_char + char_col
|
|
1076
|
+
raw = ast.get_source_segment(text, node)
|
|
1077
|
+
if raw is None:
|
|
1078
|
+
return None, False
|
|
1079
|
+
m = re.match(r"(?i)([rubf]*)('''|\"\"\"|'|\")", raw)
|
|
1080
|
+
if m is None:
|
|
1081
|
+
return None, False
|
|
1082
|
+
body_start = start + m.end()
|
|
1083
|
+
contiguous = text[body_start : body_start + len(decoded)] == decoded
|
|
1084
|
+
return body_start, contiguous
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _role_entity(
|
|
1088
|
+
sink: Sink,
|
|
1089
|
+
arn_text: str,
|
|
1090
|
+
*,
|
|
1091
|
+
path: str,
|
|
1092
|
+
sha: str,
|
|
1093
|
+
span: SourceSpan | None,
|
|
1094
|
+
evi: int,
|
|
1095
|
+
) -> DomainIdentity | None:
|
|
1096
|
+
"""ARN ``iam::acct:role/x`` → ``iam_role`` com conta explícita."""
|
|
1097
|
+
parts = parse_arn(arn_text)
|
|
1098
|
+
if parts is None or not is_iam_role_arn(parts):
|
|
1099
|
+
return None
|
|
1100
|
+
role_name = parts.resource
|
|
1101
|
+
ident = sink.entity(
|
|
1102
|
+
kind=DomainEntityKind.IAM_ROLE,
|
|
1103
|
+
name=role_name.rsplit("/", 1)[-1],
|
|
1104
|
+
qualified_name=role_name,
|
|
1105
|
+
catalog=aws_catalog("iam", parts.account, parts.region),
|
|
1106
|
+
namespace="",
|
|
1107
|
+
path=path,
|
|
1108
|
+
span=span,
|
|
1109
|
+
content_hash=sha,
|
|
1110
|
+
attributes={
|
|
1111
|
+
"arn": parts.arn,
|
|
1112
|
+
"account": parts.account,
|
|
1113
|
+
"partition": parts.partition,
|
|
1114
|
+
},
|
|
1115
|
+
evidence=(evi,),
|
|
1116
|
+
)
|
|
1117
|
+
sink.bump("iam_roles")
|
|
1118
|
+
return ident
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _mark_used(call: ast.Call, used: set[int]) -> None:
|
|
1122
|
+
"""Marca ``Constant`` str dentro dos args do call como consumidos."""
|
|
1123
|
+
for node in ast.walk(call):
|
|
1124
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
1125
|
+
used.add(id(node))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _merge_attrs(
|
|
1129
|
+
sink: Sink,
|
|
1130
|
+
ident: DomainIdentity,
|
|
1131
|
+
attrs: dict[str, str],
|
|
1132
|
+
*,
|
|
1133
|
+
path: str,
|
|
1134
|
+
sha: str,
|
|
1135
|
+
span: SourceSpan | None,
|
|
1136
|
+
evi: int,
|
|
1137
|
+
) -> None:
|
|
1138
|
+
"""Reemite a entidade já registrada acrescentando ``attrs``."""
|
|
1139
|
+
prev = sink.entities.get(ident.logical_key())
|
|
1140
|
+
if prev is None:
|
|
1141
|
+
return
|
|
1142
|
+
sink.entity_for(
|
|
1143
|
+
ident,
|
|
1144
|
+
kind=prev.kind,
|
|
1145
|
+
name=prev.name,
|
|
1146
|
+
path=path or prev.path,
|
|
1147
|
+
span=span or prev.span,
|
|
1148
|
+
content_hash=sha,
|
|
1149
|
+
attributes=attrs,
|
|
1150
|
+
evidence=(evi,),
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _job_call_args(
|
|
1155
|
+
sink: Sink,
|
|
1156
|
+
facts: _PyFacts,
|
|
1157
|
+
job: DomainIdentity,
|
|
1158
|
+
*,
|
|
1159
|
+
path: str,
|
|
1160
|
+
sha: str,
|
|
1161
|
+
starts: tuple[int, ...],
|
|
1162
|
+
used: set[int],
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""``job.init(name, ctx, args={…})`` / ``job.commit()`` literais.
|
|
1165
|
+
|
|
1166
|
+
Extrai nomes de opção + ``JOB_BOOKMARK_OPTION`` e refs ``s3://``
|
|
1167
|
+
declaradas nos args — valores sensíveis ficam ``[REDACTED]``.
|
|
1168
|
+
"""
|
|
1169
|
+
for call in [*facts.job_inits, *facts.job_commits]:
|
|
1170
|
+
_job_call_one(sink, call, job, path=path, sha=sha, starts=starts, used=used)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _job_call_one(
|
|
1174
|
+
sink: Sink,
|
|
1175
|
+
call: ast.Call,
|
|
1176
|
+
job: DomainIdentity,
|
|
1177
|
+
*,
|
|
1178
|
+
path: str,
|
|
1179
|
+
sha: str,
|
|
1180
|
+
starts: tuple[int, ...],
|
|
1181
|
+
used: set[int],
|
|
1182
|
+
) -> None:
|
|
1183
|
+
"""Um ``job.init``/``job.commit`` — args literais → attrs/refs."""
|
|
1184
|
+
span = node_span(starts, call)
|
|
1185
|
+
evi = sink.evi(path, sha, span, notes="glue-job-args")
|
|
1186
|
+
args_node = _kw(call, "args")
|
|
1187
|
+
if args_node is None:
|
|
1188
|
+
for a in call.args:
|
|
1189
|
+
if isinstance(a, ast.Dict):
|
|
1190
|
+
args_node = a
|
|
1191
|
+
break
|
|
1192
|
+
if args_node is None:
|
|
1193
|
+
return
|
|
1194
|
+
opts = _dict_lit(args_node)
|
|
1195
|
+
if opts is None:
|
|
1196
|
+
sink.diag(
|
|
1197
|
+
"dynamic-job-args",
|
|
1198
|
+
"warning",
|
|
1199
|
+
"job.init/run args is not a literal dict — option values unknown",
|
|
1200
|
+
path,
|
|
1201
|
+
)
|
|
1202
|
+
return
|
|
1203
|
+
attrs: dict[str, str] = {}
|
|
1204
|
+
for k, v in opts.items():
|
|
1205
|
+
_job_arg_entry(sink, job, k, v, attrs, path=path, sha=sha, span=span, evi=evi)
|
|
1206
|
+
if attrs:
|
|
1207
|
+
_merge_attrs(sink, job, attrs, path=path, sha=sha, span=span, evi=evi)
|
|
1208
|
+
_mark_used(call, used)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _job_arg_entry(
|
|
1212
|
+
sink: Sink,
|
|
1213
|
+
job: DomainIdentity,
|
|
1214
|
+
k: object,
|
|
1215
|
+
v: object,
|
|
1216
|
+
attrs: dict[str, str],
|
|
1217
|
+
*,
|
|
1218
|
+
path: str,
|
|
1219
|
+
sha: str,
|
|
1220
|
+
span: SourceSpan | None,
|
|
1221
|
+
evi: int,
|
|
1222
|
+
) -> None:
|
|
1223
|
+
"""Uma entrada de ``args={…}``: attr redacted + bookmark + s3 ref."""
|
|
1224
|
+
kk = str(k).lstrip("-")
|
|
1225
|
+
if not kk:
|
|
1226
|
+
return
|
|
1227
|
+
attrs[f"job_arg.{kk}"] = redact_if_sensitive(kk, str(v))
|
|
1228
|
+
if kk == "JOB_BOOKMARK_OPTION" and isinstance(v, str):
|
|
1229
|
+
attrs["job_bookmarks"] = v
|
|
1230
|
+
if not isinstance(v, str) or parse_s3_uri(v) is None:
|
|
1231
|
+
return
|
|
1232
|
+
s3 = s3_entity(sink, v, path=path, sha=sha, span=span, evi=evi)
|
|
1233
|
+
if s3 is not None:
|
|
1234
|
+
sink.rel(_REFS, path, job, to_identity=s3, span=span, evidence=(evi,))
|
|
1235
|
+
sink.bump("s3_paths")
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _handle_call(
|
|
1239
|
+
sink: Sink,
|
|
1240
|
+
facts: _PyFacts,
|
|
1241
|
+
call: ast.Call,
|
|
1242
|
+
subject: DomainIdentity,
|
|
1243
|
+
*,
|
|
1244
|
+
path: str,
|
|
1245
|
+
text: str,
|
|
1246
|
+
sha: str,
|
|
1247
|
+
starts: tuple[int, ...],
|
|
1248
|
+
used: set[int],
|
|
1249
|
+
) -> None:
|
|
1250
|
+
"""Despacha um ``ast.Call`` para o reconhecedor adequado."""
|
|
1251
|
+
for disp in (
|
|
1252
|
+
_disp_glue_io,
|
|
1253
|
+
_disp_sqlish,
|
|
1254
|
+
_disp_sdk_calls,
|
|
1255
|
+
_disp_writes,
|
|
1256
|
+
_disp_reads,
|
|
1257
|
+
):
|
|
1258
|
+
if disp(
|
|
1259
|
+
sink,
|
|
1260
|
+
facts,
|
|
1261
|
+
call,
|
|
1262
|
+
subject,
|
|
1263
|
+
path=path,
|
|
1264
|
+
text=text,
|
|
1265
|
+
sha=sha,
|
|
1266
|
+
starts=starts,
|
|
1267
|
+
used=used,
|
|
1268
|
+
):
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _disp_glue_io(
|
|
1273
|
+
sink: Sink,
|
|
1274
|
+
facts: _PyFacts,
|
|
1275
|
+
call: ast.Call,
|
|
1276
|
+
subject: DomainIdentity,
|
|
1277
|
+
*,
|
|
1278
|
+
path: str,
|
|
1279
|
+
sha: str,
|
|
1280
|
+
starts: tuple[int, ...],
|
|
1281
|
+
used: set[int],
|
|
1282
|
+
**_unused: object,
|
|
1283
|
+
) -> bool:
|
|
1284
|
+
"""``from_catalog``/``from_options``/``write_*`` do GlueContext."""
|
|
1285
|
+
dotted = _dotted(call.func)
|
|
1286
|
+
if dotted.endswith(_READ_CATALOG_SUFFIXES):
|
|
1287
|
+
_catalog_read(sink, call, subject, path=path, sha=sha, starts=starts)
|
|
1288
|
+
elif dotted.endswith(_WRITE_CATALOG_SUFFIXES):
|
|
1289
|
+
_catalog_write(sink, call, subject, path=path, sha=sha, starts=starts)
|
|
1290
|
+
elif dotted.endswith(_READ_OPTIONS_SUFFIXES):
|
|
1291
|
+
_connection_options(
|
|
1292
|
+
sink,
|
|
1293
|
+
call,
|
|
1294
|
+
subject,
|
|
1295
|
+
direction=_READS,
|
|
1296
|
+
path=path,
|
|
1297
|
+
sha=sha,
|
|
1298
|
+
starts=starts,
|
|
1299
|
+
)
|
|
1300
|
+
elif dotted.endswith(_WRITE_OPTIONS_SUFFIXES):
|
|
1301
|
+
_connection_options(
|
|
1302
|
+
sink,
|
|
1303
|
+
call,
|
|
1304
|
+
subject,
|
|
1305
|
+
direction=_WRITES,
|
|
1306
|
+
path=path,
|
|
1307
|
+
sha=sha,
|
|
1308
|
+
starts=starts,
|
|
1309
|
+
)
|
|
1310
|
+
else:
|
|
1311
|
+
return False
|
|
1312
|
+
_mark_used(call, used)
|
|
1313
|
+
return True
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _disp_sqlish(
|
|
1317
|
+
sink: Sink,
|
|
1318
|
+
facts: _PyFacts,
|
|
1319
|
+
call: ast.Call,
|
|
1320
|
+
subject: DomainIdentity,
|
|
1321
|
+
*,
|
|
1322
|
+
path: str,
|
|
1323
|
+
text: str,
|
|
1324
|
+
sha: str,
|
|
1325
|
+
starts: tuple[int, ...],
|
|
1326
|
+
used: set[int],
|
|
1327
|
+
**_unused: object,
|
|
1328
|
+
) -> bool:
|
|
1329
|
+
"""``X.sql(lit)``/``execute(lit)`` → SQL via ``sqlbridge``."""
|
|
1330
|
+
last = _dotted(call.func).rsplit(".", 1)[-1]
|
|
1331
|
+
arg: ast.AST | None = None
|
|
1332
|
+
notes = ""
|
|
1333
|
+
if last == "sql":
|
|
1334
|
+
arg = call.args[0] if call.args else _kw(call, "sqlQuery")
|
|
1335
|
+
# receiver spark OU literal que prova ser SQL — ambos honestos
|
|
1336
|
+
if not _is_spark_receiver(facts, call) and not (
|
|
1337
|
+
isinstance(arg, ast.Constant)
|
|
1338
|
+
and isinstance(arg.value, str)
|
|
1339
|
+
and looks_like_sql(arg.value)
|
|
1340
|
+
):
|
|
1341
|
+
return True # não-SQL — consumido e encerrado
|
|
1342
|
+
notes = "spark-sql-literal"
|
|
1343
|
+
elif last in ("execute", "executemany", "execute_query"):
|
|
1344
|
+
arg = call.args[0] if call.args else _kw(call, "query")
|
|
1345
|
+
if not (
|
|
1346
|
+
isinstance(arg, ast.Constant)
|
|
1347
|
+
and isinstance(arg.value, str)
|
|
1348
|
+
and looks_like_sql(arg.value)
|
|
1349
|
+
):
|
|
1350
|
+
return True
|
|
1351
|
+
notes = "execute-sql"
|
|
1352
|
+
else:
|
|
1353
|
+
return False
|
|
1354
|
+
_sql_literal_call(
|
|
1355
|
+
sink,
|
|
1356
|
+
call,
|
|
1357
|
+
subject,
|
|
1358
|
+
arg_node=arg,
|
|
1359
|
+
path=path,
|
|
1360
|
+
text=text,
|
|
1361
|
+
sha=sha,
|
|
1362
|
+
starts=starts,
|
|
1363
|
+
notes=notes,
|
|
1364
|
+
)
|
|
1365
|
+
_mark_used(call, used)
|
|
1366
|
+
return True
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _disp_sdk_calls(
|
|
1370
|
+
sink: Sink,
|
|
1371
|
+
facts: _PyFacts,
|
|
1372
|
+
call: ast.Call,
|
|
1373
|
+
subject: DomainIdentity,
|
|
1374
|
+
*,
|
|
1375
|
+
path: str,
|
|
1376
|
+
text: str,
|
|
1377
|
+
sha: str,
|
|
1378
|
+
starts: tuple[int, ...],
|
|
1379
|
+
used: set[int],
|
|
1380
|
+
**_unused: object,
|
|
1381
|
+
) -> bool:
|
|
1382
|
+
"""``start_query_execution``/``create_job``/``update_job``."""
|
|
1383
|
+
last = _dotted(call.func).rsplit(".", 1)[-1]
|
|
1384
|
+
if last == "start_query_execution":
|
|
1385
|
+
_athena_call(
|
|
1386
|
+
sink,
|
|
1387
|
+
call,
|
|
1388
|
+
subject,
|
|
1389
|
+
path=path,
|
|
1390
|
+
text=text,
|
|
1391
|
+
sha=sha,
|
|
1392
|
+
starts=starts,
|
|
1393
|
+
)
|
|
1394
|
+
elif last in ("create_job", "update_job"):
|
|
1395
|
+
_glue_job_call(
|
|
1396
|
+
sink,
|
|
1397
|
+
call,
|
|
1398
|
+
subject,
|
|
1399
|
+
path=path,
|
|
1400
|
+
sha=sha,
|
|
1401
|
+
starts=starts,
|
|
1402
|
+
used=used,
|
|
1403
|
+
)
|
|
1404
|
+
else:
|
|
1405
|
+
return False
|
|
1406
|
+
_mark_used(call, used)
|
|
1407
|
+
return True
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _disp_writes(
|
|
1411
|
+
sink: Sink,
|
|
1412
|
+
facts: _PyFacts,
|
|
1413
|
+
call: ast.Call,
|
|
1414
|
+
subject: DomainIdentity,
|
|
1415
|
+
*,
|
|
1416
|
+
path: str,
|
|
1417
|
+
sha: str,
|
|
1418
|
+
starts: tuple[int, ...],
|
|
1419
|
+
used: set[int],
|
|
1420
|
+
**_unused: object,
|
|
1421
|
+
) -> bool:
|
|
1422
|
+
"""``writeTo``/``insertInto``/``saveAsTable`` → ``WRITES_TO``."""
|
|
1423
|
+
last = _dotted(call.func).rsplit(".", 1)[-1]
|
|
1424
|
+
if last == "writeTo":
|
|
1425
|
+
_write_to(sink, facts, call, subject, path=path, sha=sha, starts=starts)
|
|
1426
|
+
elif last in ("insertInto", "saveAsTable") and _is_spark_receiver(facts, call):
|
|
1427
|
+
_named_write(sink, call, subject, facts, path=path, sha=sha, starts=starts)
|
|
1428
|
+
else:
|
|
1429
|
+
return False
|
|
1430
|
+
_mark_used(call, used)
|
|
1431
|
+
return True
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _disp_reads(
|
|
1435
|
+
sink: Sink,
|
|
1436
|
+
facts: _PyFacts,
|
|
1437
|
+
call: ast.Call,
|
|
1438
|
+
subject: DomainIdentity,
|
|
1439
|
+
*,
|
|
1440
|
+
path: str,
|
|
1441
|
+
sha: str,
|
|
1442
|
+
starts: tuple[int, ...],
|
|
1443
|
+
used: set[int],
|
|
1444
|
+
**_unused: object,
|
|
1445
|
+
) -> bool:
|
|
1446
|
+
"""``spark.table``/``spark.read.<fmt>`` → ``READS_FROM``."""
|
|
1447
|
+
last = _dotted(call.func).rsplit(".", 1)[-1]
|
|
1448
|
+
if last == "table" and _is_spark_receiver(facts, call):
|
|
1449
|
+
_named_read(sink, call, subject, facts, path=path, sha=sha, starts=starts)
|
|
1450
|
+
elif last in _SPARK_READ_FMTS and _is_read_chain(facts, call):
|
|
1451
|
+
_read_path(sink, facts, call, subject, path=path, sha=sha, starts=starts)
|
|
1452
|
+
else:
|
|
1453
|
+
return False
|
|
1454
|
+
_mark_used(call, used)
|
|
1455
|
+
return True
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _chain_attrs(facts: _PyFacts, call: ast.Call) -> list[str]:
|
|
1459
|
+
"""Atributos da cadeia englobante: ``w.writeTo(t).using(x).append()``
|
|
1460
|
+
→ ``["writeTo", "using", "append"]`` (interno→externo)."""
|
|
1461
|
+
attrs: list[str] = []
|
|
1462
|
+
node: ast.AST = call
|
|
1463
|
+
seen = 0
|
|
1464
|
+
while seen < 16:
|
|
1465
|
+
parent = facts.parents.get(id(node))
|
|
1466
|
+
if parent is None:
|
|
1467
|
+
break
|
|
1468
|
+
if isinstance(parent, ast.Attribute):
|
|
1469
|
+
attrs.append(parent.attr)
|
|
1470
|
+
gp = facts.parents.get(id(parent))
|
|
1471
|
+
node = gp if gp is not None else parent
|
|
1472
|
+
if gp is None:
|
|
1473
|
+
break
|
|
1474
|
+
elif isinstance(parent, ast.Call):
|
|
1475
|
+
node = parent
|
|
1476
|
+
else:
|
|
1477
|
+
break
|
|
1478
|
+
seen += 1
|
|
1479
|
+
return attrs
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _chain_inner_calls(call: ast.Call) -> list[tuple[str, ast.Call]]:
|
|
1483
|
+
"""Chamadas no receiver: ``a.read.format('x').load(t)`` →
|
|
1484
|
+
[(load,call),(format,…),(read,…)] externo→interno."""
|
|
1485
|
+
chain: list[tuple[str, ast.Call]] = []
|
|
1486
|
+
cur = call
|
|
1487
|
+
depth = 0
|
|
1488
|
+
while depth < 16:
|
|
1489
|
+
if not isinstance(cur.func, ast.Attribute):
|
|
1490
|
+
break
|
|
1491
|
+
chain.append((cur.func.attr, cur))
|
|
1492
|
+
recv = cur.func.value
|
|
1493
|
+
if not isinstance(recv, ast.Call):
|
|
1494
|
+
break
|
|
1495
|
+
cur = recv
|
|
1496
|
+
depth += 1
|
|
1497
|
+
return chain
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _is_read_chain(facts: _PyFacts, call: ast.Call) -> bool:
|
|
1501
|
+
"""Terminal de ``<spark>.read.<fmt>(...)`` — receiver ``*.read``
|
|
1502
|
+
ou cadeia com ``.format(...)`` sobre receiver spark."""
|
|
1503
|
+
if not isinstance(call.func, ast.Attribute):
|
|
1504
|
+
return False
|
|
1505
|
+
recv = _dotted(call.func.value)
|
|
1506
|
+
if recv.endswith(".read") or recv == "read":
|
|
1507
|
+
return True
|
|
1508
|
+
for attr, _c in _chain_inner_calls(call):
|
|
1509
|
+
if attr in ("read", "format"):
|
|
1510
|
+
return _is_spark_receiver(facts, call)
|
|
1511
|
+
return False
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _chain_format(facts: _PyFacts, call: ast.Call) -> str:
|
|
1515
|
+
"""Valor literal de ``.format("x")`` na cadeia receiver do call."""
|
|
1516
|
+
for attr, c in _chain_inner_calls(call):
|
|
1517
|
+
if attr == "format":
|
|
1518
|
+
v = _pos_lit(c, 0)
|
|
1519
|
+
if v:
|
|
1520
|
+
return v.lower()
|
|
1521
|
+
return ""
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _write_to(
|
|
1525
|
+
sink: Sink,
|
|
1526
|
+
facts: _PyFacts,
|
|
1527
|
+
call: ast.Call,
|
|
1528
|
+
subject: DomainIdentity,
|
|
1529
|
+
*,
|
|
1530
|
+
path: str,
|
|
1531
|
+
sha: str,
|
|
1532
|
+
starts: tuple[int, ...],
|
|
1533
|
+
) -> None:
|
|
1534
|
+
"""``df.writeTo("c.n.t")`` + ``.using("iceberg")`` + terminal."""
|
|
1535
|
+
outer = _chain_attrs(facts, call)
|
|
1536
|
+
terminal = [a for a in outer if a in _WRITE_TERMINALS]
|
|
1537
|
+
if not terminal:
|
|
1538
|
+
return # builder sem operação terminal — nada a afirmar
|
|
1539
|
+
using_iceberg = False
|
|
1540
|
+
node: ast.AST = call
|
|
1541
|
+
for _ in range(16):
|
|
1542
|
+
parent = facts.parents.get(id(node))
|
|
1543
|
+
if isinstance(parent, ast.Attribute) and parent.attr == "using":
|
|
1544
|
+
gp = facts.parents.get(id(parent))
|
|
1545
|
+
if isinstance(gp, ast.Call):
|
|
1546
|
+
v = _pos_lit(gp, 0)
|
|
1547
|
+
if v and v.lower() in _ICEBERG_FORMATS:
|
|
1548
|
+
using_iceberg = True
|
|
1549
|
+
break
|
|
1550
|
+
if isinstance(parent, ast.Call):
|
|
1551
|
+
node = parent
|
|
1552
|
+
continue
|
|
1553
|
+
node = parent if parent is not None else node
|
|
1554
|
+
if parent is None:
|
|
1555
|
+
break
|
|
1556
|
+
written = _pos_lit(call, 0)
|
|
1557
|
+
span = node_span(starts, call)
|
|
1558
|
+
evi = sink.evi(path, sha, span, notes="writeTo")
|
|
1559
|
+
if not written:
|
|
1560
|
+
sink.diag(
|
|
1561
|
+
"dynamic-table-ref",
|
|
1562
|
+
"warning",
|
|
1563
|
+
"writeTo with non-literal target",
|
|
1564
|
+
path,
|
|
1565
|
+
)
|
|
1566
|
+
sink.rel(
|
|
1567
|
+
_WRITES,
|
|
1568
|
+
path,
|
|
1569
|
+
subject,
|
|
1570
|
+
to_unresolved="<dynamic>",
|
|
1571
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
1572
|
+
span=span,
|
|
1573
|
+
evidence=(evi,),
|
|
1574
|
+
)
|
|
1575
|
+
return
|
|
1576
|
+
cat, _, _name = split_table_name(written)
|
|
1577
|
+
iceberg = using_iceberg or (
|
|
1578
|
+
cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
|
|
1579
|
+
)
|
|
1580
|
+
ident = _table_entity(
|
|
1581
|
+
sink,
|
|
1582
|
+
written,
|
|
1583
|
+
iceberg=iceberg,
|
|
1584
|
+
path=path,
|
|
1585
|
+
sha=sha,
|
|
1586
|
+
span=span,
|
|
1587
|
+
evi=evi,
|
|
1588
|
+
)
|
|
1589
|
+
if ident is None:
|
|
1590
|
+
return
|
|
1591
|
+
sink.rel(
|
|
1592
|
+
_WRITES,
|
|
1593
|
+
path,
|
|
1594
|
+
subject,
|
|
1595
|
+
to_identity=ident,
|
|
1596
|
+
span=span,
|
|
1597
|
+
evidence=(evi,),
|
|
1598
|
+
)
|
|
1599
|
+
_catalog_ref_for(sink, written, subject, path, span, evi)
|
|
1600
|
+
sink.bump("writes")
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _named_write(
|
|
1604
|
+
sink: Sink,
|
|
1605
|
+
call: ast.Call,
|
|
1606
|
+
subject: DomainIdentity,
|
|
1607
|
+
facts: _PyFacts,
|
|
1608
|
+
*,
|
|
1609
|
+
path: str,
|
|
1610
|
+
sha: str,
|
|
1611
|
+
starts: tuple[int, ...],
|
|
1612
|
+
) -> None:
|
|
1613
|
+
"""``insertInto``/``saveAsTable("n.t")`` → ``WRITES_TO``."""
|
|
1614
|
+
written = _pos_or_kw(call, 0, "tableName") or _kw_lit(call, "name")
|
|
1615
|
+
span = node_span(starts, call)
|
|
1616
|
+
evi = sink.evi(path, sha, span, notes="named-write")
|
|
1617
|
+
if not written:
|
|
1618
|
+
sink.rel(
|
|
1619
|
+
_WRITES,
|
|
1620
|
+
path,
|
|
1621
|
+
subject,
|
|
1622
|
+
to_unresolved="<dynamic>",
|
|
1623
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
1624
|
+
span=span,
|
|
1625
|
+
evidence=(evi,),
|
|
1626
|
+
)
|
|
1627
|
+
sink.diag(
|
|
1628
|
+
"dynamic-table-ref",
|
|
1629
|
+
"warning",
|
|
1630
|
+
"non-literal write target",
|
|
1631
|
+
path,
|
|
1632
|
+
)
|
|
1633
|
+
return
|
|
1634
|
+
cat = split_table_name(written)[0]
|
|
1635
|
+
iceberg = cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
|
|
1636
|
+
ident = _table_entity(
|
|
1637
|
+
sink,
|
|
1638
|
+
written,
|
|
1639
|
+
iceberg=iceberg,
|
|
1640
|
+
path=path,
|
|
1641
|
+
sha=sha,
|
|
1642
|
+
span=span,
|
|
1643
|
+
evi=evi,
|
|
1644
|
+
)
|
|
1645
|
+
if ident is None:
|
|
1646
|
+
return
|
|
1647
|
+
sink.rel(
|
|
1648
|
+
_WRITES,
|
|
1649
|
+
path,
|
|
1650
|
+
subject,
|
|
1651
|
+
to_identity=ident,
|
|
1652
|
+
span=span,
|
|
1653
|
+
evidence=(evi,),
|
|
1654
|
+
)
|
|
1655
|
+
_catalog_ref_for(sink, written, subject, path, span, evi)
|
|
1656
|
+
sink.bump("writes")
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _named_read(
|
|
1660
|
+
sink: Sink,
|
|
1661
|
+
call: ast.Call,
|
|
1662
|
+
subject: DomainIdentity,
|
|
1663
|
+
facts: _PyFacts,
|
|
1664
|
+
*,
|
|
1665
|
+
path: str,
|
|
1666
|
+
sha: str,
|
|
1667
|
+
starts: tuple[int, ...],
|
|
1668
|
+
) -> None:
|
|
1669
|
+
"""``spark.table("c.n.t")`` → ``READS_FROM``."""
|
|
1670
|
+
written = _pos_lit(call, 0) or _kw_lit(call, "tableName")
|
|
1671
|
+
span = node_span(starts, call)
|
|
1672
|
+
evi = sink.evi(path, sha, span, notes="named-read")
|
|
1673
|
+
if not written:
|
|
1674
|
+
sink.rel(
|
|
1675
|
+
_READS,
|
|
1676
|
+
path,
|
|
1677
|
+
subject,
|
|
1678
|
+
to_unresolved="<dynamic>",
|
|
1679
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
1680
|
+
span=span,
|
|
1681
|
+
evidence=(evi,),
|
|
1682
|
+
)
|
|
1683
|
+
return
|
|
1684
|
+
cat = split_table_name(written)[0]
|
|
1685
|
+
iceberg = cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
|
|
1686
|
+
ident = _table_entity(
|
|
1687
|
+
sink,
|
|
1688
|
+
written,
|
|
1689
|
+
iceberg=iceberg,
|
|
1690
|
+
path=path,
|
|
1691
|
+
sha=sha,
|
|
1692
|
+
span=span,
|
|
1693
|
+
evi=evi,
|
|
1694
|
+
)
|
|
1695
|
+
if ident is None:
|
|
1696
|
+
return
|
|
1697
|
+
sink.rel(
|
|
1698
|
+
_READS,
|
|
1699
|
+
path,
|
|
1700
|
+
subject,
|
|
1701
|
+
to_identity=ident,
|
|
1702
|
+
span=span,
|
|
1703
|
+
evidence=(evi,),
|
|
1704
|
+
)
|
|
1705
|
+
_catalog_ref_for(sink, written, subject, path, span, evi)
|
|
1706
|
+
sink.bump("reads")
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def _read_path(
|
|
1710
|
+
sink: Sink,
|
|
1711
|
+
facts: _PyFacts,
|
|
1712
|
+
call: ast.Call,
|
|
1713
|
+
subject: DomainIdentity,
|
|
1714
|
+
*,
|
|
1715
|
+
path: str,
|
|
1716
|
+
sha: str,
|
|
1717
|
+
starts: tuple[int, ...],
|
|
1718
|
+
) -> None:
|
|
1719
|
+
"""``spark.read.<fmt>(path|table)`` — s3 → entidade; nome → tabela."""
|
|
1720
|
+
fmt = _dotted(call.func).rsplit(".", 1)[-1]
|
|
1721
|
+
val = _pos_lit(call, 0)
|
|
1722
|
+
span = node_span(starts, call)
|
|
1723
|
+
evi = sink.evi(path, sha, span, notes=f"read-{fmt}")
|
|
1724
|
+
fmt_is_iceberg = _chain_format(facts, call) in _ICEBERG_FORMATS
|
|
1725
|
+
if val is None:
|
|
1726
|
+
# lista literal de paths?
|
|
1727
|
+
arg0 = call.args[0] if call.args else None
|
|
1728
|
+
lst = _literal(arg0)
|
|
1729
|
+
if isinstance(lst, (list, tuple)):
|
|
1730
|
+
for v in lst:
|
|
1731
|
+
_read_path_value(
|
|
1732
|
+
sink,
|
|
1733
|
+
str(v),
|
|
1734
|
+
facts,
|
|
1735
|
+
subject,
|
|
1736
|
+
fmt_is_iceberg,
|
|
1737
|
+
path=path,
|
|
1738
|
+
sha=sha,
|
|
1739
|
+
span=span,
|
|
1740
|
+
evi=evi,
|
|
1741
|
+
)
|
|
1742
|
+
return
|
|
1743
|
+
sink.rel(
|
|
1744
|
+
_READS,
|
|
1745
|
+
path,
|
|
1746
|
+
subject,
|
|
1747
|
+
to_unresolved="<dynamic>",
|
|
1748
|
+
status=ResolutionStatus.UNRESOLVED,
|
|
1749
|
+
span=span,
|
|
1750
|
+
evidence=(evi,),
|
|
1751
|
+
)
|
|
1752
|
+
sink.diag(
|
|
1753
|
+
"dynamic-path",
|
|
1754
|
+
"warning",
|
|
1755
|
+
f"spark.read.{fmt} with non-literal path",
|
|
1756
|
+
path,
|
|
1757
|
+
)
|
|
1758
|
+
return
|
|
1759
|
+
_read_path_value(
|
|
1760
|
+
sink,
|
|
1761
|
+
val,
|
|
1762
|
+
facts,
|
|
1763
|
+
subject,
|
|
1764
|
+
fmt_is_iceberg,
|
|
1765
|
+
path=path,
|
|
1766
|
+
sha=sha,
|
|
1767
|
+
span=span,
|
|
1768
|
+
evi=evi,
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _read_path_value(
|
|
1773
|
+
sink: Sink,
|
|
1774
|
+
val: str,
|
|
1775
|
+
facts: _PyFacts,
|
|
1776
|
+
subject: DomainIdentity,
|
|
1777
|
+
iceberg: bool,
|
|
1778
|
+
*,
|
|
1779
|
+
path: str,
|
|
1780
|
+
sha: str,
|
|
1781
|
+
span: SourceSpan | None,
|
|
1782
|
+
evi: int,
|
|
1783
|
+
) -> None:
|
|
1784
|
+
s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
|
|
1785
|
+
if s3 is not None:
|
|
1786
|
+
sink.rel(
|
|
1787
|
+
_READS,
|
|
1788
|
+
path,
|
|
1789
|
+
subject,
|
|
1790
|
+
to_identity=s3,
|
|
1791
|
+
span=span,
|
|
1792
|
+
evidence=(evi,),
|
|
1793
|
+
)
|
|
1794
|
+
sink.bump("s3_paths")
|
|
1795
|
+
return
|
|
1796
|
+
if val.lower().startswith(("s3://", "s3a://", "s3n://")):
|
|
1797
|
+
sink.diag(
|
|
1798
|
+
"dynamic-s3-uri",
|
|
1799
|
+
"warning",
|
|
1800
|
+
f"s3 URI not statically decodable: {val[:80]}",
|
|
1801
|
+
path,
|
|
1802
|
+
)
|
|
1803
|
+
return
|
|
1804
|
+
if re.match(r"^[A-Za-z_][\w]*(?:\.[\w`\"]+)+$", val):
|
|
1805
|
+
ident = _table_entity(
|
|
1806
|
+
sink,
|
|
1807
|
+
val,
|
|
1808
|
+
iceberg=iceberg,
|
|
1809
|
+
path=path,
|
|
1810
|
+
sha=sha,
|
|
1811
|
+
span=span,
|
|
1812
|
+
evi=evi,
|
|
1813
|
+
)
|
|
1814
|
+
if ident is not None:
|
|
1815
|
+
sink.rel(
|
|
1816
|
+
_READS,
|
|
1817
|
+
path,
|
|
1818
|
+
subject,
|
|
1819
|
+
to_identity=ident,
|
|
1820
|
+
span=span,
|
|
1821
|
+
evidence=(evi,),
|
|
1822
|
+
)
|
|
1823
|
+
_catalog_ref_for(sink, val, subject, path, span, evi)
|
|
1824
|
+
sink.bump("reads")
|
|
1825
|
+
return
|
|
1826
|
+
sink.rel(
|
|
1827
|
+
_REFS,
|
|
1828
|
+
path,
|
|
1829
|
+
subject,
|
|
1830
|
+
to_unresolved=val[:200],
|
|
1831
|
+
status=ResolutionStatus.EXTERNAL,
|
|
1832
|
+
span=span,
|
|
1833
|
+
evidence=(evi,),
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def _athena_call(
|
|
1838
|
+
sink: Sink,
|
|
1839
|
+
call: ast.Call,
|
|
1840
|
+
subject: DomainIdentity,
|
|
1841
|
+
*,
|
|
1842
|
+
path: str,
|
|
1843
|
+
text: str,
|
|
1844
|
+
sha: str,
|
|
1845
|
+
starts: tuple[int, ...],
|
|
1846
|
+
) -> None:
|
|
1847
|
+
"""``start_query_execution(QueryString=..., QueryExecutionContext=…)``."""
|
|
1848
|
+
qctx = _dict_lit(_kw(call, "QueryExecutionContext"))
|
|
1849
|
+
catalog = schema = ""
|
|
1850
|
+
if qctx:
|
|
1851
|
+
catalog = str(qctx.get("Catalog") or "")
|
|
1852
|
+
schema = str(qctx.get("Database") or "")
|
|
1853
|
+
out = _dict_lit(_kw(call, "ResultConfiguration"))
|
|
1854
|
+
span = node_span(starts, call)
|
|
1855
|
+
if out:
|
|
1856
|
+
loc = out.get("OutputLocation")
|
|
1857
|
+
if isinstance(loc, str):
|
|
1858
|
+
evi = sink.evi(path, sha, span, notes="athena-output")
|
|
1859
|
+
s3 = s3_entity(sink, loc, path=path, sha=sha, span=span, evi=evi)
|
|
1860
|
+
if s3 is not None:
|
|
1861
|
+
sink.rel(
|
|
1862
|
+
_WRITES,
|
|
1863
|
+
path,
|
|
1864
|
+
subject,
|
|
1865
|
+
to_identity=s3,
|
|
1866
|
+
span=span,
|
|
1867
|
+
evidence=(evi,),
|
|
1868
|
+
)
|
|
1869
|
+
_sql_literal_call(
|
|
1870
|
+
sink,
|
|
1871
|
+
call,
|
|
1872
|
+
subject,
|
|
1873
|
+
arg_node=_kw(call, "QueryString"),
|
|
1874
|
+
path=path,
|
|
1875
|
+
text=text,
|
|
1876
|
+
sha=sha,
|
|
1877
|
+
starts=starts,
|
|
1878
|
+
use_catalog=catalog,
|
|
1879
|
+
use_schema=schema,
|
|
1880
|
+
notes="athena-querystring",
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _glue_job_call(
|
|
1885
|
+
sink: Sink,
|
|
1886
|
+
call: ast.Call,
|
|
1887
|
+
subject: DomainIdentity,
|
|
1888
|
+
*,
|
|
1889
|
+
path: str,
|
|
1890
|
+
sha: str,
|
|
1891
|
+
starts: tuple[int, ...],
|
|
1892
|
+
used: set[int],
|
|
1893
|
+
) -> None:
|
|
1894
|
+
"""boto3 ``create_job``/``update_job`` com kwargs literais."""
|
|
1895
|
+
name = _kw_lit(call, "Name")
|
|
1896
|
+
span = node_span(starts, call)
|
|
1897
|
+
evi = sink.evi(path, sha, span, notes="glue-job-def-call")
|
|
1898
|
+
if not name:
|
|
1899
|
+
sink.diag(
|
|
1900
|
+
"dynamic-job-name",
|
|
1901
|
+
"info",
|
|
1902
|
+
"create_job/update_job without literal Name — no job entity minted",
|
|
1903
|
+
path,
|
|
1904
|
+
)
|
|
1905
|
+
return
|
|
1906
|
+
job = sink.entity(
|
|
1907
|
+
kind=DomainEntityKind.GLUE_JOB,
|
|
1908
|
+
name=name,
|
|
1909
|
+
qualified_name=name,
|
|
1910
|
+
namespace=posixpath.dirname(path),
|
|
1911
|
+
path=path,
|
|
1912
|
+
span=span,
|
|
1913
|
+
content_hash=sha,
|
|
1914
|
+
attributes={"artifact_kind": "boto3-create-job"},
|
|
1915
|
+
evidence=(evi,),
|
|
1916
|
+
)
|
|
1917
|
+
if subject.logical_key() != job.logical_key():
|
|
1918
|
+
sink.rel(
|
|
1919
|
+
"CONTAINS",
|
|
1920
|
+
path,
|
|
1921
|
+
subject,
|
|
1922
|
+
to_identity=job,
|
|
1923
|
+
span=span,
|
|
1924
|
+
evidence=(evi,),
|
|
1925
|
+
)
|
|
1926
|
+
_glue_call_role(sink, call, job, path=path, sha=sha, span=span, evi=evi, used=used)
|
|
1927
|
+
_glue_call_command(sink, call, job, name, path=path, sha=sha, span=span, evi=evi)
|
|
1928
|
+
_glue_call_defaults(sink, call, job, name, path=path, sha=sha, span=span, evi=evi)
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _glue_call_role(
|
|
1932
|
+
sink: Sink,
|
|
1933
|
+
call: ast.Call,
|
|
1934
|
+
job: DomainIdentity,
|
|
1935
|
+
*,
|
|
1936
|
+
path: str,
|
|
1937
|
+
sha: str,
|
|
1938
|
+
span: SourceSpan | None,
|
|
1939
|
+
evi: int,
|
|
1940
|
+
used: set[int],
|
|
1941
|
+
) -> None:
|
|
1942
|
+
"""``Role="arn:…:role/x"`` → ``iam_role`` + ``ASSUMES_ROLE`` external."""
|
|
1943
|
+
role = _kw(call, "Role")
|
|
1944
|
+
role_lit = _str_lit(role)
|
|
1945
|
+
if not role_lit or parse_arn(role_lit) is None:
|
|
1946
|
+
return
|
|
1947
|
+
role_ident = _role_entity(sink, role_lit, path=path, sha=sha, span=span, evi=evi)
|
|
1948
|
+
if role_ident is None:
|
|
1949
|
+
return
|
|
1950
|
+
sink.rel(
|
|
1951
|
+
_ASSUMES,
|
|
1952
|
+
path,
|
|
1953
|
+
job,
|
|
1954
|
+
to_identity=role_ident,
|
|
1955
|
+
status=ResolutionStatus.EXTERNAL,
|
|
1956
|
+
span=span,
|
|
1957
|
+
evidence=(evi,),
|
|
1958
|
+
)
|
|
1959
|
+
if isinstance(role, ast.Constant):
|
|
1960
|
+
used.add(id(role))
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _glue_call_command(
|
|
1964
|
+
sink: Sink,
|
|
1965
|
+
call: ast.Call,
|
|
1966
|
+
job: DomainIdentity,
|
|
1967
|
+
name: str,
|
|
1968
|
+
*,
|
|
1969
|
+
path: str,
|
|
1970
|
+
sha: str,
|
|
1971
|
+
span: SourceSpan | None,
|
|
1972
|
+
evi: int,
|
|
1973
|
+
) -> None:
|
|
1974
|
+
"""``Command={"Name","ScriptLocation"}`` → attr + ``REFERENCES`` s3."""
|
|
1975
|
+
cmd = _dict_lit(_kw(call, "Command"))
|
|
1976
|
+
if not cmd:
|
|
1977
|
+
return
|
|
1978
|
+
loc = cmd.get("ScriptLocation")
|
|
1979
|
+
if isinstance(loc, str):
|
|
1980
|
+
s3 = s3_entity(sink, loc, path=path, sha=sha, span=span, evi=evi)
|
|
1981
|
+
if s3 is not None:
|
|
1982
|
+
sink.rel(
|
|
1983
|
+
_REFS,
|
|
1984
|
+
path,
|
|
1985
|
+
job,
|
|
1986
|
+
to_identity=s3,
|
|
1987
|
+
span=span,
|
|
1988
|
+
evidence=(evi,),
|
|
1989
|
+
)
|
|
1990
|
+
cname = cmd.get("Name")
|
|
1991
|
+
if isinstance(cname, str):
|
|
1992
|
+
# atributo pós-criação: reemite entidade com merge honesto
|
|
1993
|
+
sink.entity_for(
|
|
1994
|
+
job,
|
|
1995
|
+
kind=DomainEntityKind.GLUE_JOB,
|
|
1996
|
+
name=name,
|
|
1997
|
+
path=path,
|
|
1998
|
+
span=span,
|
|
1999
|
+
content_hash=sha,
|
|
2000
|
+
attributes={
|
|
2001
|
+
"artifact_kind": "boto3-create-job",
|
|
2002
|
+
"command": cname,
|
|
2003
|
+
},
|
|
2004
|
+
evidence=(evi,),
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def _glue_call_defaults(
|
|
2009
|
+
sink: Sink,
|
|
2010
|
+
call: ast.Call,
|
|
2011
|
+
job: DomainIdentity,
|
|
2012
|
+
name: str,
|
|
2013
|
+
*,
|
|
2014
|
+
path: str,
|
|
2015
|
+
sha: str,
|
|
2016
|
+
span: SourceSpan | None,
|
|
2017
|
+
evi: int,
|
|
2018
|
+
) -> None:
|
|
2019
|
+
"""``DefaultArguments`` → attrs ``arg.*`` redacted + refs ``s3://``."""
|
|
2020
|
+
defaults = _dict_lit(_kw(call, "DefaultArguments"))
|
|
2021
|
+
if not defaults:
|
|
2022
|
+
return
|
|
2023
|
+
extra: dict[str, str] = {}
|
|
2024
|
+
for k, v in defaults.items():
|
|
2025
|
+
extra[f"arg.{k}"] = redact_if_sensitive(k, str(v))
|
|
2026
|
+
if isinstance(v, str) and parse_s3_uri(v) is not None:
|
|
2027
|
+
s3 = s3_entity(sink, v, path=path, sha=sha, span=span, evi=evi)
|
|
2028
|
+
if s3 is not None:
|
|
2029
|
+
sink.rel(
|
|
2030
|
+
_REFS,
|
|
2031
|
+
path,
|
|
2032
|
+
job,
|
|
2033
|
+
to_identity=s3,
|
|
2034
|
+
span=span,
|
|
2035
|
+
evidence=(evi,),
|
|
2036
|
+
)
|
|
2037
|
+
if extra:
|
|
2038
|
+
sink.entity_for(
|
|
2039
|
+
job,
|
|
2040
|
+
kind=DomainEntityKind.GLUE_JOB,
|
|
2041
|
+
name=name,
|
|
2042
|
+
path=path,
|
|
2043
|
+
span=span,
|
|
2044
|
+
content_hash=sha,
|
|
2045
|
+
attributes={"artifact_kind": "boto3-create-job", **extra},
|
|
2046
|
+
evidence=(evi,),
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def _sweep_literals(
|
|
2051
|
+
sink: Sink,
|
|
2052
|
+
facts: _PyFacts,
|
|
2053
|
+
subject: DomainIdentity,
|
|
2054
|
+
*,
|
|
2055
|
+
path: str,
|
|
2056
|
+
sha: str,
|
|
2057
|
+
starts: tuple[int, ...],
|
|
2058
|
+
used: set[int],
|
|
2059
|
+
) -> None:
|
|
2060
|
+
"""Varredura final: literais ``s3://``/ARN não consumidos."""
|
|
2061
|
+
for node in facts.str_nodes:
|
|
2062
|
+
if id(node) in used:
|
|
2063
|
+
continue
|
|
2064
|
+
val = node.value
|
|
2065
|
+
if not isinstance(val, str):
|
|
2066
|
+
continue
|
|
2067
|
+
span = node_span(starts, node)
|
|
2068
|
+
if parse_s3_uri(val) is not None:
|
|
2069
|
+
evi = sink.evi(path, sha, span, notes="s3-literal")
|
|
2070
|
+
s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
|
|
2071
|
+
if s3 is not None:
|
|
2072
|
+
sink.rel(
|
|
2073
|
+
_REFS,
|
|
2074
|
+
path,
|
|
2075
|
+
subject,
|
|
2076
|
+
to_identity=s3,
|
|
2077
|
+
span=span,
|
|
2078
|
+
evidence=(evi,),
|
|
2079
|
+
)
|
|
2080
|
+
sink.bump("s3_paths")
|
|
2081
|
+
continue
|
|
2082
|
+
for m in ARN_RE.finditer(val):
|
|
2083
|
+
parts = parse_arn(m.group(0))
|
|
2084
|
+
if parts is None or not is_iam_role_arn(parts):
|
|
2085
|
+
continue
|
|
2086
|
+
evi = sink.evi(path, sha, span, notes="iam-arn-literal")
|
|
2087
|
+
role = _role_entity(
|
|
2088
|
+
sink,
|
|
2089
|
+
m.group(0),
|
|
2090
|
+
path=path,
|
|
2091
|
+
sha=sha,
|
|
2092
|
+
span=span,
|
|
2093
|
+
evi=evi,
|
|
2094
|
+
)
|
|
2095
|
+
if role is not None:
|
|
2096
|
+
sink.rel(
|
|
2097
|
+
_REFS,
|
|
2098
|
+
path,
|
|
2099
|
+
subject,
|
|
2100
|
+
to_identity=role,
|
|
2101
|
+
span=span,
|
|
2102
|
+
evidence=(evi,),
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _degraded_lits(text: str, starts: tuple[int, ...]) -> list[tuple[str, SourceSpan | None]]:
|
|
2107
|
+
"""Literais str coletados por ``tokenize`` até a quebra."""
|
|
2108
|
+
lits: list[tuple[str, SourceSpan | None]] = []
|
|
2109
|
+
try:
|
|
2110
|
+
tokens = tokenize.generate_tokens(io.StringIO(text).readline)
|
|
2111
|
+
for tok in tokens:
|
|
2112
|
+
if tok.type != tokenize.STRING:
|
|
2113
|
+
continue
|
|
2114
|
+
try:
|
|
2115
|
+
val = ast.literal_eval(tok.string)
|
|
2116
|
+
except (ValueError, SyntaxError):
|
|
2117
|
+
continue
|
|
2118
|
+
if isinstance(val, str):
|
|
2119
|
+
lits.append((val, _token_span(starts, tok.start, tok.end)))
|
|
2120
|
+
except tokenize.TokenError:
|
|
2121
|
+
pass # degradado — o que foi coletado até a quebra vale
|
|
2122
|
+
return lits
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _emit_degraded_lit(
|
|
2126
|
+
sink: Sink,
|
|
2127
|
+
val: str,
|
|
2128
|
+
span: SourceSpan | None,
|
|
2129
|
+
job: DomainIdentity,
|
|
2130
|
+
*,
|
|
2131
|
+
path: str,
|
|
2132
|
+
sha: str,
|
|
2133
|
+
) -> None:
|
|
2134
|
+
"""Um literal degradado → entidade s3 ou ``iam_role`` + REFERENCES."""
|
|
2135
|
+
if parse_s3_uri(val) is not None:
|
|
2136
|
+
evi = sink.evi(path, sha, span, notes="s3-literal")
|
|
2137
|
+
s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
|
|
2138
|
+
if s3 is not None:
|
|
2139
|
+
sink.rel(
|
|
2140
|
+
_REFS,
|
|
2141
|
+
path,
|
|
2142
|
+
job,
|
|
2143
|
+
to_identity=s3,
|
|
2144
|
+
span=span,
|
|
2145
|
+
evidence=(evi,),
|
|
2146
|
+
)
|
|
2147
|
+
return
|
|
2148
|
+
for m in ARN_RE.finditer(val):
|
|
2149
|
+
parts = parse_arn(m.group(0))
|
|
2150
|
+
if parts is None or not is_iam_role_arn(parts):
|
|
2151
|
+
continue
|
|
2152
|
+
evi = sink.evi(path, sha, span, notes="iam-arn")
|
|
2153
|
+
role = _role_entity(sink, m.group(0), path=path, sha=sha, span=span, evi=evi)
|
|
2154
|
+
if role is not None:
|
|
2155
|
+
sink.rel(
|
|
2156
|
+
_REFS,
|
|
2157
|
+
path,
|
|
2158
|
+
job,
|
|
2159
|
+
to_identity=role,
|
|
2160
|
+
span=span,
|
|
2161
|
+
evidence=(evi,),
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _degraded_scan(
|
|
2166
|
+
sink: Sink,
|
|
2167
|
+
*,
|
|
2168
|
+
path: str,
|
|
2169
|
+
text: str,
|
|
2170
|
+
sha: str,
|
|
2171
|
+
starts: tuple[int, ...],
|
|
2172
|
+
) -> None:
|
|
2173
|
+
"""``SyntaxError`` → varredura tokenize de literais (degradado)."""
|
|
2174
|
+
lits = _degraded_lits(text, starts)
|
|
2175
|
+
s3_count = sum(1 for v, _ in lits if parse_s3_uri(v) is not None)
|
|
2176
|
+
arn_count = sum(1 for v, _ in lits if ARN_RE.search(v))
|
|
2177
|
+
if not s3_count and not arn_count:
|
|
2178
|
+
return
|
|
2179
|
+
stem = PurePosixPath(path).stem
|
|
2180
|
+
kind = (
|
|
2181
|
+
DomainEntityKind.GLUE_JOB
|
|
2182
|
+
if "awsglue" in text or "GlueContext" in text
|
|
2183
|
+
else DomainEntityKind.DATA_PIPELINE
|
|
2184
|
+
)
|
|
2185
|
+
job = sink.entity(
|
|
2186
|
+
kind=kind,
|
|
2187
|
+
name=stem,
|
|
2188
|
+
qualified_name=stem,
|
|
2189
|
+
namespace=posixpath.dirname(path),
|
|
2190
|
+
path=path,
|
|
2191
|
+
span=None,
|
|
2192
|
+
content_hash=sha,
|
|
2193
|
+
attributes={
|
|
2194
|
+
"artifact_kind": "py-script",
|
|
2195
|
+
"degraded_scan": "tokenize-fallback",
|
|
2196
|
+
},
|
|
2197
|
+
evidence=(sink.evi(path, sha, None, notes="degraded"),),
|
|
2198
|
+
)
|
|
2199
|
+
for val, span in lits:
|
|
2200
|
+
_emit_degraded_lit(sink, val, span, job, path=path, sha=sha)
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
def _token_span(
|
|
2204
|
+
starts: tuple[int, ...],
|
|
2205
|
+
start: tuple[int, int],
|
|
2206
|
+
end: tuple[int, int],
|
|
2207
|
+
) -> SourceSpan | None:
|
|
2208
|
+
"""(lineno, col-char) de tokenize → SourceSpan bytes."""
|
|
2209
|
+
from code_atlas.domains.aws.common import byte_span
|
|
2210
|
+
|
|
2211
|
+
def _abs(pos: tuple[int, int]) -> int | None:
|
|
2212
|
+
line, col = pos
|
|
2213
|
+
if line < 1 or line > len(starts):
|
|
2214
|
+
return None
|
|
2215
|
+
return starts[line - 1] + col
|
|
2216
|
+
|
|
2217
|
+
b0, b1 = _abs(start), _abs(end)
|
|
2218
|
+
if b0 is None or b1 is None:
|
|
2219
|
+
return None
|
|
2220
|
+
return byte_span(starts, b0, b1)
|