pythia-plsql 0.3.4__tar.gz → 0.4.0__tar.gz
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_plsql-0.3.4/scripts/pythia_plsql.egg-info → pythia_plsql-0.4.0}/PKG-INFO +8 -2
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/README.md +7 -1
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/pyproject.toml +1 -1
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia.py +183 -7
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0/scripts/pythia_plsql.egg-info}/PKG-INFO +8 -2
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-explore/SKILL.md +1 -1
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase3.py +75 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase5.py +27 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/LICENSE +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/name-occupants.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/queries/source.sql +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/setup.cfg +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-apply/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-write/SKILL.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_install.py +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase1.py +0 -0
- {pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/tests/test_phase2.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
|
|
|
115
115
|
| `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
|
|
116
116
|
| `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
|
|
117
117
|
| `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
|
|
118
|
-
| `cols` columns + types | `similar` programs named like this | |
|
|
118
|
+
| `cols` columns + types | `similar` programs named like this | `history` every captured version |
|
|
119
119
|
| `grep` search all source | | |
|
|
120
120
|
| `sql` free query (SELECT/WITH only) | | |
|
|
121
121
|
|
|
122
122
|
Every command takes `--json` (machine output), `--conn` (pick a connection), and
|
|
123
123
|
caps output with explicit truncation markers so context windows stay intact.
|
|
124
124
|
|
|
125
|
+
**The safety net covers hand edits too**: `src` and `impact` snapshot the
|
|
126
|
+
object silently into the journal, each with a runnable rollback file, so a
|
|
127
|
+
change made later in SQL Developer still has something to go back to —
|
|
128
|
+
`pythia history <OBJECT>` lists the versions. Source that moved with no
|
|
129
|
+
apply behind it is reported as drift.
|
|
130
|
+
|
|
125
131
|
**Your house style is config, not folklore**: put naming patterns in
|
|
126
132
|
`.pythia/conventions.json` and apply previews warn when a new object's name
|
|
127
133
|
drifts; put the prose rules in `.pythia/conventions.md` and the skills make
|
|
@@ -101,13 +101,19 @@ and WSL are all CI-tested.
|
|
|
101
101
|
| `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
|
|
102
102
|
| `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
|
|
103
103
|
| `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
|
|
104
|
-
| `cols` columns + types | `similar` programs named like this | |
|
|
104
|
+
| `cols` columns + types | `similar` programs named like this | `history` every captured version |
|
|
105
105
|
| `grep` search all source | | |
|
|
106
106
|
| `sql` free query (SELECT/WITH only) | | |
|
|
107
107
|
|
|
108
108
|
Every command takes `--json` (machine output), `--conn` (pick a connection), and
|
|
109
109
|
caps output with explicit truncation markers so context windows stay intact.
|
|
110
110
|
|
|
111
|
+
**The safety net covers hand edits too**: `src` and `impact` snapshot the
|
|
112
|
+
object silently into the journal, each with a runnable rollback file, so a
|
|
113
|
+
change made later in SQL Developer still has something to go back to —
|
|
114
|
+
`pythia history <OBJECT>` lists the versions. Source that moved with no
|
|
115
|
+
apply behind it is reported as drift.
|
|
116
|
+
|
|
111
117
|
**Your house style is config, not folklore**: put naming patterns in
|
|
112
118
|
`.pythia/conventions.json` and apply previews warn when a new object's name
|
|
113
119
|
drifts; put the prose rules in `.pythia/conventions.md` and the skills make
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pythia-plsql"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -42,6 +42,7 @@ you always know whether you saw everything.
|
|
|
42
42
|
pythia journal restore <id>
|
|
43
43
|
pythia policy
|
|
44
44
|
pythia install
|
|
45
|
+
pythia history MY_PACKAGE
|
|
45
46
|
pythia unistr "Nhóm không được để trống"
|
|
46
47
|
pythia agent-user --save
|
|
47
48
|
"""
|
|
@@ -631,6 +632,84 @@ def load_settings(root):
|
|
|
631
632
|
sys.exit(f"Cannot parse {path}: {e}")
|
|
632
633
|
|
|
633
634
|
|
|
635
|
+
def source_sha(text):
|
|
636
|
+
import hashlib
|
|
637
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def fetch_source(conn, schema, name):
|
|
641
|
+
"""Current source of an object, as ALL_SOURCE holds it. ('', None) when
|
|
642
|
+
the name is not a source object (a table, say)."""
|
|
643
|
+
_, rows = run_query(conn, load_query("source.sql"), {"s": schema, "n": name})
|
|
644
|
+
if not rows:
|
|
645
|
+
return "", None
|
|
646
|
+
return "".join(cell(r[2]) for r in rows), rows[0][0]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def last_known_state(root, name):
|
|
650
|
+
"""What the database held, as far as the journal knows: the newest entry
|
|
651
|
+
for this object, and the source that entry says was really there.
|
|
652
|
+
A preview wrote nothing, so its `before` is the real state."""
|
|
653
|
+
for eid in list_journal_entries(root): # newest first
|
|
654
|
+
e = read_journal_entry(root, eid)
|
|
655
|
+
m = e["meta"]
|
|
656
|
+
if str(m.get("object", "")).upper() != name.upper():
|
|
657
|
+
continue
|
|
658
|
+
real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
|
|
659
|
+
return real, eid, m
|
|
660
|
+
return None, None, None
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def auto_snapshot(conn, schema, name, ns):
|
|
664
|
+
"""Capture an object's source the moment an agent focuses on it, so a
|
|
665
|
+
later hand-edit outside pythia still has something to go back to.
|
|
666
|
+
|
|
667
|
+
Silent by design: writing to the journal costs the agent no context, and
|
|
668
|
+
a line of "snapshot taken" on every read would cost it on every read.
|
|
669
|
+
The one thing worth saying is drift — the source moved without an apply
|
|
670
|
+
of ours explaining it — and that goes to stderr, so --json stays clean.
|
|
671
|
+
|
|
672
|
+
Returns a drift warning, or None.
|
|
673
|
+
"""
|
|
674
|
+
root = getattr(ns, "project_root", None)
|
|
675
|
+
if root is None or not load_settings(root).get("auto_snapshot", True):
|
|
676
|
+
return None
|
|
677
|
+
try:
|
|
678
|
+
text, otype = fetch_source(conn, schema, name)
|
|
679
|
+
if not text.strip():
|
|
680
|
+
return None # not a source object; nothing to keep
|
|
681
|
+
known, eid, meta = last_known_state(root, name)
|
|
682
|
+
if known is not None and source_sha(known) == source_sha(text):
|
|
683
|
+
return None # unchanged — no entry, no output
|
|
684
|
+
write_journal_entry(root, otype or "OBJECT", name.upper(), text, text,
|
|
685
|
+
{"schema": schema, "connection": ns.conn_name,
|
|
686
|
+
"snapshot": True, "applied": False,
|
|
687
|
+
"sha": source_sha(text)})
|
|
688
|
+
if known is None:
|
|
689
|
+
return None # first sighting is a baseline
|
|
690
|
+
rollback = journal_root(root) / eid / "restore.sql"
|
|
691
|
+
return (f"! {name.upper()} changed outside pythia since {eid}.\n"
|
|
692
|
+
f" Rollback file for the previous version: {rollback}\n"
|
|
693
|
+
f" All versions: {invocation()} history {name.upper()}")
|
|
694
|
+
except Exception: # noqa: BLE001
|
|
695
|
+
return None # a safety net must never break a read
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def report_drift(msg):
|
|
699
|
+
if msg:
|
|
700
|
+
print(paint(msg, "yellow", color_enabled(sys.stderr)), file=sys.stderr)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def journaled_objects(root):
|
|
704
|
+
names = {}
|
|
705
|
+
for eid in list_journal_entries(root): # newest first
|
|
706
|
+
m = read_journal_entry(root, eid)["meta"]
|
|
707
|
+
n = str(m.get("object", "")).upper()
|
|
708
|
+
if n and n != "STATEMENT" and n not in names:
|
|
709
|
+
names[n] = eid[:19] # the entry's timestamp
|
|
710
|
+
return names
|
|
711
|
+
|
|
712
|
+
|
|
634
713
|
def undo_group_action(ns):
|
|
635
714
|
"""Undoing a CREATE is a DROP, which is `structural` — so the policy on
|
|
636
715
|
that group decides whether the restore command we print can run."""
|
|
@@ -827,6 +906,43 @@ def cmd_check(conn, schema, ns):
|
|
|
827
906
|
if warn:
|
|
828
907
|
print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
|
|
829
908
|
file=sys.stderr)
|
|
909
|
+
report_drift(drift_summary(conn, schema, ns))
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def drift_summary(conn, schema, ns):
|
|
913
|
+
"""One line, only when objects pythia knows have moved since it last saw
|
|
914
|
+
them. Uses LAST_DDL_TIME — a single query for every journaled object,
|
|
915
|
+
instead of re-reading every source.
|
|
916
|
+
ponytail: LAST_DDL_TIME also ticks on a bare recompile, so this is a
|
|
917
|
+
'go look' signal; `history` and the src/impact hash comparison are the
|
|
918
|
+
precise ones."""
|
|
919
|
+
root = getattr(ns, "project_root", None)
|
|
920
|
+
if root is None:
|
|
921
|
+
return None
|
|
922
|
+
try:
|
|
923
|
+
known = journaled_objects(root)
|
|
924
|
+
if not known:
|
|
925
|
+
return None
|
|
926
|
+
names = sorted(known)[:200] # one query, bounded
|
|
927
|
+
binds = {"s": schema}
|
|
928
|
+
placeholders = []
|
|
929
|
+
for i, n in enumerate(names):
|
|
930
|
+
binds[f"n{i}"] = n
|
|
931
|
+
placeholders.append(f":n{i}")
|
|
932
|
+
_, rows = run_query(
|
|
933
|
+
conn, "select object_name, to_char(last_ddl_time,"
|
|
934
|
+
"'yyyy-mm-dd\"T\"hh24-mi-ss') from all_objects "
|
|
935
|
+
f"where owner = :s and object_name in ({','.join(placeholders)})",
|
|
936
|
+
binds)
|
|
937
|
+
moved = [r[0] for r in rows if str(r[1]) > known.get(str(r[0]), "")]
|
|
938
|
+
if not moved:
|
|
939
|
+
return None
|
|
940
|
+
shown = ", ".join(moved[:3]) + ("…" if len(moved) > 3 else "")
|
|
941
|
+
return (f"! {len(moved)} object(s) changed since pythia last saw them: "
|
|
942
|
+
f"{shown}\n Versions and restores: {invocation()} history "
|
|
943
|
+
f"<NAME>")
|
|
944
|
+
except Exception: # noqa: BLE001
|
|
945
|
+
return None
|
|
830
946
|
|
|
831
947
|
|
|
832
948
|
def cmd_ls(conn, schema, ns):
|
|
@@ -846,8 +962,10 @@ def cmd_src(conn, schema, ns):
|
|
|
846
962
|
if not rows:
|
|
847
963
|
sys.exit(f"No source found for {ns.name!r} in schema {schema}.")
|
|
848
964
|
total = len(rows)
|
|
965
|
+
drift = auto_snapshot(conn, schema, ns.name, ns)
|
|
849
966
|
shown, truncated = clip(rows, ns.max_lines, ns.offset)
|
|
850
967
|
emit_source(ns, shown, truncated, total)
|
|
968
|
+
report_drift(drift)
|
|
851
969
|
|
|
852
970
|
|
|
853
971
|
def cmd_args(conn, schema, ns):
|
|
@@ -955,19 +1073,26 @@ def cmd_deps(conn, schema, ns):
|
|
|
955
1073
|
def cmd_impact(conn, schema, ns):
|
|
956
1074
|
cols, rows = run_query(conn, load_query("impact.sql"),
|
|
957
1075
|
{"s": schema, "n": ns.name, "depth": ns.depth})
|
|
1076
|
+
# impact is mandatory before any change, so it is the surest "about to
|
|
1077
|
+
# touch this" signal there is — the best possible place to capture a
|
|
1078
|
+
# before-state the developer never had to ask for
|
|
1079
|
+
drift = auto_snapshot(conn, schema, ns.name, ns)
|
|
958
1080
|
shown, truncated = clip(rows, ns.limit)
|
|
959
1081
|
if ns.json:
|
|
960
1082
|
print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
|
|
961
1083
|
summary=impact_summary(rows)))
|
|
1084
|
+
report_drift(drift)
|
|
962
1085
|
return
|
|
963
1086
|
if not rows:
|
|
964
1087
|
print(f"-- nothing depends on {ns.name.upper()} "
|
|
965
1088
|
f"(within {schema}, depth {ns.depth})")
|
|
1089
|
+
report_drift(drift)
|
|
966
1090
|
return
|
|
967
1091
|
sys.stdout.write(render_tree(shown, f"{schema}.{ns.name.upper()}"))
|
|
968
1092
|
if truncated:
|
|
969
1093
|
print(f"-- truncated at {len(shown)} rows (raise --limit, or --limit 0 for no cap)")
|
|
970
1094
|
print(impact_summary(rows))
|
|
1095
|
+
report_drift(drift)
|
|
971
1096
|
|
|
972
1097
|
|
|
973
1098
|
def cmd_similar(conn, schema, ns):
|
|
@@ -1351,6 +1476,48 @@ def cmd_policy(conn, schema, ns):
|
|
|
1351
1476
|
print(ROLLBACK_TABLE)
|
|
1352
1477
|
|
|
1353
1478
|
|
|
1479
|
+
def cmd_history(conn, schema, ns):
|
|
1480
|
+
"""Every version of one object the journal holds, newest first. Compact
|
|
1481
|
+
on purpose — an agent reads this to choose an id, not to read source."""
|
|
1482
|
+
root = ns.project_root
|
|
1483
|
+
name = ns.name.upper()
|
|
1484
|
+
rows = []
|
|
1485
|
+
for eid in list_journal_entries(root):
|
|
1486
|
+
e = read_journal_entry(root, eid)
|
|
1487
|
+
m = e["meta"]
|
|
1488
|
+
if str(m.get("object", "")).upper() != name:
|
|
1489
|
+
continue
|
|
1490
|
+
real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
|
|
1491
|
+
kind = ("applied" if m.get("applied") else
|
|
1492
|
+
"snapshot" if m.get("snapshot") else "preview")
|
|
1493
|
+
rows.append({"entry": eid, "kind": kind,
|
|
1494
|
+
"lines": len(real.splitlines()),
|
|
1495
|
+
"sha": source_sha(real),
|
|
1496
|
+
"rollback_file": str(journal_root(root) / eid
|
|
1497
|
+
/ "restore.sql")})
|
|
1498
|
+
if ns.json:
|
|
1499
|
+
print(json.dumps({"object": name, "versions": rows}))
|
|
1500
|
+
return
|
|
1501
|
+
if not rows:
|
|
1502
|
+
print(f"-- no journal history for {name}. It is captured the first "
|
|
1503
|
+
f"time you run `{invocation()} src {name}` or "
|
|
1504
|
+
f"`{invocation()} impact {name}`.")
|
|
1505
|
+
return
|
|
1506
|
+
prev = None
|
|
1507
|
+
for r in rows: # newest first; compare to the older one
|
|
1508
|
+
older = rows[rows.index(r) + 1] if rows.index(r) + 1 < len(rows) else None
|
|
1509
|
+
delta = ""
|
|
1510
|
+
if older and older["sha"] != r["sha"]:
|
|
1511
|
+
n = r["lines"] - older["lines"]
|
|
1512
|
+
delta = f" {n:+d} lines" if n else " content changed"
|
|
1513
|
+
print(f" {r['entry']:<44} {r['kind']:<9} {r['lines']:>5} lines{delta}")
|
|
1514
|
+
prev = r
|
|
1515
|
+
print(f"\n-- every version above has a ready-to-run rollback file:")
|
|
1516
|
+
print(f" {journal_root(root)}\\<entry>\\restore.sql")
|
|
1517
|
+
print(f"-- through pythia (previews first, you approve):")
|
|
1518
|
+
print(f" {invocation()} journal restore <entry>")
|
|
1519
|
+
|
|
1520
|
+
|
|
1354
1521
|
def cmd_journal(conn, schema, ns):
|
|
1355
1522
|
root = ns.project_root
|
|
1356
1523
|
if ns.action == "list":
|
|
@@ -1370,14 +1537,17 @@ def cmd_journal(conn, schema, ns):
|
|
|
1370
1537
|
import shutil
|
|
1371
1538
|
removed = 0
|
|
1372
1539
|
for eid in list_journal_entries(root):
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1540
|
+
meta = read_journal_entry(root, eid)["meta"]
|
|
1541
|
+
if meta.get("applied") or meta.get("snapshot"):
|
|
1542
|
+
continue # applied = a real write; snapshot = the only
|
|
1543
|
+
# undo a hand-edit outside pythia will ever have
|
|
1544
|
+
shutil.rmtree(journal_root(root) / eid)
|
|
1545
|
+
removed += 1
|
|
1376
1546
|
if ns.json:
|
|
1377
1547
|
print(json.dumps({"pruned": removed}))
|
|
1378
1548
|
else:
|
|
1379
|
-
print(f"-- pruned {removed} preview-only entries; applied
|
|
1380
|
-
"
|
|
1549
|
+
print(f"-- pruned {removed} preview-only entries; applied "
|
|
1550
|
+
"entries and snapshots are all kept")
|
|
1381
1551
|
return
|
|
1382
1552
|
if not ns.id:
|
|
1383
1553
|
sys.exit(f"Usage: {invocation()} journal "
|
|
@@ -1752,9 +1922,11 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
|
|
|
1752
1922
|
"impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
|
|
1753
1923
|
"policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
|
|
1754
1924
|
"conventions": cmd_conventions, "install": cmd_install,
|
|
1755
|
-
"unistr": cmd_unistr, "agent-user": cmd_agent_user
|
|
1925
|
+
"unistr": cmd_unistr, "agent-user": cmd_agent_user,
|
|
1926
|
+
"history": cmd_history}
|
|
1756
1927
|
|
|
1757
|
-
NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr"
|
|
1928
|
+
NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr",
|
|
1929
|
+
"history"}
|
|
1758
1930
|
|
|
1759
1931
|
|
|
1760
1932
|
# --- CLI ---------------------------------------------------------------------
|
|
@@ -1860,6 +2032,10 @@ def build_parser():
|
|
|
1860
2032
|
s.add_argument("--save", action="store_true",
|
|
1861
2033
|
help="add the new credential as <conn>_agent and make it "
|
|
1862
2034
|
"the default connection")
|
|
2035
|
+
s = sub.add_parser("history", parents=[common()],
|
|
2036
|
+
help="every captured version of one object, newest "
|
|
2037
|
+
"first — pick one to restore")
|
|
2038
|
+
s.add_argument("name")
|
|
1863
2039
|
s = sub.add_parser("unistr", parents=[common()],
|
|
1864
2040
|
help="Oracle unistr('...') literal for non-ASCII text "
|
|
1865
2041
|
"(Vietnamese messages stay exact)")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
|
|
|
115
115
|
| `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
|
|
116
116
|
| `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
|
|
117
117
|
| `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
|
|
118
|
-
| `cols` columns + types | `similar` programs named like this | |
|
|
118
|
+
| `cols` columns + types | `similar` programs named like this | `history` every captured version |
|
|
119
119
|
| `grep` search all source | | |
|
|
120
120
|
| `sql` free query (SELECT/WITH only) | | |
|
|
121
121
|
|
|
122
122
|
Every command takes `--json` (machine output), `--conn` (pick a connection), and
|
|
123
123
|
caps output with explicit truncation markers so context windows stay intact.
|
|
124
124
|
|
|
125
|
+
**The safety net covers hand edits too**: `src` and `impact` snapshot the
|
|
126
|
+
object silently into the journal, each with a runnable rollback file, so a
|
|
127
|
+
change made later in SQL Developer still has something to go back to —
|
|
128
|
+
`pythia history <OBJECT>` lists the versions. Source that moved with no
|
|
129
|
+
apply behind it is reported as drift.
|
|
130
|
+
|
|
125
131
|
**Your house style is config, not folklore**: put naming patterns in
|
|
126
132
|
`.pythia/conventions.json` and apply previews warn when a new object's name
|
|
127
133
|
drifts; put the prose rules in `.pythia/conventions.md` and the skills make
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pythia-explore
|
|
3
|
-
description: Use when you need to understand anything in an Oracle schema - finding objects, reading PL/SQL source, signatures, table columns, DDL, searching code, or asking who uses what. ALSO use when a .sql file, dump, export, migration script or repo copy of an object appears to answer the question already, including
|
|
3
|
+
description: Use when you need to understand anything in an Oracle schema - finding objects, reading PL/SQL source, signatures, table columns, DDL, searching code, or asking who uses what. ALSO use when a .sql file, dump, export, migration script or repo copy of an object appears to answer the question already, including one the developer points you at - because those files drift from the database and are wrong in ways that read as correct. The database is the only source of truth.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Exploring the Schema
|
|
@@ -687,6 +687,81 @@ def test_report_admits_a_created_objects_undo_is_blocked():
|
|
|
687
687
|
assert "will be refused" not in buf.getvalue().lower()
|
|
688
688
|
|
|
689
689
|
|
|
690
|
+
def test_auto_snapshot_captures_silently_and_writes_a_rollback_file():
|
|
691
|
+
"""Every captured version leaves a runnable rollback file — that is the
|
|
692
|
+
whole point for work done by hand, outside pythia. And capturing costs
|
|
693
|
+
the agent no context: nothing is printed when nothing moved."""
|
|
694
|
+
import argparse
|
|
695
|
+
import contextlib
|
|
696
|
+
import io
|
|
697
|
+
src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
|
|
698
|
+
script = {"from all_source": ([("TYPE", "LINE", "TEXT")],
|
|
699
|
+
[("PROCEDURE", i + 1, ln + "\n")
|
|
700
|
+
for i, ln in enumerate(src.splitlines())])}
|
|
701
|
+
with tempfile.TemporaryDirectory() as td:
|
|
702
|
+
ns = argparse.Namespace(project_root=td, conn_name="DEV")
|
|
703
|
+
buf = io.StringIO()
|
|
704
|
+
with contextlib.redirect_stdout(buf):
|
|
705
|
+
drift = pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns)
|
|
706
|
+
assert drift is None # first sighting: a baseline
|
|
707
|
+
assert buf.getvalue() == "" # and not one token of output
|
|
708
|
+
ids = pythia.list_journal_entries(td)
|
|
709
|
+
assert len(ids) == 1
|
|
710
|
+
e = pythia.read_journal_entry(td, ids[0])
|
|
711
|
+
assert e["meta"]["snapshot"] is True
|
|
712
|
+
rollback = e["restore"]
|
|
713
|
+
assert rollback.startswith("CREATE OR REPLACE PROCEDURE P_X")
|
|
714
|
+
assert (pythia.journal_root(td) / ids[0] / "restore.sql").is_file()
|
|
715
|
+
|
|
716
|
+
# reading it again unchanged must not create a second entry
|
|
717
|
+
assert pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns) is None
|
|
718
|
+
assert pythia.list_journal_entries(td) == ids
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def test_auto_snapshot_reports_drift_and_keeps_the_old_rollback():
|
|
722
|
+
"""Source moved with no apply of ours behind it — say so once, and point
|
|
723
|
+
at the rollback file for the version that was there before."""
|
|
724
|
+
import argparse
|
|
725
|
+
old_src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
|
|
726
|
+
new_src = "PROCEDURE P_X AS\nBEGIN\n other_thing;\nEND;\n"
|
|
727
|
+
|
|
728
|
+
def script(text):
|
|
729
|
+
return {"from all_source": ([("TYPE", "LINE", "TEXT")],
|
|
730
|
+
[("PROCEDURE", i + 1, ln + "\n")
|
|
731
|
+
for i, ln in enumerate(text.splitlines())])}
|
|
732
|
+
with tempfile.TemporaryDirectory() as td:
|
|
733
|
+
ns = argparse.Namespace(project_root=td, conn_name="DEV")
|
|
734
|
+
pythia.auto_snapshot(FakeConn(script(old_src)), "APP", "P_X", ns)
|
|
735
|
+
first = pythia.list_journal_entries(td)[0]
|
|
736
|
+
drift = pythia.auto_snapshot(FakeConn(script(new_src)), "APP", "P_X", ns)
|
|
737
|
+
assert drift and "changed outside pythia" in drift
|
|
738
|
+
assert "restore.sql" in drift
|
|
739
|
+
# the older version's rollback is still on disk and still runnable
|
|
740
|
+
old_rollback = pythia.read_journal_entry(td, first)["restore"]
|
|
741
|
+
assert "NULL;" in old_rollback
|
|
742
|
+
assert len(pythia.list_journal_entries(td)) == 2
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_prune_never_eats_a_snapshot():
|
|
746
|
+
import argparse
|
|
747
|
+
import contextlib
|
|
748
|
+
import io
|
|
749
|
+
import datetime
|
|
750
|
+
with tempfile.TemporaryDirectory() as td:
|
|
751
|
+
base = datetime.datetime(2026, 8, 27, 9, 0, 0)
|
|
752
|
+
pythia.write_journal_entry(td, "PROCEDURE", "P_A", "a", "b",
|
|
753
|
+
{"applied": False}, now=base)
|
|
754
|
+
snap = pythia.write_journal_entry(
|
|
755
|
+
td, "PROCEDURE", "P_B", "a", "a", {"snapshot": True,
|
|
756
|
+
"applied": False},
|
|
757
|
+
now=base.replace(minute=1))
|
|
758
|
+
ns = argparse.Namespace(action="prune", project_root=td, json=False,
|
|
759
|
+
id=None)
|
|
760
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
761
|
+
pythia.cmd_journal(None, None, ns)
|
|
762
|
+
assert pythia.list_journal_entries(td) == [snap]
|
|
763
|
+
|
|
764
|
+
|
|
690
765
|
def main():
|
|
691
766
|
failed = 0
|
|
692
767
|
for name, fn in sorted(globals().items()):
|
|
@@ -54,6 +54,33 @@ def test_frontmatter_is_valid_and_triggering():
|
|
|
54
54
|
assert len(desc) <= 1024, f"{name}: description too long ({len(desc)})"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_frontmatter_is_parseable_yaml():
|
|
58
|
+
"""A description holding an unquoted ": " is not valid YAML, and the
|
|
59
|
+
agent harness then falls back to the H1 heading — the skill keeps its
|
|
60
|
+
name but loses its trigger, so it silently stops firing. This shipped
|
|
61
|
+
once; it does not ship twice. Checked without PyYAML: the suites run on
|
|
62
|
+
the stdlib alone.
|
|
63
|
+
"""
|
|
64
|
+
for name in sorted(EXPECTED):
|
|
65
|
+
path = SKILLS / name / "SKILL.md"
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
continue
|
|
68
|
+
m = re.match(r"^---\n(.*?)\n---\n", path.read_text(encoding="utf-8"),
|
|
69
|
+
re.S)
|
|
70
|
+
assert m, f"{name}: no YAML frontmatter"
|
|
71
|
+
for line in m.group(1).splitlines():
|
|
72
|
+
km = re.match(r"^([A-Za-z_][\w.]*):\s+(.*)$", line)
|
|
73
|
+
if not km:
|
|
74
|
+
continue # continuation or nested line
|
|
75
|
+
value = km.group(2)
|
|
76
|
+
if value[:1] in "\"'":
|
|
77
|
+
continue # quoted: a colon inside is fine
|
|
78
|
+
assert ": " not in value, (
|
|
79
|
+
f"{name}: frontmatter '{km.group(1)}' holds an unquoted "
|
|
80
|
+
f"': ' — YAML reads that as a nested mapping and the field "
|
|
81
|
+
f"is lost. Rephrase or quote the value.")
|
|
82
|
+
|
|
83
|
+
|
|
57
84
|
def test_skill_bodies_stay_within_budget():
|
|
58
85
|
for name in sorted(EXPECTED):
|
|
59
86
|
path = SKILLS / name / "SKILL.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/scripts/pythia_plsql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.3.4 → pythia_plsql-0.4.0}/skills/pythia-explore/reference/data-dictionary.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|