pythia-plsql 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythia.py +1566 -0
- pythia_plsql-0.1.0.dist-info/METADATA +167 -0
- pythia_plsql-0.1.0.dist-info/RECORD +29 -0
- pythia_plsql-0.1.0.dist-info/WHEEL +5 -0
- pythia_plsql-0.1.0.dist-info/entry_points.txt +2 -0
- pythia_plsql-0.1.0.dist-info/licenses/LICENSE +21 -0
- pythia_plsql-0.1.0.dist-info/top_level.txt +3 -0
- pythia_queries/compile-errors.sql +17 -0
- pythia_queries/dependencies.sql +30 -0
- pythia_queries/impact.sql +26 -0
- pythia_queries/invalid-objects.sql +11 -0
- pythia_queries/name-occupants.sql +15 -0
- pythia_queries/object-source.sql +13 -0
- pythia_queries/plscope-enabled.sql +9 -0
- pythia_queries/plscope-statements.sql +21 -0
- pythia_queries/plscope-usages.sql +18 -0
- pythia_queries/session-privileges.sql +8 -0
- pythia_queries/similar-candidates.sql +13 -0
- pythia_queries/source.sql +10 -0
- pythia_skills/plsql-apply/SKILL.md +124 -0
- pythia_skills/plsql-explore/SKILL.md +61 -0
- pythia_skills/plsql-explore/reference/data-dictionary.md +61 -0
- pythia_skills/plsql-impact/SKILL.md +56 -0
- pythia_skills/plsql-review/SKILL.md +50 -0
- pythia_skills/plsql-review/reference/antipatterns.md +106 -0
- pythia_skills/plsql-setup/SKILL.md +76 -0
- pythia_skills/plsql-skill-author/SKILL.md +94 -0
- pythia_skills/plsql-write/SKILL.md +52 -0
- pythia_skills/plsql-write/reference/patterns.md +99 -0
pythia.py
ADDED
|
@@ -0,0 +1,1566 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pythia — ask an Oracle Database directly instead of reading stale dumps.
|
|
3
|
+
|
|
4
|
+
Reads are plain commands; sql accepts SELECT/WITH only. Writes go through
|
|
5
|
+
`apply` — snapshot, impact, preview, apply, verify, report — gated by
|
|
6
|
+
.pythia/policy.json. There is no --write flag: the write path is `apply`,
|
|
7
|
+
nothing else.
|
|
8
|
+
|
|
9
|
+
Connection resolution order:
|
|
10
|
+
1. --conn NAME
|
|
11
|
+
2. PYTHIA_CONNECTION (name of an entry in connections.json)
|
|
12
|
+
3. PYTHIA_USER / PYTHIA_PASSWORD / PYTHIA_DSN (+ optional PYTHIA_SCHEMA)
|
|
13
|
+
4. .pythia/connections.json, searched upward from the current directory
|
|
14
|
+
(override the file path with PYTHIA_CONFIG):
|
|
15
|
+
- a single entry is used as-is
|
|
16
|
+
- with several entries, the path segment directly under the project
|
|
17
|
+
root picks one (root/DEV/anything -> DEV)
|
|
18
|
+
- failing that, the entry named by a top-level "default": "<name>"
|
|
19
|
+
- anything still ambiguous is an error, never a guess
|
|
20
|
+
|
|
21
|
+
Output is capped so large objects cannot swallow a context window; every cut
|
|
22
|
+
is announced with "-- truncated ..." (text) or "truncated": true (JSON), so
|
|
23
|
+
you always know whether you saw everything.
|
|
24
|
+
|
|
25
|
+
pythia check
|
|
26
|
+
pythia ls "PKG_%"
|
|
27
|
+
pythia src MY_PACKAGE --body
|
|
28
|
+
pythia args MY_PROCEDURE
|
|
29
|
+
pythia ddl TABLE MY_TABLE
|
|
30
|
+
pythia cols MY_TABLE
|
|
31
|
+
pythia grep "some_identifier"
|
|
32
|
+
pythia sql "select count(*) from all_views where owner = user"
|
|
33
|
+
pythia invalid
|
|
34
|
+
pythia errors MY_PACKAGE
|
|
35
|
+
pythia deps MY_PACKAGE --depth 2
|
|
36
|
+
pythia impact MY_TABLE
|
|
37
|
+
pythia similar PKG_ORDER_TOTAL_LIST
|
|
38
|
+
pythia plscope MY_TABLE
|
|
39
|
+
pythia apply PKG_ORDER_BODY.sql
|
|
40
|
+
pythia apply PKG_ORDER_BODY.sql --confirm 7f3a91
|
|
41
|
+
pythia journal list
|
|
42
|
+
pythia journal restore <id>
|
|
43
|
+
pythia policy
|
|
44
|
+
pythia install
|
|
45
|
+
"""
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import pathlib
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
READONLY = re.compile(r"^\s*(select|with)\b", re.I)
|
|
54
|
+
CONFIG_DIR = ".pythia"
|
|
55
|
+
CONFIG_NAME = "connections.json"
|
|
56
|
+
INT_MAX = 2147483647
|
|
57
|
+
|
|
58
|
+
def _pack_dir(source_name, installed_name):
|
|
59
|
+
"""Repo layout first (reviewable dirs next to scripts/), wheel layout
|
|
60
|
+
second (package data installed beside this module)."""
|
|
61
|
+
here = pathlib.Path(__file__).resolve().parent
|
|
62
|
+
source = here.parent / source_name
|
|
63
|
+
return source if source.is_dir() else here / installed_name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
QUERY_DIR = _pack_dir("queries", "pythia_queries")
|
|
67
|
+
SKILLS_DIR = _pack_dir("skills", "pythia_skills")
|
|
68
|
+
|
|
69
|
+
# Bind contract: what each query file is allowed to use. tests/test_phase2.py
|
|
70
|
+
# fails on any drift in either direction — that is how queries/ stays reviewable
|
|
71
|
+
# by PR without a database.
|
|
72
|
+
QUERY_BINDS = {
|
|
73
|
+
"invalid-objects.sql": {"s"},
|
|
74
|
+
"compile-errors.sql": {"s", "n"},
|
|
75
|
+
"dependencies.sql": {"s", "n", "depth", "with_sys"},
|
|
76
|
+
"impact.sql": {"s", "n", "depth"},
|
|
77
|
+
"similar-candidates.sql": {"s"},
|
|
78
|
+
"plscope-usages.sql": {"s", "n"},
|
|
79
|
+
"plscope-statements.sql": {"s", "n"},
|
|
80
|
+
"plscope-enabled.sql": {"s"},
|
|
81
|
+
"source.sql": {"s", "n"},
|
|
82
|
+
"object-source.sql": {"s", "n", "t"},
|
|
83
|
+
"session-privileges.sql": set(),
|
|
84
|
+
"name-occupants.sql": {"s", "n"},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- pure helpers (covered by tests/test_phase1.py) --------------------------
|
|
89
|
+
|
|
90
|
+
def is_readonly_sql(stmt):
|
|
91
|
+
return bool(READONLY.match(stmt))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- terminal presentation ---------------------------------------------------
|
|
95
|
+
|
|
96
|
+
ANSI = {"red": "31", "green": "32", "yellow": "33", "cyan": "36",
|
|
97
|
+
"bold": "1", "dim": "2"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def color_enabled(stream=None, env=None):
|
|
101
|
+
"""ANSI color only for a human at a TTY. NO_COLOR (the standard) always
|
|
102
|
+
wins; FORCE_COLOR opts back in; pipes stay plain so agents parse exactly
|
|
103
|
+
what they saw."""
|
|
104
|
+
env = os.environ if env is None else env
|
|
105
|
+
if env.get("FORCE_COLOR"):
|
|
106
|
+
return True # explicit opt-in outranks the ambient opt-out below
|
|
107
|
+
if env.get("NO_COLOR"):
|
|
108
|
+
return False
|
|
109
|
+
stream = sys.stdout if stream is None else stream
|
|
110
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def paint(text, color, enabled):
|
|
114
|
+
if not enabled or not color:
|
|
115
|
+
return text
|
|
116
|
+
return f"\x1b[{ANSI[color]}m{text}\x1b[0m"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
BLOCK_LOGO = """\
|
|
120
|
+
██████╗ ██╗ ██╗████████╗██╗ ██╗██╗ █████╗
|
|
121
|
+
██╔══██╗╚██╗ ██╔╝╚══██╔══╝██║ ██║██║██╔══██╗
|
|
122
|
+
██████╔╝ ╚████╔╝ ██║ ███████║██║███████║
|
|
123
|
+
██╔═══╝ ╚██╔╝ ██║ ██╔══██║██║██╔══██║
|
|
124
|
+
██║ ██║ ██║ ██║ ██║██║██║ ██║
|
|
125
|
+
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
LOGO_STOPS = ((34, 211, 238), (167, 139, 250), (244, 114, 182)) # cyan → violet → pink
|
|
129
|
+
LOGO_256 = (51, 45, 39, 105, 141, 177) # same ramp, 256-color
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _logo_rgb(t):
|
|
133
|
+
"""Interpolate the gradient stops at t in [0, 1]."""
|
|
134
|
+
seg = t * (len(LOGO_STOPS) - 1)
|
|
135
|
+
i = min(int(seg), len(LOGO_STOPS) - 2)
|
|
136
|
+
f = seg - i
|
|
137
|
+
a, b = LOGO_STOPS[i], LOGO_STOPS[i + 1]
|
|
138
|
+
return tuple(round(a[k] + (b[k] - a[k]) * f) for k in range(3))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def banner(enabled, env=None):
|
|
142
|
+
"""A hello on `check`, TTY only — agents piping output never see it.
|
|
143
|
+
Solid blocks get a diagonal color gradient; the box-drawing outline stays
|
|
144
|
+
dim for depth. Truecolor when the terminal advertises it (COLORTERM, or
|
|
145
|
+
WT_SESSION — Windows Terminal supports 24-bit but never says so),
|
|
146
|
+
a 256-color ramp everywhere else."""
|
|
147
|
+
if not enabled:
|
|
148
|
+
return ""
|
|
149
|
+
env = os.environ if env is None else env
|
|
150
|
+
truecolor = (env.get("COLORTERM", "").lower() in ("truecolor", "24bit")
|
|
151
|
+
or bool(env.get("WT_SESSION")))
|
|
152
|
+
lines = BLOCK_LOGO.splitlines()
|
|
153
|
+
out = []
|
|
154
|
+
for y, ln in enumerate(lines):
|
|
155
|
+
width = max(len(ln), 1)
|
|
156
|
+
chunk = []
|
|
157
|
+
for x, ch in enumerate(ln):
|
|
158
|
+
if ch == " ":
|
|
159
|
+
chunk.append(ch)
|
|
160
|
+
elif ch == "█":
|
|
161
|
+
if truecolor:
|
|
162
|
+
r, g, b = _logo_rgb((x / width + y / len(lines)) / 2)
|
|
163
|
+
chunk.append(f"\x1b[38;2;{r};{g};{b}m{ch}\x1b[0m")
|
|
164
|
+
else:
|
|
165
|
+
chunk.append(f"\x1b[38;5;{LOGO_256[min(y, len(LOGO_256) - 1)]}m{ch}\x1b[0m")
|
|
166
|
+
else:
|
|
167
|
+
chunk.append(f"\x1b[2m{ch}\x1b[0m") # outline: dim, for depth
|
|
168
|
+
out.append("".join(chunk))
|
|
169
|
+
tag = paint("judgment for your agent's Oracle connection", "dim", enabled)
|
|
170
|
+
return "\n" + "\n".join(out) + f"\n{tag}\n\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def paint_diff_line(ln, enabled):
|
|
174
|
+
if ln.startswith(("+++", "---")):
|
|
175
|
+
return paint(ln, "dim", enabled)
|
|
176
|
+
if ln.startswith("+"):
|
|
177
|
+
return paint(ln, "green", enabled)
|
|
178
|
+
if ln.startswith("-"):
|
|
179
|
+
return paint(ln, "red", enabled)
|
|
180
|
+
if ln.startswith("@@"):
|
|
181
|
+
return paint(ln, "cyan", enabled)
|
|
182
|
+
return ln
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def invocation():
|
|
186
|
+
"""How to invoke this tool, exactly as the user actually ran it. Every
|
|
187
|
+
printed command must be paste-able: a bare `pythia ...` is
|
|
188
|
+
CommandNotFound for anyone running from source."""
|
|
189
|
+
main_spec = getattr(sys.modules.get("__main__"), "__spec__", None)
|
|
190
|
+
if getattr(main_spec, "name", "") == "pythia":
|
|
191
|
+
# `python -m pythia` — argv[0] is the module's site-packages path,
|
|
192
|
+
# which nobody typed; the -m form is the paste-able one
|
|
193
|
+
return f"{pathlib.Path(sys.executable).stem} -m pythia"
|
|
194
|
+
prog = sys.argv[0] or "pythia"
|
|
195
|
+
if prog.lower().endswith(".py"):
|
|
196
|
+
# the interpreter actually running us: "python3" on most Linux/macOS,
|
|
197
|
+
# "python" on Windows — a hardcoded "python" would not paste on Ubuntu
|
|
198
|
+
return f"{pathlib.Path(sys.executable).stem} {prog}"
|
|
199
|
+
return pathlib.Path(prog).name
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def forbid_write_flag(argv):
|
|
203
|
+
if "--write" in argv:
|
|
204
|
+
sys.exit(f"There is no --write flag. The write path is `{invocation()} "
|
|
205
|
+
"apply <file>` — snapshot, preview and verify included; "
|
|
206
|
+
"nothing else writes.")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_query(name):
|
|
210
|
+
"""Read a statement from queries/. All SQL lives there so it can be
|
|
211
|
+
reviewed, tested and contributed to without reading Python."""
|
|
212
|
+
path = QUERY_DIR / name
|
|
213
|
+
if not path.is_file():
|
|
214
|
+
sys.exit(f"Missing query file: {path}")
|
|
215
|
+
return path.read_text(encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def query_binds(sql):
|
|
219
|
+
"""Bind names a statement actually uses, ignoring comments and string
|
|
220
|
+
literals — a date format like 'hh24:mi:ss' is not two binds."""
|
|
221
|
+
sql = re.sub(r"--[^\n]*", " ", sql)
|
|
222
|
+
sql = re.sub(r"'(?:[^']|'')*'", " ", sql)
|
|
223
|
+
return set(re.findall(r"(?<![:\w]):([a-z_][a-z0-9_]*)", sql, re.I))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def find_config(cwd, env):
|
|
227
|
+
"""Return (config dict, project root) or (None, None).
|
|
228
|
+
|
|
229
|
+
PYTHIA_CONFIG points straight at a JSON file; otherwise walk upward from
|
|
230
|
+
cwd for .pythia/connections.json. The project root is the directory
|
|
231
|
+
holding .pythia/ — path-based connection inference is anchored to it.
|
|
232
|
+
"""
|
|
233
|
+
override = env.get("PYTHIA_CONFIG")
|
|
234
|
+
if override:
|
|
235
|
+
path = pathlib.Path(override)
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
sys.exit(f"PYTHIA_CONFIG points to a missing file: {path}")
|
|
238
|
+
root = path.parent.parent if path.parent.name == CONFIG_DIR else path.parent
|
|
239
|
+
return _load_config(path), root
|
|
240
|
+
for d in [cwd, *cwd.parents]:
|
|
241
|
+
path = d / CONFIG_DIR / CONFIG_NAME
|
|
242
|
+
if path.is_file():
|
|
243
|
+
return _load_config(path), d
|
|
244
|
+
return None, None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load_config(path):
|
|
248
|
+
if os.name == "posix":
|
|
249
|
+
mode = path.stat().st_mode & 0o777
|
|
250
|
+
if mode & 0o077:
|
|
251
|
+
print(f"-- warning: {path} is readable by other users "
|
|
252
|
+
f"(mode {mode:o}); chmod 600 recommended", file=sys.stderr)
|
|
253
|
+
try:
|
|
254
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
sys.exit(f"Cannot parse {path}: {e}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resolve_connection(cfg, explicit, env, cwd, root):
|
|
260
|
+
"""Pick a connection. Precedence: --conn, PYTHIA_CONNECTION, PYTHIA_* env
|
|
261
|
+
credentials, then the config file (single entry, the path segment directly
|
|
262
|
+
under the project root, or the entry named by the top-level "default").
|
|
263
|
+
Ambiguity is an error, never a guess."""
|
|
264
|
+
cfg = dict(cfg or {})
|
|
265
|
+
fallback = cfg.pop("default", None)
|
|
266
|
+
if fallback is not None and not isinstance(fallback, str):
|
|
267
|
+
example = next((k for k in cfg), "dev")
|
|
268
|
+
sys.exit(f'"default" must name a connection, for example '
|
|
269
|
+
f'"default": "{example}" — got {json.dumps(fallback)}.')
|
|
270
|
+
|
|
271
|
+
lookup = {}
|
|
272
|
+
for k, v in cfg.items():
|
|
273
|
+
if not isinstance(v, dict):
|
|
274
|
+
sys.exit(f"Connection {k!r} in {CONFIG_NAME} must be an object of "
|
|
275
|
+
f"settings, got {json.dumps(v)}.")
|
|
276
|
+
if k.upper() in lookup:
|
|
277
|
+
sys.exit(f"Connection names collide ignoring case in {CONFIG_NAME}: "
|
|
278
|
+
f"{lookup[k.upper()][0]!r} vs {k!r}")
|
|
279
|
+
lookup[k.upper()] = (k, v)
|
|
280
|
+
names = ", ".join(sorted(k for k, _ in lookup.values()))
|
|
281
|
+
|
|
282
|
+
if fallback and fallback.upper() not in lookup:
|
|
283
|
+
sys.exit(f'"default" names {fallback!r}, which is not a connection. '
|
|
284
|
+
f"Available: {names or 'none'}.")
|
|
285
|
+
|
|
286
|
+
wanted = explicit or env.get("PYTHIA_CONNECTION")
|
|
287
|
+
if wanted:
|
|
288
|
+
hit = lookup.get(wanted.upper())
|
|
289
|
+
if not hit:
|
|
290
|
+
sys.exit(f"Unknown connection {wanted!r}. Available: {names or 'none'}.")
|
|
291
|
+
return hit
|
|
292
|
+
|
|
293
|
+
if env.get("PYTHIA_USER") and env.get("PYTHIA_PASSWORD") and env.get("PYTHIA_DSN"):
|
|
294
|
+
c = {"user": env["PYTHIA_USER"], "password": env["PYTHIA_PASSWORD"],
|
|
295
|
+
"dsn": env["PYTHIA_DSN"]}
|
|
296
|
+
if env.get("PYTHIA_SCHEMA"):
|
|
297
|
+
c["schema"] = env["PYTHIA_SCHEMA"]
|
|
298
|
+
return "env", c
|
|
299
|
+
|
|
300
|
+
if not lookup:
|
|
301
|
+
sys.exit(f"No connection configured. Create {CONFIG_DIR}/{CONFIG_NAME} "
|
|
302
|
+
"(see examples/connections.example.json) or set "
|
|
303
|
+
"PYTHIA_USER / PYTHIA_PASSWORD / PYTHIA_DSN.")
|
|
304
|
+
|
|
305
|
+
if len(lookup) == 1:
|
|
306
|
+
return next(iter(lookup.values()))
|
|
307
|
+
|
|
308
|
+
if root is not None:
|
|
309
|
+
try:
|
|
310
|
+
seg = cwd.resolve().relative_to(pathlib.Path(root).resolve()).parts
|
|
311
|
+
except ValueError:
|
|
312
|
+
seg = ()
|
|
313
|
+
if seg:
|
|
314
|
+
hit = lookup.get(seg[0].upper())
|
|
315
|
+
if hit:
|
|
316
|
+
return hit
|
|
317
|
+
|
|
318
|
+
# Nothing in the path to go on. The top-level "default" is a choice the user
|
|
319
|
+
# wrote down rather than a guess, so it is safe to fall back to — and the
|
|
320
|
+
# connection actually used is always echoed on stderr.
|
|
321
|
+
if fallback:
|
|
322
|
+
return lookup[fallback.upper()]
|
|
323
|
+
|
|
324
|
+
misplaced = sorted(n for n, v in lookup.values() if "default" in v)
|
|
325
|
+
if misplaced:
|
|
326
|
+
sys.exit(f'Connection {misplaced[0]!r} has a "default" key inside it. '
|
|
327
|
+
f"The default belongs at the top level of {CONFIG_NAME}, "
|
|
328
|
+
f'alongside the connections: "default": "{misplaced[0]}"')
|
|
329
|
+
|
|
330
|
+
sys.exit(f"Cannot infer a connection from {cwd}. Available: {names}.\n"
|
|
331
|
+
"Use --conn NAME, set PYTHIA_CONNECTION, or name one at the top "
|
|
332
|
+
f'level of {CONFIG_NAME}: "default": "<name>"')
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def clip(rows, limit, offset=0):
|
|
336
|
+
"""Window a result; truncated=True whenever anything beyond the window exists."""
|
|
337
|
+
end = offset + limit if limit else len(rows)
|
|
338
|
+
return rows[offset:end], len(rows) > end
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def filter_units(rows, body, spec):
|
|
342
|
+
"""rows: (type, line, text). --body keeps *BODY units, --spec the rest."""
|
|
343
|
+
if body == spec:
|
|
344
|
+
return rows
|
|
345
|
+
return [r for r in rows if r[0].upper().endswith("BODY") == body]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def format_source(rows, raw):
|
|
349
|
+
"""Number source with Oracle's own ALL_SOURCE line values so they map 1:1
|
|
350
|
+
to compiler `line N` positions; numbering restarts per unit (spec/body)."""
|
|
351
|
+
if raw:
|
|
352
|
+
return "".join(str(t).rstrip("\n") + "\n" for _, _, t in rows)
|
|
353
|
+
multi = len({u for u, _, _ in rows}) > 1
|
|
354
|
+
out, seen = [], None
|
|
355
|
+
for unit, line, text in rows:
|
|
356
|
+
if multi and unit != seen:
|
|
357
|
+
if seen is not None:
|
|
358
|
+
out.append("\n")
|
|
359
|
+
out.append(f"-- {unit}\n")
|
|
360
|
+
seen = unit
|
|
361
|
+
s = str(text).rstrip("\n")
|
|
362
|
+
out.append(f"{line:>6} {s}\n")
|
|
363
|
+
return "".join(out)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def format_errors(rows):
|
|
367
|
+
"""rows: (name, type, sequence, line, position, attribute, text).
|
|
368
|
+
One header per object, then 'line:col SEVERITY message' per error."""
|
|
369
|
+
out, seen = [], None
|
|
370
|
+
for name, otype, _seq, line, pos, attr, text in rows:
|
|
371
|
+
if (name, otype) != seen:
|
|
372
|
+
out.append(f"{name} ({otype})\n")
|
|
373
|
+
seen = (name, otype)
|
|
374
|
+
out.append(f" {line}:{pos} {attr} {str(text).strip()}\n")
|
|
375
|
+
return "".join(out)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def render_tree(rows, root):
|
|
379
|
+
"""rows: (lvl, owner, name, type, ...) in hierarchical order; trailing
|
|
380
|
+
columns are ignored so deps and impact share one renderer. The same object
|
|
381
|
+
can appear on more than one path — that is the graph, not a bug."""
|
|
382
|
+
out = [f"{root}\n"]
|
|
383
|
+
for row in rows:
|
|
384
|
+
lvl, owner, name, otype = row[:4]
|
|
385
|
+
out.append(f"{' ' * int(lvl)}{owner}.{name} ({otype})\n")
|
|
386
|
+
return "".join(out)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def impact_summary(rows):
|
|
390
|
+
"""rows: (lvl, owner, name, type, status, dependency_type). Counts distinct
|
|
391
|
+
objects — one object reached by three paths is still one object that has to
|
|
392
|
+
recompile."""
|
|
393
|
+
seen = {}
|
|
394
|
+
for row in rows:
|
|
395
|
+
_lvl, owner, name, otype, status = row[:5]
|
|
396
|
+
seen[(owner, name, otype)] = status
|
|
397
|
+
valid = sum(1 for s in seen.values() if s == "VALID")
|
|
398
|
+
return f"-- impact: {len(seen)} dependent objects, {valid} currently VALID"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def rank_similar(target, candidates):
|
|
402
|
+
"""candidates: (object_name, object_type, status, last_ddl). A codebase's
|
|
403
|
+
naming convention lives in the underscore-separated tokens of its names, so
|
|
404
|
+
shared tokens are the cheapest honest signal of 'written the same way'.
|
|
405
|
+
Returns each match with the shared tokens appended, best first."""
|
|
406
|
+
target = str(target).upper()
|
|
407
|
+
want = {t for t in target.split("_") if t}
|
|
408
|
+
scored = []
|
|
409
|
+
for row in candidates:
|
|
410
|
+
name = str(row[0]).upper()
|
|
411
|
+
if name == target:
|
|
412
|
+
continue
|
|
413
|
+
shared = want & {t for t in name.split("_") if t}
|
|
414
|
+
if shared:
|
|
415
|
+
scored.append((len(shared), name, (*row, " ".join(sorted(shared)))))
|
|
416
|
+
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
417
|
+
return [row for _score, _name, row in scored]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def plscope_message(name, has_any_data):
|
|
421
|
+
"""What to say when an identifier lookup returns nothing. Never run the
|
|
422
|
+
ALTER: recompiling a shared schema is the team's call, not the tool's."""
|
|
423
|
+
if has_any_data:
|
|
424
|
+
return (f"No PL/Scope entry for {name!r}. Either it does not exist, or the "
|
|
425
|
+
"objects using it were compiled before PL/Scope was enabled.")
|
|
426
|
+
return ("PL/Scope has no data for this schema, so this question cannot be "
|
|
427
|
+
"answered exactly yet.\n"
|
|
428
|
+
"To enable it, and then recompile the objects you care about:\n"
|
|
429
|
+
" ALTER SESSION SET plscope_settings='IDENTIFIERS:ALL, STATEMENTS:ALL';\n"
|
|
430
|
+
" ALTER PROCEDURE <name> COMPILE;\n"
|
|
431
|
+
"Recompiling on a shared schema affects everyone using it — agree it with "
|
|
432
|
+
"the team first. pythia will not run these for you.\n"
|
|
433
|
+
f"Until then, the approximate answer is: {invocation()} grep \"<text>\"")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# --- write layer: pure decision functions (tests/test_phase3.py) -------------
|
|
437
|
+
|
|
438
|
+
GROUPS = ("plsql_source", "data_dml", "structural", "grants", "session")
|
|
439
|
+
|
|
440
|
+
PLSQL_SOURCE_RE = re.compile(
|
|
441
|
+
r"^create\s+(?:or\s+replace\s+)?(?:(?:no)?editionable\s+|(?:no)?force\s+)*"
|
|
442
|
+
r"(procedure|function|package\s+body|package|trigger|view|type\s+body|type)\b",
|
|
443
|
+
re.I | re.S)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def skip_leading_noise(sql):
|
|
447
|
+
"""Drop leading whitespace and comments so classification sees the first
|
|
448
|
+
keyword. Only leading ones: stripping comments globally would corrupt
|
|
449
|
+
string literals like '-- not a comment'."""
|
|
450
|
+
while True:
|
|
451
|
+
sql = sql.lstrip()
|
|
452
|
+
if sql.startswith("--"):
|
|
453
|
+
nl = sql.find("\n")
|
|
454
|
+
sql = "" if nl < 0 else sql[nl + 1:]
|
|
455
|
+
elif sql.startswith("/*"):
|
|
456
|
+
end = sql.find("*/")
|
|
457
|
+
if end < 0:
|
|
458
|
+
return ""
|
|
459
|
+
sql = sql[end + 2:]
|
|
460
|
+
else:
|
|
461
|
+
return sql
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def classify(sql):
|
|
465
|
+
"""Which policy group a statement belongs to, or "anonymous" for a bare
|
|
466
|
+
BEGIN/DECLARE block (it can EXECUTE IMMEDIATE anything, so giving it a
|
|
467
|
+
group would be self-deception), or None for anything unrecognized.
|
|
468
|
+
Unrecognized means refused: a classifier that guesses generously is a
|
|
469
|
+
classifier that lets deny be bypassed."""
|
|
470
|
+
s = skip_leading_noise(sql)
|
|
471
|
+
if re.match(r"^alter\s+session\b", s, re.I):
|
|
472
|
+
return "session" # before the generic ALTER below
|
|
473
|
+
if PLSQL_SOURCE_RE.match(s):
|
|
474
|
+
return "plsql_source"
|
|
475
|
+
if re.match(r"^(insert|update|delete|merge)\b", s, re.I):
|
|
476
|
+
return "data_dml"
|
|
477
|
+
if re.match(r"^(grant|revoke)\b", s, re.I):
|
|
478
|
+
return "grants"
|
|
479
|
+
if re.match(r"^(alter|drop|truncate|rename|create)\b", s, re.I):
|
|
480
|
+
return "structural"
|
|
481
|
+
if re.match(r"^(begin|declare)\b", s, re.I):
|
|
482
|
+
return "anonymous"
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def parse_object(sql):
|
|
487
|
+
"""(type, name, schema|None) from a plsql_source statement. The parsed
|
|
488
|
+
identity drives the snapshot — getting it wrong would snapshot the wrong
|
|
489
|
+
object and silently destroy the only undo, hence the strict match."""
|
|
490
|
+
s = skip_leading_noise(sql)
|
|
491
|
+
m = PLSQL_SOURCE_RE.match(s)
|
|
492
|
+
if not m:
|
|
493
|
+
sys.exit("Cannot parse the object type from this CREATE statement.")
|
|
494
|
+
otype = " ".join(m.group(1).upper().split())
|
|
495
|
+
rest = s[m.end():]
|
|
496
|
+
ident = r'(?:"([^"]+)"|([A-Za-z][\w$#]*))'
|
|
497
|
+
m2 = re.match(r"\s*" + ident + r"(?:\s*\.\s*" + ident + r")?", rest)
|
|
498
|
+
if not m2:
|
|
499
|
+
sys.exit(f"Cannot parse the {otype.lower()} name from this statement.")
|
|
500
|
+
q1, p1, q2, p2 = m2.groups()
|
|
501
|
+
first = q1 if q1 is not None else p1.upper()
|
|
502
|
+
if q2 is None and p2 is None:
|
|
503
|
+
return otype, first, None
|
|
504
|
+
second = q2 if q2 is not None else p2.upper()
|
|
505
|
+
return otype, second, first
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def prepare_statement(sql, group):
|
|
509
|
+
"""What actually gets executed. A trailing line holding only / is a
|
|
510
|
+
SQL*Plus directive, not SQL. The trailing ; belongs to a PL/SQL block but
|
|
511
|
+
must go for everything else — specified per group because getting it
|
|
512
|
+
backwards produces baffling compile errors. Content after the terminator
|
|
513
|
+
means two statements in one file: refused, one object per file."""
|
|
514
|
+
lines = sql.replace("\r\n", "\n").split("\n")
|
|
515
|
+
while lines and not lines[-1].strip():
|
|
516
|
+
lines.pop()
|
|
517
|
+
if lines and lines[-1].strip() == "/":
|
|
518
|
+
lines.pop()
|
|
519
|
+
if any(ln.strip() == "/" for ln in lines):
|
|
520
|
+
sys.exit("The file contains more than one statement (a / separator "
|
|
521
|
+
"remains mid-file). pythia apply takes exactly one statement "
|
|
522
|
+
"per file — split it.")
|
|
523
|
+
text = "\n".join(lines).rstrip()
|
|
524
|
+
if group != "plsql_source":
|
|
525
|
+
body = text.rstrip(";").rstrip()
|
|
526
|
+
if ";" in body:
|
|
527
|
+
sys.exit("The file contains more than one statement. pythia apply "
|
|
528
|
+
"takes exactly one statement per file — split it.")
|
|
529
|
+
return body
|
|
530
|
+
return text
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
POLICY_DEFAULTS = {"plsql_source": "confirm", "data_dml": "deny",
|
|
534
|
+
"structural": "deny", "grants": "deny", "session": "allow"}
|
|
535
|
+
|
|
536
|
+
ROLLBACK_TABLE = """\
|
|
537
|
+
Is rollback real? (this table also appears in README.md and plsql-apply)
|
|
538
|
+
plsql_source Yes - completely. Source is recoverable from ALL_SOURCE.
|
|
539
|
+
data_dml No. After commit only Flashback Query remains, within undo retention.
|
|
540
|
+
structural Almost never. DROP COLUMN is permanent; a dropped table may be in the Recycle Bin.
|
|
541
|
+
grants Yes, but by hand.
|
|
542
|
+
session Not needed."""
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def apply_token(object_type, name, file_text, db_source):
|
|
546
|
+
"""6 hex chars binding the write to what was previewed: file or database
|
|
547
|
+
changing since the preview yields a different token, so what gets applied
|
|
548
|
+
is exactly what was seen. A consistency check, not a secret — it is
|
|
549
|
+
compared against one recomputed value, so length would cost usability and
|
|
550
|
+
buy nothing."""
|
|
551
|
+
import hashlib
|
|
552
|
+
payload = "\n".join([object_type, name,
|
|
553
|
+
file_text.replace("\r\n", "\n"),
|
|
554
|
+
db_source.replace("\r\n", "\n")])
|
|
555
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:6]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def effective_policy(raw):
|
|
559
|
+
"""Merge policy.json over the defaults, remembering where each value came
|
|
560
|
+
from. Config is a trust boundary: unknown groups and values are refused
|
|
561
|
+
with the accepted spelling, not ignored."""
|
|
562
|
+
eff = {g: (v, "default") for g, v in POLICY_DEFAULTS.items()}
|
|
563
|
+
for k, v in (raw or {}).items():
|
|
564
|
+
if k not in POLICY_DEFAULTS:
|
|
565
|
+
sys.exit(f"Unknown policy group {k!r} in policy.json. "
|
|
566
|
+
f"Groups: {', '.join(POLICY_DEFAULTS)}.")
|
|
567
|
+
if v not in ("allow", "confirm", "deny"):
|
|
568
|
+
sys.exit(f"Policy {k!r} must be allow, confirm or deny — got {v!r}.")
|
|
569
|
+
eff[k] = (v, "policy.json")
|
|
570
|
+
return eff
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def policy_path(root):
|
|
574
|
+
return pathlib.Path(root) / CONFIG_DIR / "policy.json"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def load_policy(root):
|
|
578
|
+
path = policy_path(root)
|
|
579
|
+
if not path.is_file():
|
|
580
|
+
return effective_policy(None)
|
|
581
|
+
try:
|
|
582
|
+
return effective_policy(json.loads(path.read_text(encoding="utf-8")))
|
|
583
|
+
except ValueError as e:
|
|
584
|
+
sys.exit(f"Cannot parse {path}: {e}")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def journal_root(root):
|
|
588
|
+
return pathlib.Path(root) / CONFIG_DIR / "journal"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def render_restore(obj_type, name, before_text):
|
|
592
|
+
"""The statement that puts things back. For an object that did not exist,
|
|
593
|
+
undo means DROP — a genuinely different promise than restoring source, so
|
|
594
|
+
the caller records created=True and the report says it plainly."""
|
|
595
|
+
if before_text.strip():
|
|
596
|
+
return "CREATE OR REPLACE " + before_text.rstrip() + "\n"
|
|
597
|
+
return f"DROP {obj_type} {name}\n"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def write_journal_entry(root, obj_type, name, before, after, meta, now=None):
|
|
601
|
+
"""Snapshot on disk before anything touches the database. Nothing can turn
|
|
602
|
+
this off: DDL commits itself, so this directory is the only undo there is."""
|
|
603
|
+
import datetime
|
|
604
|
+
now = now or datetime.datetime.now()
|
|
605
|
+
eid = (now.strftime("%Y-%m-%dT%H-%M-%S")
|
|
606
|
+
+ f"_{name}_{obj_type.replace(' ', '-')}")
|
|
607
|
+
d = journal_root(root) / eid
|
|
608
|
+
n = 1
|
|
609
|
+
while d.exists(): # apply + restore can land in the same second;
|
|
610
|
+
n += 1 # overwriting the previous entry would destroy
|
|
611
|
+
d = journal_root(root) / f"{eid}-{n}" # the only undo there is
|
|
612
|
+
eid = d.name
|
|
613
|
+
d.mkdir(parents=True)
|
|
614
|
+
(d / "before.sql").write_text(before, encoding="utf-8")
|
|
615
|
+
(d / "after.sql").write_text(after, encoding="utf-8")
|
|
616
|
+
(d / "restore.sql").write_text(render_restore(obj_type, name, before),
|
|
617
|
+
encoding="utf-8")
|
|
618
|
+
full = {"object": name, "type": obj_type, "created": not before.strip(),
|
|
619
|
+
"entry": eid, **meta}
|
|
620
|
+
(d / "meta.json").write_text(json.dumps(full, indent=2, default=str) + "\n",
|
|
621
|
+
encoding="utf-8")
|
|
622
|
+
return eid
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def read_journal_entry(root, entry_id):
|
|
626
|
+
d = journal_root(root) / entry_id
|
|
627
|
+
if not d.is_dir():
|
|
628
|
+
available = ", ".join(list_journal_entries(root)[:5]) or "none"
|
|
629
|
+
sys.exit(f"No journal entry {entry_id!r}. Recent: {available}. "
|
|
630
|
+
f"Use: {invocation()} journal list")
|
|
631
|
+
return {"before": (d / "before.sql").read_text(encoding="utf-8"),
|
|
632
|
+
"after": (d / "after.sql").read_text(encoding="utf-8"),
|
|
633
|
+
"restore": (d / "restore.sql").read_text(encoding="utf-8"),
|
|
634
|
+
"meta": json.loads((d / "meta.json").read_text(encoding="utf-8"))}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def list_journal_entries(root):
|
|
638
|
+
d = journal_root(root)
|
|
639
|
+
if not d.is_dir():
|
|
640
|
+
return []
|
|
641
|
+
return sorted((p.name for p in d.iterdir() if p.is_dir()), reverse=True)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def newly_invalid(before_rows, after_rows):
|
|
645
|
+
"""(name, type) pairs INVALID now that were not before. The whole point of
|
|
646
|
+
step 5: the agent must not report success while this list is non-empty."""
|
|
647
|
+
return sorted(set(map(tuple, after_rows)) - set(map(tuple, before_rows)))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def render_diff(before, after):
|
|
651
|
+
import difflib
|
|
652
|
+
lines = list(difflib.unified_diff(before.splitlines(), after.splitlines(),
|
|
653
|
+
lineterm=""))
|
|
654
|
+
changed = sum(1 for ln in lines
|
|
655
|
+
if ln[:1] in "+-" and not ln.startswith(("+++", "---")))
|
|
656
|
+
return "\n".join(lines), changed
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def json_envelope(command, connection, schema, cols, rows, truncated, **extra):
|
|
660
|
+
payload = {"ok": True, "command": command, "connection": connection,
|
|
661
|
+
"schema": schema, "rows": [dict(zip(cols, r)) for r in rows],
|
|
662
|
+
"truncated": bool(truncated), **extra}
|
|
663
|
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# --- database access ---------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def connect_failure_message(exc, conn_name):
|
|
669
|
+
"""A failure to connect should say which entry failed and what to check —
|
|
670
|
+
a driver stack trace tells the reader nothing actionable."""
|
|
671
|
+
return (f"Could not connect using connection {conn_name!r}: {exc}\n"
|
|
672
|
+
"Check host/port/service_name, the credentials, and that the database "
|
|
673
|
+
"is reachable from here. Use --conn NAME to try a different entry.")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def open_pool(c):
|
|
677
|
+
import oracledb
|
|
678
|
+
if not c.get("user") or not c.get("password"):
|
|
679
|
+
sys.exit(f"Connection is missing 'user'/'password' — fill in {CONFIG_NAME}.")
|
|
680
|
+
if not (c.get("dsn") or c.get("host")):
|
|
681
|
+
sys.exit(f"Connection needs 'dsn' or 'host' — fill in {CONFIG_NAME}.")
|
|
682
|
+
dsn = c.get("dsn") or oracledb.makedsn(
|
|
683
|
+
c["host"], int(c.get("port", 1521)),
|
|
684
|
+
service_name=c.get("service_name") or None, sid=c.get("sid") or None)
|
|
685
|
+
# ponytail: a pool only helps within one invocation; cross-invocation reuse
|
|
686
|
+
# needs the MCP backend (persistent session) or a future resident mode.
|
|
687
|
+
return oracledb.create_pool(user=c["user"], password=c["password"], dsn=dsn,
|
|
688
|
+
min=1, max=2)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def session_should_be_readonly(command, action=""):
|
|
692
|
+
"""Read commands get SET TRANSACTION READ ONLY as a second line of
|
|
693
|
+
defence. The write path must not: DML under a read-only transaction dies
|
|
694
|
+
with ORA-01456, and its defences are the classifier, the policy gate, the
|
|
695
|
+
token and the snapshot — not a transaction attribute."""
|
|
696
|
+
if command == "apply":
|
|
697
|
+
return False
|
|
698
|
+
if command == "journal" and action == "restore":
|
|
699
|
+
return False
|
|
700
|
+
return True
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def acquire(pool, readonly=True):
|
|
704
|
+
conn = pool.acquire()
|
|
705
|
+
if readonly:
|
|
706
|
+
with conn.cursor() as cur: # defence 2: Oracle itself rejects
|
|
707
|
+
cur.execute("set transaction read only") # DML/DDL in this transaction.
|
|
708
|
+
return conn # NOT inherited by autonomous
|
|
709
|
+
# transactions inside called PL/SQL.
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def cell(v):
|
|
713
|
+
if v is None:
|
|
714
|
+
return ""
|
|
715
|
+
if hasattr(v, "read"): # LOB
|
|
716
|
+
v = v.read()
|
|
717
|
+
return str(v).replace("\t", " ")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def run_query(conn, sql, binds=None):
|
|
721
|
+
with conn.cursor() as cur:
|
|
722
|
+
cur.execute(sql, binds or {})
|
|
723
|
+
return [d[0] for d in cur.description], cur.fetchall()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def fetch_n(limit):
|
|
727
|
+
# bind one row beyond the limit so truncation is detected, never guessed
|
|
728
|
+
return limit + 1 if limit else INT_MAX
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --- output ------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
def emit_table(ns, cols, rows, truncated):
|
|
734
|
+
if ns.json:
|
|
735
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
|
|
736
|
+
return
|
|
737
|
+
print("\t".join(cols))
|
|
738
|
+
for r in rows:
|
|
739
|
+
print("\t".join(cell(v) for v in r))
|
|
740
|
+
if truncated:
|
|
741
|
+
print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
|
|
742
|
+
print(f"-- {len(rows)} rows", file=sys.stderr)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def emit_source(ns, rows, truncated, total):
|
|
746
|
+
if ns.json:
|
|
747
|
+
clean = [(u, ln, str(t).rstrip("\n")) for u, ln, t in rows]
|
|
748
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema,
|
|
749
|
+
("TYPE", "LINE", "TEXT"), clean, truncated, total_lines=total))
|
|
750
|
+
return
|
|
751
|
+
sys.stdout.write(format_source(rows, ns.raw))
|
|
752
|
+
if truncated:
|
|
753
|
+
print(f"-- truncated: showing {len(rows)} of {total} source lines "
|
|
754
|
+
f"(use --offset {ns.offset + len(rows)} to continue, or --max-lines 0 for all)")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# --- subcommands (SQL kept verbatim from the field-proven v1 tool) -----------
|
|
758
|
+
|
|
759
|
+
def cmd_check(conn, schema, ns):
|
|
760
|
+
en = getattr(ns, "color", False)
|
|
761
|
+
if en:
|
|
762
|
+
sys.stdout.write(banner(en))
|
|
763
|
+
cols, rows = run_query(conn, """
|
|
764
|
+
select user connected_as, :s owner_used,
|
|
765
|
+
sys_context('userenv','db_name') db,
|
|
766
|
+
(select count(*) from all_objects where owner = :s) objects,
|
|
767
|
+
(select count(*) from all_views where owner = :s) views,
|
|
768
|
+
(select count(*) from all_types where owner = :s) types,
|
|
769
|
+
(select count(*) from all_indexes where owner = :s) indexes,
|
|
770
|
+
(select count(*) from all_triggers where owner = :s) triggers
|
|
771
|
+
from dual""", {"s": schema})
|
|
772
|
+
emit_table(ns, cols, rows, False)
|
|
773
|
+
warn = privilege_warning(conn, schema, ns.conn_user)
|
|
774
|
+
if warn:
|
|
775
|
+
print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
|
|
776
|
+
file=sys.stderr)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def cmd_ls(conn, schema, ns):
|
|
780
|
+
cols, rows = run_query(conn, """
|
|
781
|
+
select object_name, object_type, status, to_char(last_ddl_time,'yyyy-mm-dd') last_ddl
|
|
782
|
+
from all_objects where owner = :s and object_name like upper(:p)
|
|
783
|
+
order by object_type, object_name fetch first :n rows only""",
|
|
784
|
+
{"s": schema, "p": ns.pattern, "n": fetch_n(ns.limit)})
|
|
785
|
+
rows, truncated = clip(rows, ns.limit)
|
|
786
|
+
emit_table(ns, cols, rows, truncated)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def cmd_src(conn, schema, ns):
|
|
790
|
+
_, rows = run_query(conn, load_query("source.sql"), {"s": schema, "n": ns.name})
|
|
791
|
+
rows = [(t, ln, cell(x)) for t, ln, x in rows]
|
|
792
|
+
rows = filter_units(rows, ns.body, ns.spec)
|
|
793
|
+
if not rows:
|
|
794
|
+
sys.exit(f"No source found for {ns.name!r} in schema {schema}.")
|
|
795
|
+
total = len(rows)
|
|
796
|
+
shown, truncated = clip(rows, ns.max_lines, ns.offset)
|
|
797
|
+
emit_source(ns, shown, truncated, total)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def cmd_args(conn, schema, ns):
|
|
801
|
+
cols, rows = run_query(conn, """
|
|
802
|
+
select position, argument_name, in_out, data_type,
|
|
803
|
+
type_name, type_subname, data_level, defaulted
|
|
804
|
+
from all_arguments
|
|
805
|
+
where owner = :s and object_name = upper(:n) and argument_name is not null
|
|
806
|
+
order by position""", {"s": schema, "n": ns.name})
|
|
807
|
+
emit_table(ns, cols, rows, False)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def cmd_ddl(conn, schema, ns):
|
|
811
|
+
with conn.cursor() as cur: # strip STORAGE/SEGMENT noise before it burns context
|
|
812
|
+
cur.execute("begin dbms_metadata.set_transform_param("
|
|
813
|
+
"dbms_metadata.session_transform,'STORAGE',false); "
|
|
814
|
+
"dbms_metadata.set_transform_param("
|
|
815
|
+
"dbms_metadata.session_transform,'SEGMENT_ATTRIBUTES',false); end;")
|
|
816
|
+
_, rows = run_query(conn, "select dbms_metadata.get_ddl(upper(:t), upper(:n), :s) from dual",
|
|
817
|
+
{"t": ns.type, "n": ns.name, "s": schema})
|
|
818
|
+
lines = cell(rows[0][0]).split("\n") if rows else []
|
|
819
|
+
shown, truncated = clip(lines, ns.max_lines, ns.offset)
|
|
820
|
+
if ns.json:
|
|
821
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, ("DDL",),
|
|
822
|
+
[("\n".join(shown),)], truncated, total_lines=len(lines)))
|
|
823
|
+
return
|
|
824
|
+
print("\n".join(shown))
|
|
825
|
+
if truncated:
|
|
826
|
+
print(f"-- truncated: showing {len(shown)} of {len(lines)} lines "
|
|
827
|
+
f"(use --offset {ns.offset + len(shown)} to continue, or --max-lines 0 for all)")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def cmd_cols(conn, schema, ns):
|
|
831
|
+
cols, rows = run_query(conn, """
|
|
832
|
+
select column_id, column_name, data_type, data_length, data_precision,
|
|
833
|
+
data_scale, nullable, char_used, data_default
|
|
834
|
+
from all_tab_columns where owner = :s and table_name = upper(:n)
|
|
835
|
+
order by column_id""", {"s": schema, "n": ns.name})
|
|
836
|
+
emit_table(ns, cols, rows, False)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def cmd_grep(conn, schema, ns):
|
|
840
|
+
cols, rows = run_query(conn, """
|
|
841
|
+
select name, type, line, trim(text) text from all_source
|
|
842
|
+
where owner = :s and upper(text) like upper('%' || :p || '%')
|
|
843
|
+
order by name, line fetch first :n rows only""",
|
|
844
|
+
{"s": schema, "p": ns.pattern, "n": fetch_n(ns.limit)})
|
|
845
|
+
rows, truncated = clip(rows, ns.limit)
|
|
846
|
+
emit_table(ns, cols, rows, truncated)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def cmd_sql(conn, schema, ns):
|
|
850
|
+
stmt = " ".join(ns.statement).strip().rstrip(";")
|
|
851
|
+
if not is_readonly_sql(stmt):
|
|
852
|
+
sys.exit("Only SELECT/WITH statements are allowed; pythia is read-only.")
|
|
853
|
+
cols, rows = run_query(conn, stmt)
|
|
854
|
+
rows, truncated = clip(rows, ns.limit)
|
|
855
|
+
if ns.raw and not ns.json:
|
|
856
|
+
for r in rows:
|
|
857
|
+
sys.stdout.write(cell(r[0]).rstrip("\n") + "\n")
|
|
858
|
+
if truncated:
|
|
859
|
+
print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
|
|
860
|
+
return
|
|
861
|
+
emit_table(ns, cols, rows, truncated)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def cmd_invalid(conn, schema, ns):
|
|
865
|
+
cols, rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
|
|
866
|
+
rows, truncated = clip(rows, ns.limit)
|
|
867
|
+
emit_table(ns, cols, rows, truncated)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_errors(conn, schema, ns):
|
|
871
|
+
cols, rows = run_query(conn, load_query("compile-errors.sql"),
|
|
872
|
+
{"s": schema, "n": ns.name})
|
|
873
|
+
rows, truncated = clip(rows, ns.limit)
|
|
874
|
+
if ns.json:
|
|
875
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
|
|
876
|
+
return
|
|
877
|
+
if not rows:
|
|
878
|
+
target = ns.name or f"schema {schema}"
|
|
879
|
+
print(f"-- no compilation errors for {target}")
|
|
880
|
+
return
|
|
881
|
+
sys.stdout.write(format_errors(rows))
|
|
882
|
+
if truncated:
|
|
883
|
+
print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def cmd_deps(conn, schema, ns):
|
|
887
|
+
cols, rows = run_query(conn, load_query("dependencies.sql"),
|
|
888
|
+
{"s": schema, "n": ns.name, "depth": ns.depth,
|
|
889
|
+
"with_sys": 1 if ns.with_sys else 0})
|
|
890
|
+
rows, truncated = clip(rows, ns.limit)
|
|
891
|
+
if ns.json:
|
|
892
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
|
|
893
|
+
return
|
|
894
|
+
if not rows:
|
|
895
|
+
print(f"-- {ns.name.upper()} depends on nothing (or does not exist in {schema})")
|
|
896
|
+
return
|
|
897
|
+
sys.stdout.write(render_tree(rows, f"{schema}.{ns.name.upper()}"))
|
|
898
|
+
if truncated:
|
|
899
|
+
print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def cmd_impact(conn, schema, ns):
|
|
903
|
+
cols, rows = run_query(conn, load_query("impact.sql"),
|
|
904
|
+
{"s": schema, "n": ns.name, "depth": ns.depth})
|
|
905
|
+
shown, truncated = clip(rows, ns.limit)
|
|
906
|
+
if ns.json:
|
|
907
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
|
|
908
|
+
summary=impact_summary(rows)))
|
|
909
|
+
return
|
|
910
|
+
if not rows:
|
|
911
|
+
print(f"-- nothing depends on {ns.name.upper()} "
|
|
912
|
+
f"(within {schema}, depth {ns.depth})")
|
|
913
|
+
return
|
|
914
|
+
sys.stdout.write(render_tree(shown, f"{schema}.{ns.name.upper()}"))
|
|
915
|
+
if truncated:
|
|
916
|
+
print(f"-- truncated at {len(shown)} rows (raise --limit, or --limit 0 for no cap)")
|
|
917
|
+
print(impact_summary(rows))
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def cmd_similar(conn, schema, ns):
|
|
921
|
+
cols, rows = run_query(conn, load_query("similar-candidates.sql"), {"s": schema})
|
|
922
|
+
ranked = rank_similar(ns.name, rows)
|
|
923
|
+
shown, truncated = clip(ranked, ns.limit)
|
|
924
|
+
if not shown and not ns.json:
|
|
925
|
+
print(f"-- nothing in {schema} shares a name token with {ns.name.upper()}")
|
|
926
|
+
return
|
|
927
|
+
emit_table(ns, [*cols, "MATCHED_TOKENS"], shown, truncated)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def cmd_plscope(conn, schema, ns):
|
|
931
|
+
cols, rows = run_query(conn, load_query("plscope-usages.sql"),
|
|
932
|
+
{"s": schema, "n": ns.name})
|
|
933
|
+
if not rows:
|
|
934
|
+
_, probe = run_query(conn, load_query("plscope-enabled.sql"), {"s": schema})
|
|
935
|
+
sys.exit(plscope_message(ns.name, bool(probe)))
|
|
936
|
+
stmt_cols, stmt_rows = (), []
|
|
937
|
+
if any(str(r[3]).upper() == "TABLE" for r in rows): # TYPE column
|
|
938
|
+
stmt_cols, stmt_rows = run_query(conn, load_query("plscope-statements.sql"),
|
|
939
|
+
{"s": schema, "n": ns.name})
|
|
940
|
+
shown, truncated = clip(rows, ns.limit)
|
|
941
|
+
if ns.json:
|
|
942
|
+
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
|
|
943
|
+
statements=[dict(zip(stmt_cols, r)) for r in stmt_rows]))
|
|
944
|
+
return
|
|
945
|
+
emit_table(ns, cols, shown, truncated)
|
|
946
|
+
if stmt_rows:
|
|
947
|
+
print("\n-- SQL statements touching this table")
|
|
948
|
+
emit_table(ns, stmt_cols, *clip(stmt_rows, ns.limit))
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def load_conventions(root):
|
|
952
|
+
"""Project house style from .pythia/conventions.json — the machine half of
|
|
953
|
+
the customization surface (the prose half is conventions.md, for agents).
|
|
954
|
+
Config is a trust boundary: unknown keys and broken regexes are refused
|
|
955
|
+
with the fix, not skipped."""
|
|
956
|
+
path = pathlib.Path(root) / CONFIG_DIR / "conventions.json"
|
|
957
|
+
if not path.is_file():
|
|
958
|
+
return None
|
|
959
|
+
try:
|
|
960
|
+
conv = json.loads(path.read_text(encoding="utf-8"))
|
|
961
|
+
except ValueError as e:
|
|
962
|
+
sys.exit(f"Cannot parse {path}: {e}")
|
|
963
|
+
unknown = set(conv) - {"naming"}
|
|
964
|
+
if unknown:
|
|
965
|
+
sys.exit(f"Unknown key {sorted(unknown)[0]!r} in {path} — "
|
|
966
|
+
"supported: \"naming\".")
|
|
967
|
+
for otype, pattern in conv.get("naming", {}).items():
|
|
968
|
+
try:
|
|
969
|
+
re.compile(pattern)
|
|
970
|
+
except re.error as e:
|
|
971
|
+
sys.exit(f"Bad regex for {otype!r} in {path}: {e}")
|
|
972
|
+
return conv
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def naming_violation(otype, name, conv):
|
|
976
|
+
"""The warning line for a name outside the project's pattern, or None.
|
|
977
|
+
Style warns; only policy blocks."""
|
|
978
|
+
pattern = ((conv or {}).get("naming") or {}).get(otype)
|
|
979
|
+
if pattern and not re.match(pattern, name):
|
|
980
|
+
return (f"naming: {name} does not match this project's {otype} "
|
|
981
|
+
f"pattern {pattern} — see .pythia/conventions.md")
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# Which main-namespace occupants may legally coexist with each object type.
|
|
986
|
+
# Spec and body pair up; triggers have a namespace of their own (None = nothing
|
|
987
|
+
# in the main namespace can block them).
|
|
988
|
+
NAMESPACE_COEXIST = {
|
|
989
|
+
"PROCEDURE": {"PROCEDURE"},
|
|
990
|
+
"FUNCTION": {"FUNCTION"},
|
|
991
|
+
"PACKAGE": {"PACKAGE", "PACKAGE BODY"},
|
|
992
|
+
"PACKAGE BODY": {"PACKAGE", "PACKAGE BODY"},
|
|
993
|
+
"TYPE": {"TYPE", "TYPE BODY"},
|
|
994
|
+
"TYPE BODY": {"TYPE", "TYPE BODY"},
|
|
995
|
+
"VIEW": {"VIEW"},
|
|
996
|
+
"TRIGGER": None,
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def name_conflicts(otype, occupants):
|
|
1001
|
+
"""Occupant types that CREATE OR REPLACE <otype> cannot replace — the
|
|
1002
|
+
ORA-00955 the preview must predict instead of discovering at apply time."""
|
|
1003
|
+
allowed = NAMESPACE_COEXIST.get(otype)
|
|
1004
|
+
if allowed is None:
|
|
1005
|
+
return []
|
|
1006
|
+
return sorted(set(occupants) - allowed)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def privilege_warning(conn, schema, conn_user):
|
|
1010
|
+
"""One line, only when true. The policy file is an application-side fence;
|
|
1011
|
+
Oracle grants are the only layer that cannot be walked around, so say when
|
|
1012
|
+
this session is running with more power than the task needs."""
|
|
1013
|
+
_, rows = run_query(conn, load_query("session-privileges.sql"))
|
|
1014
|
+
dangerous = [r[0] for r in rows]
|
|
1015
|
+
owner = bool(conn_user) and conn_user.upper() == schema
|
|
1016
|
+
if not owner and not dangerous:
|
|
1017
|
+
return None
|
|
1018
|
+
what = ("the schema owner" if owner else
|
|
1019
|
+
f"a user holding {', '.join(dangerous[:3])}"
|
|
1020
|
+
+ ("…" if len(dangerous) > 3 else ""))
|
|
1021
|
+
return (f"! Connected as {what}: this account can do far more than apply "
|
|
1022
|
+
"PL/SQL.\n A least-privilege account is safer — pythia policy "
|
|
1023
|
+
"explains what is at stake.")
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def run_apply(conn, schema, ns, file_text, origin=None):
|
|
1027
|
+
"""The six steps: SNAPSHOT, IMPACT, PREVIEW, APPLY, VERIFY, REPORT.
|
|
1028
|
+
Returns the exit code. Refusals raise SystemExit (exit 1)."""
|
|
1029
|
+
group = classify(file_text)
|
|
1030
|
+
if group == "anonymous":
|
|
1031
|
+
sys.exit("Anonymous PL/SQL blocks are refused: a BEGIN...END block can "
|
|
1032
|
+
"EXECUTE IMMEDIATE anything, so no policy group honestly fits.\n"
|
|
1033
|
+
"Wrap the logic in a named procedure and apply that instead.")
|
|
1034
|
+
if group is None:
|
|
1035
|
+
sys.exit("Cannot classify this statement, so it is refused rather than "
|
|
1036
|
+
"guessed at. pythia apply takes one CREATE OR REPLACE / DML / "
|
|
1037
|
+
"DDL / GRANT statement per file.")
|
|
1038
|
+
action = load_policy(ns.project_root)[group][0]
|
|
1039
|
+
if action == "deny":
|
|
1040
|
+
extra = ("no snapshot can undo it after commit"
|
|
1041
|
+
if group in ("data_dml", "structural", "grants")
|
|
1042
|
+
else "policy forbids it")
|
|
1043
|
+
sys.exit(f"Refused: {group} is set to deny — {extra}.\n"
|
|
1044
|
+
f"To allow it once you have weighed that: "
|
|
1045
|
+
f"{invocation()} policy set {group} confirm")
|
|
1046
|
+
stmt = prepare_statement(file_text, group)
|
|
1047
|
+
|
|
1048
|
+
if group == "session":
|
|
1049
|
+
# Nothing persistent changes; and the setting dies with this process's
|
|
1050
|
+
# connection, so say so instead of pretending it did something lasting.
|
|
1051
|
+
with conn.cursor() as cur:
|
|
1052
|
+
cur.execute(stmt)
|
|
1053
|
+
print("Session parameter set — note it lasts only for this "
|
|
1054
|
+
"invocation's connection, which is now over.")
|
|
1055
|
+
return 0
|
|
1056
|
+
|
|
1057
|
+
if group == "plsql_source":
|
|
1058
|
+
otype, name, file_schema = parse_object(file_text)
|
|
1059
|
+
if file_schema and file_schema.upper() != schema:
|
|
1060
|
+
sys.exit(f"The file names schema {file_schema.upper()!r} but this "
|
|
1061
|
+
f"connection targets {schema!r}. Refused: applying across "
|
|
1062
|
+
"schemas hides which database object actually changes.\n"
|
|
1063
|
+
f"Use --conn to select the {file_schema.upper()!r} connection.")
|
|
1064
|
+
_, occ_rows = run_query(conn, load_query("name-occupants.sql"),
|
|
1065
|
+
{"s": schema, "n": name})
|
|
1066
|
+
blockers = name_conflicts(otype, [r[0] for r in occ_rows])
|
|
1067
|
+
if blockers:
|
|
1068
|
+
sys.exit(f"{name} already exists as {', '.join(blockers)} in {schema} "
|
|
1069
|
+
"— CREATE OR REPLACE cannot change an object's type "
|
|
1070
|
+
"(ORA-00955 would follow).\n"
|
|
1071
|
+
"Changing the type means DROP first — structural, and the "
|
|
1072
|
+
"policy on that group applies:\n"
|
|
1073
|
+
f" {invocation()} policy set structural confirm "
|
|
1074
|
+
"(only if you accept losing the old object)")
|
|
1075
|
+
else:
|
|
1076
|
+
# confirm-mode DML/DDL/grants: no object identity, no snapshot — the
|
|
1077
|
+
# journal records the statement itself so at least *what ran* is kept.
|
|
1078
|
+
otype, name = group.upper(), "STATEMENT"
|
|
1079
|
+
|
|
1080
|
+
# 1. SNAPSHOT — before anything else, unconditionally.
|
|
1081
|
+
db_source = ""
|
|
1082
|
+
if group == "plsql_source":
|
|
1083
|
+
_, rows = run_query(conn, load_query("object-source.sql"),
|
|
1084
|
+
{"s": schema, "n": name, "t": otype})
|
|
1085
|
+
db_source = "".join(cell(r[0]) for r in rows)
|
|
1086
|
+
token = apply_token(otype, name, file_text, db_source)
|
|
1087
|
+
confirmed = bool(ns.yes) or ns.confirm == token
|
|
1088
|
+
if ns.confirm and ns.confirm != token:
|
|
1089
|
+
sys.exit("The confirmation token does not match: the file or the "
|
|
1090
|
+
"database object changed since that preview. Preview again:\n"
|
|
1091
|
+
f" {invocation()} apply {ns.file}")
|
|
1092
|
+
|
|
1093
|
+
_, inv_rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
|
|
1094
|
+
invalid_before = [(r[0], r[1]) for r in inv_rows]
|
|
1095
|
+
meta = {"schema": schema, "connection": ns.conn_name, "group": group,
|
|
1096
|
+
"token": token, "applied": False,
|
|
1097
|
+
"invalid_before": invalid_before, **(origin or {})}
|
|
1098
|
+
entry = write_journal_entry(ns.project_root, otype, name, db_source,
|
|
1099
|
+
file_text, meta)
|
|
1100
|
+
created = not db_source.strip()
|
|
1101
|
+
|
|
1102
|
+
# 2. IMPACT
|
|
1103
|
+
summary = ""
|
|
1104
|
+
if group == "plsql_source":
|
|
1105
|
+
_, dep_rows = run_query(conn, load_query("impact.sql"),
|
|
1106
|
+
{"s": schema, "n": name, "depth": ns.depth})
|
|
1107
|
+
summary = impact_summary(dep_rows)
|
|
1108
|
+
|
|
1109
|
+
# 3. PREVIEW — diff like against like: ALL_SOURCE never stores the
|
|
1110
|
+
# CREATE OR REPLACE header, so prepend it before comparing, or an
|
|
1111
|
+
# unchanged object would show a phantom two-line change forever.
|
|
1112
|
+
base = ("CREATE OR REPLACE " + db_source) if db_source.strip() else ""
|
|
1113
|
+
diff_text, changed = render_diff(base, stmt)
|
|
1114
|
+
warn = privilege_warning(conn, schema, ns.conn_user)
|
|
1115
|
+
style = (naming_violation(otype, name, load_conventions(ns.project_root))
|
|
1116
|
+
if group == "plsql_source" else None)
|
|
1117
|
+
if ns.json:
|
|
1118
|
+
print(json.dumps({"ok": True, "object": name, "type": otype,
|
|
1119
|
+
"created": created, "changed_lines": changed,
|
|
1120
|
+
"summary": summary, "warning": warn,
|
|
1121
|
+
"naming_warning": style, "token": token,
|
|
1122
|
+
"journal": entry, "will_apply": confirmed}))
|
|
1123
|
+
else:
|
|
1124
|
+
en = getattr(ns, "color", False)
|
|
1125
|
+
if created:
|
|
1126
|
+
head = "new object"
|
|
1127
|
+
elif changed == 0:
|
|
1128
|
+
head = "no source change (recompile)"
|
|
1129
|
+
else:
|
|
1130
|
+
head = f"{changed} lines changed"
|
|
1131
|
+
print(f"\n {paint(f'{name} ({otype})', 'bold', en)} in {schema} — {head}")
|
|
1132
|
+
if summary:
|
|
1133
|
+
print(f" {summary.lstrip('- ')}")
|
|
1134
|
+
if warn:
|
|
1135
|
+
print(f"\n {paint(warn, 'yellow', en)}")
|
|
1136
|
+
if style:
|
|
1137
|
+
print(f"\n {paint('! ' + style, 'yellow', en)}")
|
|
1138
|
+
if diff_text:
|
|
1139
|
+
print()
|
|
1140
|
+
for ln in diff_text.splitlines():
|
|
1141
|
+
print(f" {paint_diff_line(ln, en)}")
|
|
1142
|
+
print(paint(f"\n Snapshot saved: {journal_root(ns.project_root) / entry}",
|
|
1143
|
+
"dim", en))
|
|
1144
|
+
if not confirmed:
|
|
1145
|
+
print(f"\n To apply:\n "
|
|
1146
|
+
+ paint(f"{invocation()} apply {ns.file} --confirm {token}",
|
|
1147
|
+
"cyan", en))
|
|
1148
|
+
if not confirmed:
|
|
1149
|
+
return 0
|
|
1150
|
+
|
|
1151
|
+
# 4. APPLY
|
|
1152
|
+
with conn.cursor() as cur:
|
|
1153
|
+
cur.execute(stmt)
|
|
1154
|
+
|
|
1155
|
+
# 5. VERIFY
|
|
1156
|
+
err_rows = []
|
|
1157
|
+
invalid_after = invalid_before
|
|
1158
|
+
if group == "plsql_source":
|
|
1159
|
+
_, err_rows = run_query(conn, load_query("compile-errors.sql"),
|
|
1160
|
+
{"s": schema, "n": name})
|
|
1161
|
+
_, inv_rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
|
|
1162
|
+
invalid_after = [(r[0], r[1]) for r in inv_rows]
|
|
1163
|
+
broke = newly_invalid(invalid_before, invalid_after)
|
|
1164
|
+
meta.update(applied=True, invalid_after=invalid_after, newly_invalid=broke,
|
|
1165
|
+
compile_errors=[list(r) for r in err_rows])
|
|
1166
|
+
# update the SAME entry in place — a second write_journal_entry would race
|
|
1167
|
+
# the timestamp and either collide or split one apply across two entries
|
|
1168
|
+
(journal_root(ns.project_root) / entry / "meta.json").write_text(
|
|
1169
|
+
json.dumps({"object": name, "type": otype, "created": created,
|
|
1170
|
+
"entry": entry, **meta}, indent=2, default=str) + "\n",
|
|
1171
|
+
encoding="utf-8")
|
|
1172
|
+
|
|
1173
|
+
# 6. REPORT
|
|
1174
|
+
own_errors = [r for r in err_rows if str(r[0]).upper() == name.upper()]
|
|
1175
|
+
ok = not own_errors and not broke
|
|
1176
|
+
if ns.json:
|
|
1177
|
+
print(json.dumps({"ok": ok, "applied": True, "object": name,
|
|
1178
|
+
"type": otype, "errors": [list(r) for r in own_errors],
|
|
1179
|
+
"newly_invalid": [list(x) for x in broke],
|
|
1180
|
+
"restore": f"{invocation()} journal restore {entry}",
|
|
1181
|
+
"exit": 0 if ok else 3}))
|
|
1182
|
+
else:
|
|
1183
|
+
en = getattr(ns, "color", False)
|
|
1184
|
+
if ok:
|
|
1185
|
+
print(paint(f"\n Applied {name} ({otype}).", "green", en))
|
|
1186
|
+
print(paint(" Compiled clean. No new INVALID objects.", "green", en))
|
|
1187
|
+
else:
|
|
1188
|
+
if own_errors:
|
|
1189
|
+
print(paint(f"\n Applied {name} ({otype}) — but it did not "
|
|
1190
|
+
"compile cleanly:", "red", en))
|
|
1191
|
+
for r in own_errors:
|
|
1192
|
+
print(paint(f" {r[3]}:{r[4]} {r[5]} {str(r[6]).strip()}",
|
|
1193
|
+
"red", en))
|
|
1194
|
+
else:
|
|
1195
|
+
print(paint(f"\n Applied {name} ({otype}) — it compiled, but "
|
|
1196
|
+
"broke other objects:", "red", en))
|
|
1197
|
+
if broke:
|
|
1198
|
+
print(f" {len(broke)} objects were VALID before and are INVALID now:")
|
|
1199
|
+
for n2, t2 in broke:
|
|
1200
|
+
print(paint(f" {n2} ({t2})", "red", en))
|
|
1201
|
+
undo = "dropping it (it did not exist before)" if created else None
|
|
1202
|
+
print(f"\n To undo{' — note: undo means ' + undo if undo else ''}:")
|
|
1203
|
+
print(" " + paint(f"{invocation()} journal restore {entry}", "cyan", en))
|
|
1204
|
+
return 0 if ok else 3
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def cmd_apply(conn, schema, ns):
|
|
1208
|
+
path = pathlib.Path(ns.file)
|
|
1209
|
+
if not path.is_file():
|
|
1210
|
+
sys.exit(f"No such file: {path}")
|
|
1211
|
+
code = run_apply(conn, schema, ns, path.read_text(encoding="utf-8"))
|
|
1212
|
+
if code:
|
|
1213
|
+
sys.exit(code)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def run_restore(conn, schema, ns):
|
|
1217
|
+
"""Restore is itself a write: feed the saved statement back through the
|
|
1218
|
+
same six steps. There is no second write path and no silent restore."""
|
|
1219
|
+
e = read_journal_entry(ns.project_root, ns.id)
|
|
1220
|
+
print(f"Restoring from {ns.id} — this is itself a write and goes through "
|
|
1221
|
+
"the full six steps.", file=sys.stderr)
|
|
1222
|
+
return run_apply(conn, schema, ns, e["restore"], origin={"restored_from": ns.id})
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def cmd_policy(conn, schema, ns):
|
|
1226
|
+
if ns.action == "set" and (not ns.group or not ns.value):
|
|
1227
|
+
sys.exit(f"Usage: {invocation()} policy set <group> <value>\n"
|
|
1228
|
+
f"Groups: {', '.join(sorted(POLICY_DEFAULTS))}; "
|
|
1229
|
+
"values: allow, confirm, deny.")
|
|
1230
|
+
if ns.action == "set":
|
|
1231
|
+
eff = load_policy(ns.project_root)
|
|
1232
|
+
eff[ns.group] = (ns.value, "policy.json") # validated by argparse choices
|
|
1233
|
+
path = policy_path(ns.project_root)
|
|
1234
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1235
|
+
path.write_text(json.dumps({g: v for g, (v, _) in eff.items()}, indent=2)
|
|
1236
|
+
+ "\n", encoding="utf-8")
|
|
1237
|
+
print(f"Wrote {path}")
|
|
1238
|
+
eff = load_policy(ns.project_root)
|
|
1239
|
+
if ns.json:
|
|
1240
|
+
print(json.dumps({g: {"value": v, "source": src}
|
|
1241
|
+
for g, (v, src) in eff.items()}))
|
|
1242
|
+
return
|
|
1243
|
+
print("Effective write policy:")
|
|
1244
|
+
for g, (v, src) in eff.items():
|
|
1245
|
+
print(f" {g:<13} {v:<8} ({src})")
|
|
1246
|
+
print()
|
|
1247
|
+
print(ROLLBACK_TABLE)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def cmd_journal(conn, schema, ns):
|
|
1251
|
+
root = ns.project_root
|
|
1252
|
+
if ns.action == "list":
|
|
1253
|
+
ids = list_journal_entries(root)
|
|
1254
|
+
if ns.json:
|
|
1255
|
+
print(json.dumps(ids))
|
|
1256
|
+
return
|
|
1257
|
+
if not ids:
|
|
1258
|
+
print(f"-- journal is empty ({journal_root(root)})")
|
|
1259
|
+
return
|
|
1260
|
+
for eid in ids:
|
|
1261
|
+
meta = read_journal_entry(root, eid)["meta"]
|
|
1262
|
+
state = "applied" if meta.get("applied") else "preview"
|
|
1263
|
+
print(f"{eid} [{state}]")
|
|
1264
|
+
return
|
|
1265
|
+
if not ns.id:
|
|
1266
|
+
sys.exit(f"Usage: {invocation()} journal "
|
|
1267
|
+
"{list | show <id> | diff <id> | export <id> | restore <id>}")
|
|
1268
|
+
e = read_journal_entry(root, ns.id)
|
|
1269
|
+
if ns.action == "show":
|
|
1270
|
+
print(json.dumps(e["meta"], indent=2))
|
|
1271
|
+
elif ns.action == "diff":
|
|
1272
|
+
text, changed = render_diff(e["before"], e["after"])
|
|
1273
|
+
print(text or "-- no difference")
|
|
1274
|
+
print(f"-- {changed} lines changed", file=sys.stderr)
|
|
1275
|
+
elif ns.action == "export":
|
|
1276
|
+
out = pathlib.Path(f"{ns.id}_{ns.what}.sql")
|
|
1277
|
+
out.write_text(e[ns.what], encoding="utf-8")
|
|
1278
|
+
print(f"Wrote {out}")
|
|
1279
|
+
else:
|
|
1280
|
+
sys.exit("unreachable: restore is dispatched with a connection in main")
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def cmd_conventions(conn, schema, ns):
|
|
1284
|
+
conv = load_conventions(ns.project_root)
|
|
1285
|
+
if ns.json:
|
|
1286
|
+
print(json.dumps(conv or {}))
|
|
1287
|
+
return
|
|
1288
|
+
if not conv:
|
|
1289
|
+
print("No project conventions configured.")
|
|
1290
|
+
print(f"Create {pathlib.Path(ns.project_root) / CONFIG_DIR / 'conventions.json'} "
|
|
1291
|
+
"(see examples/conventions.example.json) and apply previews will "
|
|
1292
|
+
"warn when a new object's name drifts from your patterns.\n"
|
|
1293
|
+
"Put the prose rules in conventions.md next to it for your agents.")
|
|
1294
|
+
return
|
|
1295
|
+
print("Naming patterns — apply previews warn when a name drifts:")
|
|
1296
|
+
for otype, pattern in conv.get("naming", {}).items():
|
|
1297
|
+
print(f" {otype:<13} {pattern}")
|
|
1298
|
+
md = pathlib.Path(ns.project_root) / CONFIG_DIR / "conventions.md"
|
|
1299
|
+
if md.is_file():
|
|
1300
|
+
print(f"\nProse rules for agents: {md}")
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
DEFAULT_SKILLS_SOURCE = "thaildhe172591/pythia"
|
|
1304
|
+
|
|
1305
|
+
# Kept byte-identical to examples/connections.example.json —
|
|
1306
|
+
# tests/test_install.py fails on any drift.
|
|
1307
|
+
CONNECTIONS_TEMPLATE = """{
|
|
1308
|
+
"default": "dev",
|
|
1309
|
+
|
|
1310
|
+
"dev": {
|
|
1311
|
+
"host": "",
|
|
1312
|
+
"port": 1521,
|
|
1313
|
+
"service_name": "",
|
|
1314
|
+
"sid": "",
|
|
1315
|
+
"dsn": "",
|
|
1316
|
+
"user": "",
|
|
1317
|
+
"password": "",
|
|
1318
|
+
"schema": ""
|
|
1319
|
+
},
|
|
1320
|
+
"staging": {
|
|
1321
|
+
"host": "",
|
|
1322
|
+
"port": 1521,
|
|
1323
|
+
"service_name": "",
|
|
1324
|
+
"sid": "",
|
|
1325
|
+
"dsn": "",
|
|
1326
|
+
"user": "",
|
|
1327
|
+
"password": "",
|
|
1328
|
+
"schema": ""
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
"""
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def scaffold_config(root):
|
|
1335
|
+
"""Create .pythia/connections.json from the template. An existing file is
|
|
1336
|
+
never touched — it may hold real credentials."""
|
|
1337
|
+
path = pathlib.Path(root) / CONFIG_DIR / CONFIG_NAME
|
|
1338
|
+
if path.is_file():
|
|
1339
|
+
return path, False
|
|
1340
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1341
|
+
path.write_text(CONNECTIONS_TEMPLATE, encoding="utf-8")
|
|
1342
|
+
return path, True
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def run_skills_add(source, interactive=False):
|
|
1346
|
+
"""Install the skill pack via the skills CLI. Returns its exit code, or
|
|
1347
|
+
None when npx is absent — the caller then copies the bundled pack, so a
|
|
1348
|
+
machine with only Python still gets the whole kit. At a TTY the CLI's own
|
|
1349
|
+
interactive agent picker is left on; piped/CI runs get -y."""
|
|
1350
|
+
import shutil
|
|
1351
|
+
import subprocess
|
|
1352
|
+
npx = shutil.which("npx")
|
|
1353
|
+
if npx is None:
|
|
1354
|
+
return None
|
|
1355
|
+
cmd = [npx, "skills", "add", source]
|
|
1356
|
+
if not interactive:
|
|
1357
|
+
cmd.append("-y")
|
|
1358
|
+
return subprocess.run(cmd).returncode
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def copy_bundled_skills(root):
|
|
1362
|
+
"""No-Node fallback: copy the wheel-bundled pack into the two
|
|
1363
|
+
conventional project layouts — Claude Code and the universal .agents one
|
|
1364
|
+
(Codex, Cursor, Copilot, Gemini CLI and friends all read it)."""
|
|
1365
|
+
import shutil
|
|
1366
|
+
targets = []
|
|
1367
|
+
for rel in (".claude/skills", ".agents/skills"):
|
|
1368
|
+
dest_root = pathlib.Path(root) / rel
|
|
1369
|
+
for pack in sorted(SKILLS_DIR.iterdir()):
|
|
1370
|
+
if not (pack / "SKILL.md").is_file():
|
|
1371
|
+
continue
|
|
1372
|
+
shutil.copytree(pack, dest_root / pack.name, dirs_exist_ok=True)
|
|
1373
|
+
targets.append(dest_root)
|
|
1374
|
+
return targets
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def cmd_install(conn, schema, ns):
|
|
1378
|
+
en = getattr(ns, "color", False)
|
|
1379
|
+
if en:
|
|
1380
|
+
sys.stdout.write(banner(en))
|
|
1381
|
+
path, created = scaffold_config(ns.project_root)
|
|
1382
|
+
print(f"{'Created' if created else 'Kept existing'} {path}")
|
|
1383
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
1384
|
+
code = run_skills_add(ns.source, interactive)
|
|
1385
|
+
if code is None:
|
|
1386
|
+
for t in copy_bundled_skills(ns.project_root):
|
|
1387
|
+
print(f"Copied the bundled skill pack into {t}")
|
|
1388
|
+
print("(npx not found — with Node.js, `npx skills add` reaches 77 "
|
|
1389
|
+
"agents with symlinked updates.)")
|
|
1390
|
+
elif code:
|
|
1391
|
+
sys.exit(code)
|
|
1392
|
+
print(f"\nNext: fill in {path}")
|
|
1393
|
+
print(f"Then: {invocation()} check")
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
|
|
1397
|
+
"ddl": cmd_ddl, "cols": cmd_cols, "grep": cmd_grep, "sql": cmd_sql,
|
|
1398
|
+
"invalid": cmd_invalid, "errors": cmd_errors, "deps": cmd_deps,
|
|
1399
|
+
"impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
|
|
1400
|
+
"policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
|
|
1401
|
+
"conventions": cmd_conventions, "install": cmd_install}
|
|
1402
|
+
|
|
1403
|
+
NO_DB_COMMANDS = {"policy", "journal", "conventions", "install"}
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
# --- CLI ---------------------------------------------------------------------
|
|
1407
|
+
|
|
1408
|
+
def build_parser():
|
|
1409
|
+
def common():
|
|
1410
|
+
"""A fresh set of shared options per subcommand. argparse's `parents`
|
|
1411
|
+
shares the very same action objects, so one subcommand's set_defaults
|
|
1412
|
+
would otherwise rewrite every other subcommand's default."""
|
|
1413
|
+
c = argparse.ArgumentParser(add_help=False)
|
|
1414
|
+
c.add_argument("--conn", help="connection name from connections.json")
|
|
1415
|
+
c.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1416
|
+
c.add_argument("--limit", type=int, default=200,
|
|
1417
|
+
help="max rows for list output, 0 = no cap (default 200)")
|
|
1418
|
+
c.add_argument("--max-lines", type=int, default=2000, dest="max_lines",
|
|
1419
|
+
help="max source/DDL lines, 0 = no cap (default 2000)")
|
|
1420
|
+
c.add_argument("--offset", type=int, default=0,
|
|
1421
|
+
help="skip N lines/rows first (continue truncated output)")
|
|
1422
|
+
c.add_argument("--raw", action="store_true",
|
|
1423
|
+
help="plain text, no line numbers or unit headers")
|
|
1424
|
+
return c
|
|
1425
|
+
|
|
1426
|
+
p = argparse.ArgumentParser(
|
|
1427
|
+
prog="pythia", description=__doc__,
|
|
1428
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1429
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1430
|
+
sub.add_parser("check", parents=[common()],
|
|
1431
|
+
help="connectivity + object counts for the schema")
|
|
1432
|
+
s = sub.add_parser("ls", parents=[common()], help="find objects by name pattern")
|
|
1433
|
+
s.add_argument("pattern", help="LIKE pattern, e.g. \"PKG_%%\"")
|
|
1434
|
+
s = sub.add_parser("src", parents=[common()],
|
|
1435
|
+
help="PL/SQL source with Oracle line numbers")
|
|
1436
|
+
s.add_argument("name")
|
|
1437
|
+
s.add_argument("--body", action="store_true", help="only *BODY units")
|
|
1438
|
+
s.add_argument("--spec", action="store_true", help="only spec units")
|
|
1439
|
+
s = sub.add_parser("args", parents=[common()], help="procedure/function signature")
|
|
1440
|
+
s.add_argument("name")
|
|
1441
|
+
s = sub.add_parser("ddl", parents=[common()], help="DDL via DBMS_METADATA")
|
|
1442
|
+
s.add_argument("type", help="e.g. TABLE, INDEX, VIEW, PACKAGE_BODY")
|
|
1443
|
+
s.add_argument("name")
|
|
1444
|
+
s = sub.add_parser("cols", parents=[common()], help="columns and data types")
|
|
1445
|
+
s.add_argument("name")
|
|
1446
|
+
s = sub.add_parser("grep", parents=[common()], help="search all PL/SQL source")
|
|
1447
|
+
s.add_argument("pattern")
|
|
1448
|
+
s = sub.add_parser("sql", parents=[common()], help="free query (SELECT/WITH only)")
|
|
1449
|
+
s.add_argument("statement", nargs="+")
|
|
1450
|
+
sub.add_parser("invalid", parents=[common()],
|
|
1451
|
+
help="every INVALID object in the schema")
|
|
1452
|
+
s = sub.add_parser("errors", parents=[common()],
|
|
1453
|
+
help="compilation errors with line and column")
|
|
1454
|
+
s.add_argument("name", nargs="?", default=None,
|
|
1455
|
+
help="object name; omit for every object in the schema")
|
|
1456
|
+
s = sub.add_parser("deps", parents=[common()],
|
|
1457
|
+
help="what an object depends on")
|
|
1458
|
+
s.add_argument("name")
|
|
1459
|
+
s.add_argument("--depth", type=int, default=3,
|
|
1460
|
+
help="levels to walk (default 3)")
|
|
1461
|
+
s.add_argument("--with-sys", action="store_true", dest="with_sys",
|
|
1462
|
+
help="include SYS/PUBLIC built-ins, hidden by default")
|
|
1463
|
+
s = sub.add_parser("impact", parents=[common()],
|
|
1464
|
+
help="what depends on an object — run this before changing it")
|
|
1465
|
+
s.add_argument("name")
|
|
1466
|
+
s.add_argument("--depth", type=int, default=3,
|
|
1467
|
+
help="levels to walk (default 3)")
|
|
1468
|
+
s = sub.add_parser("similar", parents=[common()],
|
|
1469
|
+
help="programs named like this one — copy their conventions")
|
|
1470
|
+
s.add_argument("name")
|
|
1471
|
+
s.set_defaults(limit=20)
|
|
1472
|
+
s = sub.add_parser("plscope", parents=[common()],
|
|
1473
|
+
help="exact identifier usages from PL/Scope")
|
|
1474
|
+
s.add_argument("name")
|
|
1475
|
+
s = sub.add_parser("policy", parents=[common()],
|
|
1476
|
+
help="show or change the write policy")
|
|
1477
|
+
s.add_argument("action", nargs="?", choices=["show", "set"], default="show")
|
|
1478
|
+
s.add_argument("group", nargs="?", choices=sorted(POLICY_DEFAULTS))
|
|
1479
|
+
s.add_argument("value", nargs="?", choices=["allow", "confirm", "deny"])
|
|
1480
|
+
s = sub.add_parser("journal", parents=[common()],
|
|
1481
|
+
help="list, inspect, export and restore write snapshots")
|
|
1482
|
+
s.add_argument("action", nargs="?",
|
|
1483
|
+
choices=["list", "show", "diff", "export", "restore"],
|
|
1484
|
+
default="list")
|
|
1485
|
+
s.add_argument("id", nargs="?")
|
|
1486
|
+
s.add_argument("--what", choices=["after", "before", "restore"],
|
|
1487
|
+
default="after", help="which file export writes (default after)")
|
|
1488
|
+
s.add_argument("--confirm", metavar="TOKEN")
|
|
1489
|
+
s.add_argument("--yes", action="store_true")
|
|
1490
|
+
s.add_argument("--depth", type=int, default=3)
|
|
1491
|
+
s = sub.add_parser("apply", parents=[common()],
|
|
1492
|
+
help="preview and apply one statement with snapshot and verify")
|
|
1493
|
+
s.add_argument("file", help="file containing exactly one statement")
|
|
1494
|
+
s.add_argument("--confirm", metavar="TOKEN",
|
|
1495
|
+
help="token printed by the preview; applies only if nothing changed since")
|
|
1496
|
+
s.add_argument("--yes", action="store_true",
|
|
1497
|
+
help="apply without stopping; the full preview still prints and journals")
|
|
1498
|
+
s.add_argument("--depth", type=int, default=3,
|
|
1499
|
+
help="impact depth for the preview (default 3)")
|
|
1500
|
+
sub.add_parser("conventions", parents=[common()],
|
|
1501
|
+
help="show the project's house-style naming patterns")
|
|
1502
|
+
s = sub.add_parser("install", parents=[common()],
|
|
1503
|
+
help="install the skill pack and scaffold .pythia/ config")
|
|
1504
|
+
s.add_argument("--source", default=DEFAULT_SKILLS_SOURCE,
|
|
1505
|
+
help="skills repo for `npx skills add` "
|
|
1506
|
+
f"(default {DEFAULT_SKILLS_SOURCE})")
|
|
1507
|
+
return p
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def main(argv=None):
|
|
1511
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
1512
|
+
forbid_write_flag(argv)
|
|
1513
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1514
|
+
try:
|
|
1515
|
+
stream.reconfigure(encoding="utf-8")
|
|
1516
|
+
except AttributeError:
|
|
1517
|
+
pass
|
|
1518
|
+
ns = build_parser().parse_args(argv)
|
|
1519
|
+
ns.color = color_enabled() and not ns.json
|
|
1520
|
+
if ns.color and os.name == "nt":
|
|
1521
|
+
# Constant empty string, never user input — safe from injection. This
|
|
1522
|
+
# no-op shell call is the stdlib idiom that flips on ANSI (VT) escape
|
|
1523
|
+
# processing in legacy Windows consoles; Windows Terminal needs nothing.
|
|
1524
|
+
os.system("")
|
|
1525
|
+
cwd = pathlib.Path.cwd()
|
|
1526
|
+
cfg, root = find_config(cwd, os.environ)
|
|
1527
|
+
ns.project_root = root if root is not None else cwd
|
|
1528
|
+
if ns.command in NO_DB_COMMANDS and not (
|
|
1529
|
+
ns.command == "journal" and getattr(ns, "action", "") == "restore"):
|
|
1530
|
+
COMMANDS[ns.command](None, None, ns)
|
|
1531
|
+
return
|
|
1532
|
+
name, c = resolve_connection(cfg, ns.conn, os.environ, cwd, root)
|
|
1533
|
+
ns.conn_name = name
|
|
1534
|
+
ns.conn_user = c.get("user", "")
|
|
1535
|
+
ns.schema = (c.get("schema") or c["user"]).upper()
|
|
1536
|
+
print(f"-- connection={name} schema={ns.schema}", file=sys.stderr)
|
|
1537
|
+
try:
|
|
1538
|
+
import oracledb
|
|
1539
|
+
except ModuleNotFoundError:
|
|
1540
|
+
sys.exit("The 'oracledb' package is required to connect: pip install oracledb")
|
|
1541
|
+
try:
|
|
1542
|
+
pool = open_pool(c)
|
|
1543
|
+
conn = acquire(pool, session_should_be_readonly(
|
|
1544
|
+
ns.command, getattr(ns, "action", "") or ""))
|
|
1545
|
+
except (oracledb.Error, OSError) as e:
|
|
1546
|
+
# OSError too: a DNS or socket failure arrives raw from the socket layer,
|
|
1547
|
+
# not wrapped as an oracledb.Error.
|
|
1548
|
+
sys.exit(connect_failure_message(e, name))
|
|
1549
|
+
try:
|
|
1550
|
+
if ns.command == "journal": # only restore reaches here
|
|
1551
|
+
if not ns.id:
|
|
1552
|
+
sys.exit(f"Usage: {invocation()} journal restore <id>")
|
|
1553
|
+
ns.file = f"journal:{ns.id}"
|
|
1554
|
+
code = run_restore(conn, ns.schema, ns)
|
|
1555
|
+
if code:
|
|
1556
|
+
sys.exit(code)
|
|
1557
|
+
else:
|
|
1558
|
+
COMMANDS[ns.command](conn, ns.schema, ns)
|
|
1559
|
+
except oracledb.Error as e:
|
|
1560
|
+
sys.exit(f"Oracle error: {e}")
|
|
1561
|
+
finally:
|
|
1562
|
+
pool.close(force=True)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
if __name__ == "__main__":
|
|
1566
|
+
main()
|