baucli 1.0.1__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.
baucli/apexlang.py ADDED
@@ -0,0 +1,317 @@
1
+ """APEXLang split-export reader for DOCAPEX ingestion.
2
+
3
+ Builds the `POST /apex/sync` payload (contract of
4
+ PKG_BAU_DOCAPEX_CORE.SYNC_APP) by PARSING THE APP DIRECTORY of a SQLcl
5
+ split export (`application.apx` + `pages/p*.apx`) — no APEX dictionary
6
+ access required. This is the canonical ingestion path: the split export
7
+ is what teams version and what `apex-rebuild` operates on.
8
+
9
+ Component ids
10
+ =============
11
+ The server upserts regions/items/processes/DAs keyed by numeric component
12
+ ids. APEXLang blocks have names, not ids, so we derive a STABLE numeric id
13
+ from sha256(page|kind|name|ordinal): the same block maps to the same id on
14
+ every sync, which keeps the server's per-row hash change-detection working.
15
+
16
+ Parsing model
17
+ =============
18
+ Same machinery proven by apex-rebuild: paren-balanced block scanning that
19
+ skips ```fences``` and quoted strings, plus property lookups by indent
20
+ depth. Only depth-1 blocks of the page are scanned (region / pageItem /
21
+ process / dynamicAction are siblings in the MMD grammar).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import re
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ from baucli.apex_rebuild import find_matching_paren, _resolve_root_and_appdir
32
+
33
+ _PAGE_HEAD = re.compile(r"^page\s+(\d+)\s*\(", re.MULTILINE)
34
+ _APP_HEAD = re.compile(r"^app\s+([A-Za-z0-9_-]+)\s*\(", re.MULTILINE)
35
+ _BLOCK = re.compile(
36
+ r"^([ \t]*)(region|pageItem|process|dynamicAction)[ \t]+"
37
+ r"([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\(",
38
+ re.MULTILINE,
39
+ )
40
+
41
+
42
+ def _stable_id(*parts) -> int:
43
+ """Deterministic 48-bit id from the block identity — stable across
44
+ syncs so the server's hash diffing sees the same row."""
45
+ key = "|".join(str(p) for p in parts)
46
+ return int(hashlib.sha256(key.encode("utf-8")).hexdigest()[:12], 16)
47
+
48
+
49
+ def _prop(body: str, key: str, indent: str) -> str | None:
50
+ """Scalar `key: value` at exactly the given indent."""
51
+ m = re.search(rf"^{re.escape(indent)}{re.escape(key)}\s*:\s*(.*?)\s*$",
52
+ body, re.MULTILINE)
53
+ return m.group(1) if m else None
54
+
55
+
56
+ def _nested(body: str, parent: str, key: str) -> str | None:
57
+ """`parent { ... key: value ... }` anywhere in the block body."""
58
+ m = re.search(rf"^([ \t]*){re.escape(parent)}\s*\{{(.*?)^\1\}}",
59
+ body, re.MULTILINE | re.DOTALL)
60
+ if not m:
61
+ return None
62
+ kv = re.search(rf"^[ \t]*{re.escape(key)}\s*:\s*(.*?)\s*$",
63
+ m.group(2), re.MULTILINE)
64
+ return kv.group(1) if kv else None
65
+
66
+
67
+ def _fence(body: str) -> str | None:
68
+ """First ```fenced``` content in the block body (sql/plsql/html)."""
69
+ m = re.search(r"```[a-z]*\n(.*?)```", body, re.DOTALL)
70
+ return m.group(1).strip() if m else None
71
+
72
+
73
+ def _blocks(page_body: str, page_indent: str = " "):
74
+ """Depth-1 blocks (kind, name, body) of a page body."""
75
+ for m in _BLOCK.finditer(page_body):
76
+ if m.group(1) != page_indent:
77
+ continue # nested (e.g. `action` inside dynamicAction)
78
+ op = page_body.find("(", m.end() - 1)
79
+ try:
80
+ end = find_matching_paren(page_body, op)
81
+ except ValueError:
82
+ continue
83
+ yield m.group(2), m.group(3), page_body[op + 1:end - 1]
84
+
85
+
86
+ def _parse_page(path: Path, payload: dict) -> None:
87
+ src = path.read_text(encoding="utf-8", errors="replace")
88
+ hm = _PAGE_HEAD.search(src)
89
+ if not hm:
90
+ return
91
+ page_id = int(hm.group(1))
92
+ op = src.find("(", hm.end() - 1)
93
+ body = src[op + 1:find_matching_paren(src, op) - 1]
94
+
95
+ mode = _nested(body, "appearance", "pageMode") or "normal"
96
+ # Alvos `page: N` (colunas-link, botões redirect, branches) → usados por
97
+ # _infer_modal_launchers p/ descobrir qual página lança cada modal.
98
+ _targets = sorted({int(n) for n in re.findall(r"\bpage\s*:\s*(\d+)", body)})
99
+ payload["pages"].append({
100
+ "page_id": page_id,
101
+ "page_name": _prop(body, "name", " "),
102
+ "page_title": _prop(body, "title", " "),
103
+ "page_alias": _prop(body, "alias", " "),
104
+ "page_mode": "Modal Dialog" if mode == "modalDialog" else "Normal",
105
+ "page_type": None,
106
+ "parent_page_id": None,
107
+ "authorization_scheme":
108
+ (_nested(body, "security", "authorizationScheme") or "")
109
+ .lstrip("@") or None,
110
+ "help_text": _nested(body, "help", "helpText"),
111
+ "_targets": _targets,
112
+ })
113
+
114
+ for kind, name, blk in _blocks(body):
115
+ if kind == "region":
116
+ # Source: fenced SQL/PLSQL, or the form/table reference.
117
+ source = _fence(blk)
118
+ if source is None:
119
+ source = (_nested(blk, "source", "tableName")
120
+ or _nested(blk, "source", "table"))
121
+ payload["regions"].append({
122
+ "page_id": page_id,
123
+ "region_id": _stable_id(page_id, "region", name),
124
+ "region_name": _prop(blk, "name", " ") or name,
125
+ "region_type": _prop(blk, "type", " "),
126
+ "region_template":
127
+ (_nested(blk, "appearance", "template") or "").lstrip("@/")
128
+ or None,
129
+ "display_position": _nested(blk, "layout", "slot"),
130
+ "display_sequence":
131
+ _num(_nested(blk, "layout", "sequence")),
132
+ "parent_region_id": None,
133
+ "region_source": source,
134
+ })
135
+ elif kind == "pageItem":
136
+ label = (_nested(blk, "label", "label")
137
+ or _prop(blk, "label", " "))
138
+ template = _nested(blk, "appearance", "template") or ""
139
+ required = ("Y" if ("required" in template
140
+ or (_nested(blk, "validation",
141
+ "valueRequired") == "true"))
142
+ else "N")
143
+ src_type = _nested(blk, "source", "type")
144
+ src_value = (_nested(blk, "source", "dbColumn")
145
+ or _nested(blk, "source", "column")
146
+ or _nested(blk, "source", "sqlQuery"))
147
+ if src_type is None and _nested(blk, "source", "formRegion"):
148
+ src_type = "formRegion"
149
+ # Vínculo item→região: layout { region: @nome } aponta o bloco da
150
+ # região; o id estável é o mesmo gerado para o bloco region, então
151
+ # o servidor resolve o FK BAU_DOCAPEX_REGIONS_ID no upsert.
152
+ region_ref = _nested(blk, "layout", "region")
153
+ payload["items"].append({
154
+ "page_id": page_id,
155
+ "region_id": (_stable_id(page_id, "region", region_ref.lstrip("@"))
156
+ if region_ref else None),
157
+ "item_id": _stable_id(page_id, "item", name),
158
+ "item_name": name,
159
+ "item_type": _prop(blk, "type", " "),
160
+ "label": (label or "")[:450] or None,
161
+ "display_sequence": _num(_nested(blk, "layout", "sequence")),
162
+ "data_type": _nested(blk, "source", "dataType"),
163
+ "is_required": required,
164
+ # VARCHAR2(4000) on the server is BYTES — UTF-8 text must
165
+ # stay well under it (4000 chars of accented text overflow).
166
+ "default_value": (_nested(blk, "default", "staticValue")
167
+ or "")[:1000] or None,
168
+ "source_type": src_type,
169
+ "source_value": (src_value or "")[:2000] or None,
170
+ "lov_name": None,
171
+ "help_text": _nested(blk, "help", "helpText"),
172
+ })
173
+ elif kind == "process":
174
+ payload["proc"].append({
175
+ "page_id": page_id,
176
+ "process_id": _stable_id(page_id, "process", name),
177
+ "process_name": _prop(blk, "name", " ") or name,
178
+ "process_type": _prop(blk, "type", " "),
179
+ "process_point":
180
+ _nested(blk, "execution", "point") or "afterSubmit",
181
+ "display_sequence":
182
+ _num(_nested(blk, "execution", "sequence")),
183
+ # Native form processes (autoRowFetch/autoRowProcessing)
184
+ # carry the table in source{tableName}, not a code fence.
185
+ "process_source": (_fence(blk)
186
+ or _nested(blk, "source", "tableName")),
187
+ "when_button_pressed":
188
+ _nested(blk, "serverSideCondition", "value"),
189
+ })
190
+ elif kind == "dynamicAction":
191
+ actions = re.findall(
192
+ r"^[ \t]*action[ \t]+[A-Za-z_][\w-]*[ \t]*\(\s*\n"
193
+ r"[ \t]*action\s*:\s*(\S+)",
194
+ blk, re.MULTILINE)
195
+ payload["da"].append({
196
+ "page_id": page_id,
197
+ "region_id": None,
198
+ "da_id": _stable_id(page_id, "da", name),
199
+ "da_name": _prop(blk, "name", " ") or name,
200
+ "event_name": _nested(blk, "when", "event"),
201
+ "selection_type": _nested(blk, "when", "selectionType"),
202
+ "selection_value":
203
+ (_nested(blk, "when", "button")
204
+ or _nested(blk, "when", "items")
205
+ or "").lstrip("@") or None,
206
+ "condition_type": None,
207
+ "condition_value": None,
208
+ "actions_json": json.dumps(actions) if actions else None,
209
+ })
210
+
211
+
212
+ def _num(v: str | None):
213
+ try:
214
+ return int(v) if v is not None else None
215
+ except (TypeError, ValueError):
216
+ return None
217
+
218
+
219
+ def _application_id(app_root: Path, override: int | None) -> int | None:
220
+ """From --app-id, else the deployments/*.json manifest."""
221
+ if override:
222
+ return override
223
+ dep_dir = app_root / "deployments"
224
+ if dep_dir.is_dir():
225
+ for f in sorted(dep_dir.glob("*.json")):
226
+ try:
227
+ data = json.loads(f.read_text(encoding="utf-8"))
228
+ app_id = (data.get("app") or {}).get("id")
229
+ if app_id:
230
+ return int(app_id)
231
+ except (OSError, ValueError):
232
+ continue
233
+ return None
234
+
235
+
236
+ def extract_apexlang(project_root: Path, app_dir: str, schema: str,
237
+ application_id: int | None = None,
238
+ workspace: str | None = None) -> dict:
239
+ """Parse an APEXLang split export into the /apex/sync payload."""
240
+ root, eff_app_dir = _resolve_root_and_appdir(project_root, app_dir)
241
+ if root is None:
242
+ raise FileNotFoundError(
243
+ f"App directory '{app_dir}' has no pages/ folder under "
244
+ f"{project_root} or its parents.")
245
+ app_root = root / "apex" / eff_app_dir
246
+
247
+ app_id = _application_id(app_root, application_id)
248
+ if app_id is None:
249
+ raise ValueError(
250
+ "Could not determine the APEX application id: pass --app-id or "
251
+ "keep a deployments/*.json with {\"app\":{\"id\":N}} in the "
252
+ "export.")
253
+
254
+ app_name = None
255
+ app_alias = None
256
+ app_file = app_root / "application.apx"
257
+ if app_file.is_file():
258
+ src = app_file.read_text(encoding="utf-8", errors="replace")
259
+ am = _APP_HEAD.search(src)
260
+ if am:
261
+ app_alias = am.group(1)
262
+ op = src.find("(", am.end() - 1)
263
+ body = src[op + 1:find_matching_paren(src, op) - 1]
264
+ app_name = _prop(body, "name", " ")
265
+
266
+ page_files = sorted((app_root / "pages").glob("*.apx"))
267
+ last_upd = max((f.stat().st_mtime for f in page_files), default=None)
268
+
269
+ payload = {
270
+ "schema": schema.upper(),
271
+ "app": {
272
+ "application_id": app_id,
273
+ "app_name": app_name or eff_app_dir,
274
+ "app_alias": app_alias,
275
+ "workspace_name": workspace,
276
+ "authentication_scheme": None,
277
+ "theme_name": None,
278
+ "build_status": None,
279
+ "app_version": None,
280
+ "page_count": len(page_files),
281
+ "last_updated_by": "APEXLANG",
282
+ "last_updated_on": (
283
+ datetime.fromtimestamp(last_upd, tz=timezone.utc)
284
+ .strftime("%Y-%m-%dT%H:%M:%S") if last_upd else None),
285
+ },
286
+ "pages": [], "regions": [], "items": [], "proc": [], "da": [],
287
+ }
288
+ for f in page_files:
289
+ _parse_page(f, payload)
290
+ _infer_modal_launchers(payload)
291
+ return payload
292
+
293
+
294
+ def _infer_modal_launchers(payload: dict) -> None:
295
+ """Infere, p/ cada página modal (Modal Dialog), a página LANÇADORA — a
296
+ página Normal que a referencia via `page: N` (coluna-link / botão / branch).
297
+ Grava `launch_page` no dict da modal p/ o servidor registrar
298
+ `BAU_DOCAPEX_PAGES.SS_LAUNCH_PAGE` no sync (só preenche quando a coluna está
299
+ NULL — edições da p42 vencem). Não descobre modais abertas só por JS
300
+ (`apex.navigation.dialog`) sem `page: N` no export — essas seguem pela
301
+ auto-descoberta em runtime (harvest do link f?p). Limpa o campo transitório
302
+ `_targets`."""
303
+ pages = payload.get("pages", [])
304
+ by_id = {p["page_id"]: p for p in pages}
305
+ cands: dict[int, list[int]] = {}
306
+ for p in pages:
307
+ for t in (p.get("_targets") or []):
308
+ if t and t != p["page_id"]:
309
+ cands.setdefault(t, []).append(p["page_id"])
310
+ for p in pages:
311
+ if p.get("page_mode") == "Modal Dialog":
312
+ ls = cands.get(p["page_id"], [])
313
+ # prefere lançador Normal (evita modal→modal); desempate determinístico
314
+ normal = sorted(l for l in ls
315
+ if by_id.get(l, {}).get("page_mode") != "Modal Dialog")
316
+ p["launch_page"] = normal[0] if normal else (sorted(ls)[0] if ls else None)
317
+ p.pop("_targets", None)