From 5b5ca203aaf88c9c6af3ff82bf09d6cabf0b8a82 Mon Sep 17 00:00:00 2001 From: ridhima-splunk Date: Thu, 20 Aug 2026 18:28:17 -0700 Subject: [PATCH 1/2] Galileo to splunk regex migration tool --- .../splunk_ao_migrate/README.md | 191 +++++++ .../splunk_ao_migrate/__init__.py | 1 + .../splunk_ao_migrate/migrate.py | 350 +++++++++++++ .../splunk_ao_migrate/pyproject.toml | 23 + .../splunk_ao_migrate/reporter.py | 90 ++++ .../splunk_ao_migrate/rules.py | 474 ++++++++++++++++++ .../splunk_ao_migrate/transformer.py | 156 ++++++ 7 files changed, 1285 insertions(+) create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/README.md create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/__init__.py create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/migrate.py create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/pyproject.toml create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/reporter.py create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/rules.py create mode 100644 splunk-ao-migration-tool/splunk_ao_migrate/transformer.py diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/README.md b/splunk-ao-migration-tool/splunk_ao_migrate/README.md new file mode 100644 index 00000000..500f4a40 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/README.md @@ -0,0 +1,191 @@ +# splunk_ao_migrate — Regex-Based Migration Tool + +Automatically migrate Python code from the `galileo` SDK to `splunk-ao-python` +using ordered regex substitutions. + +## What it does + +Rewrites every file type the migration touches in a single pass, then renames any +directories or files whose names contain `galileo`: + +| File type | Examples | What changes | +|-----------|----------|--------------| +| Python source | `*.py` | Imports, class names, kwargs, env-var strings, HTTP headers | +| Doc files | `*.md`, `*.rst` | Same rules as Python; known Galileo doc URLs rewritten to Splunk AO equivalents; all other URLs left intact | +| Dependency files | `requirements*.txt`, `pyproject.toml` | Package names, Python identifiers, uv source keys, pytest env vars, brand prose, `requires-python` floor | +| Environment files | `.env`, `.env.example` | All `GALILEO_*` keys → `SPLUNK_AO_*`; `galileo` in placeholder values | +| Filesystem paths | directories, filenames | `galileo-a2a/` → `splunk-ao-a2a/`, `galileo_a2a/` → `splunk_ao_a2a/`, etc. | + +## Installation + +```bash +# Install from the package directory +pip install ./splunk_ao_migrate + +# Or with uv +uv pip install ./splunk_ao_migrate +``` + +No external dependencies — uses Python stdlib only. + +## Usage + +```bash +# Rewrite an entire directory in place +splunk-ao-migrate src/ + +# Rewrite a single file +splunk-ao-migrate my_agent.py + +# Preview changes without writing (dry run) +splunk-ao-migrate --dry-run src/ + +# Suppress the summary report +splunk-ao-migrate --no-report src/ + +# Run directly without installing +python splunk_ao_migrate/migrate.py --dry-run src/ + +# Run as a module +python -m splunk_ao_migrate.migrate --dry-run src/ + +# Run with uv +uv run python splunk_ao_migrate/migrate.py --dry-run src/ +``` + +## Package layout + +``` +splunk_ao_migrate/ + migrate.py ← CLI entry point (also registered as splunk-ao-migrate console script) + rules.py ← all substitution rules (imports, symbols, kwargs, env-vars, headers) + transformer.py ← applies rules to source text, returns TransformResult + reporter.py ← formats and prints the migration summary report + pyproject.toml ← package metadata and entry point declaration + README.md ← this file +``` + +## What gets migrated + +### Python files + +- `from galileo import …` → `from splunk_ao import …` +- `from galileo.metric import …` → `from splunk_ao.evaluator import …` +- `GalileoLogger` → `SplunkAOLogger` (and all other `Galileo*` class renames) +- `GalileoMetric` / `GalileoMetrics` / `GalileoScorers` → `SplunkAOEvaluator` / `SplunkAOEvaluators` +- `SplunkAOMetric` → `SplunkAOEvaluator`, `SplunkAOMetrics` → `SplunkAOEvaluators` +- Domain renames: `Metric` → `Evaluator`, `LlmMetric` → `LlmEvaluator`, `LocalMetric` → `LocalEvaluator`, etc. +- **Not renamed**: `MetricSpec` and `LocalMetricConfig` — these remain as live names in `splunk-ao` (the rename proposal `EvaluatorSpec` / `LocalEvaluatorConfig` was not implemented) +- `LogStream` → `AgentStream`, `.logstreams` → `.agent_streams` +- Method renames: `get_log_stream` → `get_agent_stream`, `create_log_stream` → `create_agent_stream`, `list_log_streams` → `list_agent_streams`, `delete_metric` → `delete_evaluator`, `create_custom_llm_metric` → `create_custom_llm_evaluator` +- **Not renamed**: `get_metrics()` and `set_metrics()` on `AgentStream` — these remain as live method names; only the module-level `get_evaluators()` function is the new API +- Keyword argument and parameter renames: `log_stream=` → `agent_stream=`, `log_stream_name=` → `agent_stream_name=`, `logstream=` → `agentstream=`; also catches typed parameter declarations like `log_stream: str | None = None` → `agent_stream: str | None = None` +- Config file renames: `galileo-python-config.json` → `splunk-ao-config.json`, `galileo-config.json` → `splunk-ao-config.json` +- `GALILEO_*` env-var string literals → `SPLUNK_AO_*` (including `GALILEO_API_ENDPOINT`, `GALILEO_API_KEY`, `GALILEO_CONSOLE_URL`, `GALILEO_HOME_DIR`, etc.) +- `X-Galileo-Trace-ID` / `X-Galileo-Parent-ID` HTTP headers +- `GalileoSpanProcessor` → `SplunkAOSpanProcessor`, `add_galileo_span_processor` → `add_splunk_ao_span_processor` +- `GalileoObserver` → `SplunkAOObserver` +- `galileo_*` prefixed identifiers (e.g. `galileo_session_id`) → `splunk_ao_*` +- `_galileo_` mid-identifier and attribute patterns (e.g. `func._galileo_is_retriever`, `self._handler._galileo_logger`) → `_splunk_ao_*`; the rule fires after `.`, spaces, and quotes, not just within word characters +- `GALILEO_OBSERVE_KEY` constant name → `SPLUNK_AO_OBSERVE_KEY` (the string wire value `"galileo_observe"` is intentionally left unchanged for A2A metadata compatibility) +- `galileo-a2a` package name in string literals and pip installs → `splunk-ao-a2a` (hyphenated; handled before the generic `galileo` rule to avoid producing the wrong underscore form) +- `Galileo.ai` brand name in prose → `Splunk AO` +- `Galileo` brand name in comments/docstrings → `Splunk AO` + +### Doc files (`.md`, `.rst`) + +Doc files are processed in three passes: + +1. **URL pass**: known Galileo documentation URLs are rewritten to their Splunk AO equivalents: + - `https://docs.galileo.ai/` → `https://agent-observability-docs.splunk.com/` + - `.../add-galileo-to-crewai/add-galileo-to-crewai` → `.../add-splunk-ao-to-crewai/add-splunk-ao-to-crewai` + - `-galileo.md` filename references → `-splunk-ao.md` + - `-galileo.txt` filename references → `-splunk-ao.txt` + - `/what-is-galileo` → `/what-is-splunk-agent-observability` + - `/getting-started/logging` → `/concepts/logging/overview` + - `/concepts/experiments/overview` → `/sdk-api/experiments/experiments` +2. **Prose pass**: all the same symbol, import, env-var, and brand-name substitutions as Python files, with one exception — `logstream=` (no underscore) is **not** rewritten in docs to avoid corrupting env-var string values like `TRACELOOP_HEADERS="..., logstream=default, ..."`. `log_stream=` and `log_stream_name=` are still rewritten. +3. **Placeholder fix pass**: corrects `your-splunk_ao-*` (underscore, produced by the import rule) back to `your-splunk-ao-*` (hyphenated, correct prose form). Also corrects bare `splunk_ao` in prose position (not followed by `_` or `.`) to the brand name `Splunk AO`. + +All other URLs (`https?://...`) are **not rewritten** — external links remain intact. + +### Dependency files + +- `galileo` → `splunk-ao` +- `galileo-adk` → `splunk-ao-adk` +- `galileo-a2a` → `splunk-ao-a2a` +- `galileo_a2a` → `splunk_ao_a2a` (Python package identifier in paths and config) +- `galileo_adk` → `splunk_ao_adk` +- `sources = { galileo = ...}` → `sources = { "splunk-ao" = ...}` (uv TOML source key, quoted because hyphen is not valid in a bare TOML key) +- `GALILEO_*` env-var strings in `pyproject.toml` pytest `env = [...]` blocks → `SPLUNK_AO_*` +- `requires-python` floor below `3.11` → `>=3.11` (e.g. `>=3.10,<3.14` → `>=3.11,<3.14`) +- `Galileo` brand name in prose fields (e.g. `description`, `authors`) → `Splunk AO` + +### Environment files + +- All `GALILEO_*` keys → `SPLUNK_AO_*` (e.g. `GALILEO_API_ENDPOINT`, `GALILEO_API_KEY`, `GALILEO_CONSOLE_URL`, `GALILEO_PROJECT`, etc.) +- `GALILEO_LOGSTREAM` / `GALILEO_LOG_STREAM` → `SPLUNK_AO_AGENT_STREAM` +- HTTP header strings: `Galileo-API-Key` → `Splunk-AO-API-Key`, `X-Galileo-Trace-ID` → `Splunk-AO-Trace-ID` +- `galileo` as a word in placeholder values (e.g. `your-galileo-key` → `your-splunk-ao-key`) +- `galileo` inside underscore-delimited placeholder tokens (e.g. `your_galileo_api_key_here` → `your_splunk_ao_api_key_here`) +- `Galileo` brand name in comments → `Splunk AO` + +### Filesystem paths + +Directories and files are renamed after file content is rewritten, deepest-first +so child paths are handled before their parents: + +- `galileo-a2a/` → `splunk-ao-a2a/` +- `galileo-adk/` → `splunk-ao-adk/` +- `galileo_a2a/` → `splunk_ao_a2a/` (Python package dirs use underscore) +- `galileo_` prefix in any directory or filename → `splunk_ao_` +- bare `galileo` directory name → `splunk_ao` + +The root directory passed as the CLI argument is included in the rename scan, +so `splunk-ao-migrate galileo-a2a/` will rename the directory itself to `splunk-ao-a2a/`. + +## Warnings (flagged, not auto-fixed) + +- **Protect feature usage** (`invoke_protect`, `ainvoke_protect`, etc.) — keep `galileo` + as a dependency; Protect is not available in `splunk-ao` +- **`galileo_core` imports** — `galileo_core` is a low-level external dependency used + internally by `splunk-ao`. It is **not** renamed to `splunk_ao_core` (no such package + exists). When this warning fires, review any `galileo_core` types used in your code + (e.g. `Metrics` from `galileo_core.schemas.logging.step`) — they are internal types + and should not be renamed to the `splunk-ao` public API equivalents +- **Dynamic env-var construction** (`f"GALILEO_{key}"`) — cannot be auto-rewritten; + update manually +- **`GALILEO_OBSERVE_KEY`** — OTel interop constant name in `splunk-ao-a2a`; the tool + renames the Python constant to `SPLUNK_AO_OBSERVE_KEY` but flags it so you can verify + the wire-level string value `"galileo_observe"` is intentionally preserved for + cross-agent A2A metadata compatibility +- **Lowercase `galileo` in string literals** — may refer to the astronomer or other + non-SDK usage (e.g. `"what moons did galileo discover"`); verify whether it should + be renamed or left as-is + +## Manual steps after migration + +- **On-disk config directory**: the local config directory has moved from `~/.galileo/` to `~/.splunk/`. + Delete or migrate any `~/.galileo/galileo-python-config.json` to `~/.splunk/splunk-ao-config.json` + manually — the tool rewrites file content and names but does not touch directories outside the target path. + +## Limitations + +- Rules are applied to raw text, so occurrences in comments and docstrings are also + rewritten. If you need comments and docstrings left untouched, use the AST-based tool + (`splunk_ao_migrate_ast`) instead. +- URLs are not rewritten in Python, dependency, and environment files. In doc files + (`.md`, `.rst`), only the known Galileo documentation URLs listed above are rewritten; + all other external links are preserved as-is. +- **`galileo_core` interop code**: files that import from `galileo_core` and use its + internal types (e.g. `Metrics`, `_ADK_ROLE_TO_GALILEO`, `_map_adk_role_to_galileo`) + may have some internal variable/function names over-fired. The `galileo_core` warning + identifies these files for manual review. This only affects code that directly wraps or + bridges `galileo_core` internals (e.g. `splunk-ao-adk` source itself) — typical user + application code is not affected. + +## See also + +- `splunk_ao_migrate_ast/` — AST-based tool (preserves comments and docstrings) +- `agent_migrate/` — AI agent tool (LLM-driven interactive migration) +- `splunk-ao-migration-tool/README.md` — complete migration guide diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/__init__.py b/splunk-ao-migration-tool/splunk_ao_migrate/__init__.py new file mode 100644 index 00000000..fcd9f16f --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/__init__.py @@ -0,0 +1 @@ +# splunk_ao_migrate — automated galileo → splunk-ao migration tool diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/migrate.py b/splunk-ao-migration-tool/splunk_ao_migrate/migrate.py new file mode 100644 index 00000000..8490ff22 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/migrate.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +splunk-ao-migrate +================= +Automatically migrate Python code from the galileo SDK to splunk-ao-python. + +Usage +----- + python -m splunk_ao_migrate.migrate src/ # rewrite an entire directory in place + python -m splunk_ao_migrate.migrate my_agent.py # rewrite a single file + python -m splunk_ao_migrate.migrate --dry-run src/ # preview changes without writing + python -m splunk_ao_migrate.migrate requirements.txt .env # migrate dependency / env files + +Once installed via pip: + splunk-ao-migrate src/ + splunk-ao-migrate --dry-run src/ + +See splunk-ao-migration-tool/PROPOSAL.md for full design details. +See splunk-ao-migration-tool/README.md for the complete migration guide. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +from pathlib import Path + +# Allow running directly (python splunk_ao_migrate/migrate.py) without installing. +# __file__ is /splunk_ao_migrate/migrate.py so parent.parent is . +_TOOL_ROOT = Path(__file__).parent.parent +if str(_TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(_TOOL_ROOT)) + +from splunk_ao_migrate.reporter import FileResult, Reporter +from splunk_ao_migrate.rules import ( + DEP_RULES, + DOC_PLACEHOLDER_RULES, + DOC_PROSE_RULES, + DOC_URL_RULES, + ENV_FILE_RULES, + PYTHON_RULES, + WARNING_RULES, +) +from splunk_ao_migrate.transformer import transform, transform_urls + +# --------------------------------------------------------------------------- +# File classification +# --------------------------------------------------------------------------- + +_DEP_NAMES = {"requirements.txt", "requirements-dev.txt", "requirements-test.txt"} +_DEP_GLOB = "requirements*.txt" +_ENV_SUFFIXES = {".env"} +_ENV_PREFIXES = {".env"} +_TOML_NAME = "pyproject.toml" + + +def _classify(path: Path) -> str: + """Return 'python', 'dep', 'env', 'toml', 'doc', or 'skip'.""" + name = path.name.lower() + if path.suffix == ".py": + return "python" + if name.startswith("requirements") and name.endswith(".txt"): + return "dep" + if name == _TOML_NAME: + return "toml" + if name.startswith(".env") or path.suffix in _ENV_SUFFIXES: + return "env" + if path.suffix in {".md", ".rst"}: + return "doc" + return "skip" + + +# --------------------------------------------------------------------------- +# Per-file migration +# --------------------------------------------------------------------------- + +def migrate_file(path: Path, dry_run: bool) -> FileResult: + result = FileResult(path=str(path)) + kind = _classify(path) + + if kind == "skip": + result.skipped = True + result.skip_reason = "not a recognised file type" + return result + + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + result.skipped = True + result.skip_reason = "non-UTF-8 content" + return result + except OSError as exc: + result.skipped = True + result.skip_reason = str(exc) + return result + + if kind == "doc": + # Pass 1: rewrite full URLs (docs.galileo.ai → agent-observability-docs.splunk.com). + # transform_urls bypasses the URL guard so URL-pattern rules actually match. + url_tr = transform_urls(content, DOC_URL_RULES) + # Pass 2: apply prose rules (brand names, symbols, env vars …) on the + # already-URL-rewritten content, with the URL guard re-enabled. + # DOC_PROSE_RULES excludes KWARG_RULES to avoid rewriting kwarg-style + # tokens inside string values (e.g. "logstream=default" in TRACELOOP_HEADERS). + prose_tr = transform(url_tr.content, DOC_PROSE_RULES, WARNING_RULES) + # Pass 3: fix placeholder over-rewrites — "your-galileo-*" became + # "your-splunk_ao-*" (underscore) via the import rule; correct to + # "your-splunk-ao-*" (hyphen) as used in prose and code-fence examples. + placeholder_tr = transform_urls(prose_tr.content, DOC_PLACEHOLDER_RULES) + result.matches = url_tr.matches + prose_tr.matches + placeholder_tr.matches + result.warnings = prose_tr.warnings + tr_content = placeholder_tr.content + elif kind == "python": + tr = transform(content, PYTHON_RULES, WARNING_RULES) + result.matches = tr.matches + result.warnings = tr.warnings + tr_content = tr.content + elif kind in ("dep", "toml"): + tr = transform(content, DEP_RULES) + result.matches = tr.matches + result.warnings = [] + tr_content = tr.content + else: # env + tr = transform(content, ENV_FILE_RULES) + result.matches = tr.matches + result.warnings = [] + tr_content = tr.content + + if result.matches and not dry_run: + _write_atomic(path, tr_content) + + return result + + +def _write_atomic(path: Path, content: str) -> None: + """Write content to path atomically via a temp file in the same directory.""" + dir_ = path.parent + fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".splunk_ao_migrate_") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Directory walk +# --------------------------------------------------------------------------- + +_SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", ".tox", "dist", "build"} + + +def collect_paths(roots: list[str]) -> list[Path]: + """Expand directories recursively; return deduplicated list of paths.""" + seen: set[Path] = set() + out: list[Path] = [] + + for root in roots: + p = Path(root) + if p.is_file(): + if p not in seen: + seen.add(p) + out.append(p) + elif p.is_dir(): + for dirpath, dirnames, filenames in os.walk(p): + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] + for fname in filenames: + fp = Path(dirpath) / fname + if _classify(fp) != "skip" and fp not in seen: + seen.add(fp) + out.append(fp) + else: + print(f"Warning: {root!r} does not exist, skipping.", file=sys.stderr) + + return out + + +# --------------------------------------------------------------------------- +# Path renaming (directories and files whose names contain "galileo") +# --------------------------------------------------------------------------- + +_PATH_RENAMES: list[tuple[str, str]] = [ + # hyphenated package/dir names (galileo-a2a → splunk-ao-a2a) + ("galileo-adk", "splunk-ao-adk"), + ("galileo-a2a", "splunk-ao-a2a"), + # bare hyphenated galileo prefix in dir/file names (galileo-* → splunk-ao-*) + ("galileo-", "splunk-ao-"), + # galileo preceded by a hyphen in the middle of a name (e.g. 03-using-galileo.md) + # Must come before the bare 'galileo' rule to produce the hyphenated form. + ("-galileo", "-splunk-ao"), + # Python package/module dirs (galileo_a2a → splunk_ao_a2a, galileo_ prefix) + ("galileo_", "splunk_ao_"), + # bare galileo dir/file name (last resort) + ("galileo", "splunk_ao"), +] + + +def _rename_path_segment(name: str) -> str: + """Return the renamed version of a single path segment, or the original if no match.""" + for old, new in _PATH_RENAMES: + if old in name: + return name.replace(old, new) + return name + + +def collect_path_renames(roots: list[str]) -> list[tuple[Path, Path]]: + """ + Walk roots and return (old_path, new_path) pairs for every filesystem entry + whose name contains 'galileo'. Pairs are ordered deepest-first so renames + can be applied without invalidating parent paths. + """ + candidates: list[Path] = [] + + for root in roots: + p = Path(root) + base = p if p.is_dir() else p.parent + # Check the root itself — os.walk never yields the top-level dir as an entry + if "galileo" in p.name.lower(): + candidates.append(p) + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] + dp = Path(dirpath) + for d in dirnames: + if "galileo" in d.lower(): + candidates.append(dp / d) + for f in filenames: + if "galileo" in f.lower(): + candidates.append(dp / f) + + # deepest first so child renames happen before parent renames + candidates.sort(key=lambda p: len(p.parts), reverse=True) + + renames: list[tuple[Path, Path]] = [] + seen: set[Path] = set() + for old in candidates: + if old in seen: + continue + seen.add(old) + new_name = _rename_path_segment(old.name) + if new_name != old.name: + renames.append((old, old.parent / new_name)) + + return renames + + +def apply_path_renames(renames: list[tuple[Path, Path]], dry_run: bool) -> None: + """Print and optionally apply filesystem renames.""" + if not renames: + return + + if dry_run: + print("\nPath renames (dry run — not applied):") + else: + print("\nRenamed paths:") + + for old, new in renames: + rel_old = _rel(str(old)) + rel_new = _rel(str(new)) + print(f" {rel_old} → {rel_new}") + if not dry_run: + old.rename(new) + + +# --------------------------------------------------------------------------- +# Dry-run diff printer +# --------------------------------------------------------------------------- + +def _print_diff(file_result: FileResult) -> None: + if not file_result.matches: + return + print(f"\n--- {_rel(file_result.path)}") + for m in file_result.matches: + print(f" line {m.line:>4}: - {m.original}") + print(f" + {m.replacement}") + + +def _rel(path: str) -> str: + try: + return os.path.relpath(path) + except ValueError: + return path + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="splunk-ao-migrate", + description="Migrate Python code from galileo SDK to splunk-ao-python.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + p.add_argument( + "paths", + nargs="+", + metavar="PATH", + help="Files or directories to migrate.", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="Show changes without writing any files.", + ) + p.add_argument( + "--no-report", + action="store_true", + help="Suppress the summary report.", + ) + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + paths = collect_paths(args.paths) + if not paths: + print("No files found to migrate.", file=sys.stderr) + return 1 + + reporter = Reporter() + + for path in paths: + file_result = migrate_file(path, dry_run=args.dry_run) + reporter.add(file_result) + if args.dry_run and file_result.changed: + _print_diff(file_result) + + # Rename directories and files containing "galileo" in their name. + # Runs after content rewrites so updated files land in the right place. + renames = collect_path_renames(args.paths) + apply_path_renames(renames, dry_run=args.dry_run) + + if not args.no_report: + reporter.print_report(dry_run=args.dry_run) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/pyproject.toml b/splunk-ao-migration-tool/splunk_ao_migrate/pyproject.toml new file mode 100644 index 00000000..f012fe79 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "splunk-ao-migrate" +version = "0.1.0" +description = "Regex-based CLI to migrate Python code from the galileo SDK to splunk-ao-python" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +keywords = ["migration", "galileo", "splunk-ao", "codemod"] +dependencies = [] # stdlib only — no external dependencies + +[project.scripts] +splunk-ao-migrate = "splunk_ao_migrate.migrate:main" + +[project.urls] +Repository = "https://github.com/splunk/splunk-ao-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +# Include only the splunk_ao_migrate package, not the broader tool directory +packages = ["splunk_ao_migrate"] diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/reporter.py b/splunk-ao-migration-tool/splunk_ao_migrate/reporter.py new file mode 100644 index 00000000..2e807013 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/reporter.py @@ -0,0 +1,90 @@ +""" +Collects per-file results and prints the final migration report. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from .transformer import Match + + +@dataclass +class FileResult: + path: str + matches: list[Match] = field(default_factory=list) + warnings: list[Match] = field(default_factory=list) + skipped: bool = False + skip_reason: str = "" + + @property + def changed(self) -> bool: + return bool(self.matches) + + @property + def substitution_count(self) -> int: + return len(self.matches) + + +class Reporter: + def __init__(self) -> None: + self._results: list[FileResult] = [] + + def add(self, result: FileResult) -> None: + self._results.append(result) + + def print_report(self, dry_run: bool = False) -> None: + changed = [r for r in self._results if r.changed] + skipped = [r for r in self._results if r.skipped] + warnings_all = [r for r in self._results if r.warnings] + total_subs = sum(r.substitution_count for r in changed) + total_scanned = len(self._results) + + action = "Would change" if dry_run else "Changed" + + print() + print("splunk-ao-migrate — Migration Report") + print("=" * 45) + print(f"Files scanned: {total_scanned}") + print(f"Files {'would change' if dry_run else 'changed'}:{' ' * (4 if dry_run else 9)}{len(changed)}") + print(f"Files skipped: {len(skipped)}") + print(f"Substitutions: {total_subs}") + + if changed: + print(f"\n{action} files:") + for r in changed: + rel = _rel(r.path) + print(f" {rel:<55} {r.substitution_count} substitution(s)") + + if skipped: + print("\nSkipped files:") + for r in skipped: + print(f" {_rel(r.path)} — {r.skip_reason}") + + if warnings_all: + print("\nWarnings (manual review required):") + for r in warnings_all: + for w in r.warnings: + print(f" {_rel(r.path)}:{w.line} — {w.rule_description}") + + print() + print("Next steps:") + if dry_run: + print(" 1. Re-run without --dry-run to apply changes") + print(" 2. See the full migration guide: splunk-ao-migration-tool/splunk_ao_migrate/README.md") + else: + print(" 1. Review the diff: git diff") + print(' 2. Install splunk-ao:') + print(' pip install "splunk-ao @ git+https://github.com/splunk/splunk-ao-python.git"') + print(" 3. Upgrade Python to >= 3.11 if not already done") + print(" Also ensure requires-python = \">=3.11\" in pyproject.toml (auto-updated by this tool)") + print(" 4. See the full migration guide: splunk-ao-migration-tool/splunk_ao_migrate/README.md") + print() + + +def _rel(path: str) -> str: + try: + return os.path.relpath(path) + except ValueError: + return path diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/rules.py b/splunk-ao-migration-tool/splunk_ao_migrate/rules.py new file mode 100644 index 00000000..e7506081 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/rules.py @@ -0,0 +1,474 @@ +""" +All substitution rules for the galileo → splunk-ao migration. + +Rules are grouped and ordered deliberately: + 1. Import rewrites (must run first — change module paths) + 2. Class / symbol renames (longest names first to avoid partial matches) + 3. Keyword argument renames + 4. Env-var string literals + 5. HTTP header string literals + 6. Configuration attribute renames + +Warning rules are never applied; they only trigger a report entry. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Rule: + pattern: str + replacement: str + description: str + is_warning: bool = False + + +# --------------------------------------------------------------------------- +# 1. Import rewrites +# --------------------------------------------------------------------------- +# galileo.metric → splunk_ao.evaluator (must come before generic galileo.X rule) +IMPORT_RULES: list[Rule] = [ + Rule( + pattern=r"\bgalileo\.metric\b", + replacement="splunk_ao.evaluator", + description="galileo.metric → splunk_ao.evaluator", + ), + Rule( + pattern=r"\bgalileo_adk\b", + replacement="splunk_ao_adk", + description="galileo_adk → splunk_ao_adk", + ), + Rule( + pattern=r"\bgalileo_a2a\b", + replacement="splunk_ao_a2a", + description="galileo_a2a → splunk_ao_a2a", + ), + # Package name as a hyphenated string literal (e.g. "galileo-a2a", pip install galileo-a2a). + # Must come before the generic galileo rule which would produce "splunk_ao-a2a" (underscore). + Rule( + pattern=r"\bgalileo-a2a\b", + replacement="splunk-ao-a2a", + description="galileo-a2a → splunk-ao-a2a (package name in string literals and prose)", + ), + Rule( + # Exclude domain names: skip when followed by a short TLD (.ai, .com, .io, .org etc. — 2-3 chars). + # Longer suffixes like .otel, .metric, .python are Python module paths and must be matched. + # Full URL skipping is handled in transformer.py._sub_outside_urls. + pattern=r"\bgalileo(?!\.[a-z]{2,3}\b)\b", + replacement="splunk_ao", + description="galileo → splunk_ao (imports and module refs)", + ), +] + +# --------------------------------------------------------------------------- +# 2. Class / symbol renames (ordered longest-first within each group) +# --------------------------------------------------------------------------- +SYMBOL_RULES: list[Rule] = [ + # --- Handlers & middleware --- + Rule("GalileoAsyncBaseHandler", "SplunkAOAsyncBaseHandler", "GalileoAsyncBaseHandler → SplunkAOAsyncBaseHandler"), + Rule("GalileoAsyncCallback", "SplunkAOAsyncCallback", "GalileoAsyncCallback → SplunkAOAsyncCallback"), + Rule("GalileoAgentControlBridge", "SplunkAOAgentControlBridge", "GalileoAgentControlBridge → SplunkAOAgentControlBridge"), + Rule("GalileoTracingProcessor", "SplunkAOTracingProcessor", "GalileoTracingProcessor → SplunkAOTracingProcessor"), + Rule("GalileoLoggerSingleton", "SplunkAOLoggerSingleton", "GalileoLoggerSingleton → SplunkAOLoggerSingleton"), + Rule("GalileoLoggerException", "SplunkAOLoggerException", "GalileoLoggerException → SplunkAOLoggerException"), + Rule("GalileoOTLPExporter", "SplunkAOOTLPExporter", "GalileoOTLPExporter → SplunkAOOTLPExporter"), + Rule("GalileoSpanProcessor", "SplunkAOSpanProcessor", "GalileoSpanProcessor → SplunkAOSpanProcessor"), + Rule("add_galileo_span_processor", "add_splunk_ao_span_processor", "add_galileo_span_processor → add_splunk_ao_span_processor"), + Rule("start_galileo_span", "start_splunk_ao_span", "start_galileo_span → start_splunk_ao_span"), + Rule("GalileoPythonConfig", "SplunkAOConfig", "GalileoPythonConfig → SplunkAOConfig"), + Rule("GalileoMiddleware", "SplunkAOMiddleware", "GalileoMiddleware → SplunkAOMiddleware"), + Rule("GalileoDecorator", "SplunkAODecorator", "GalileoDecorator → SplunkAODecorator"), + Rule("GalileoCallback", "SplunkAOCallback", "GalileoCallback → SplunkAOCallback"), + Rule("GalileoFutureError", "SplunkAOFutureError", "GalileoFutureError → SplunkAOFutureError"), + Rule("GalileoCustomSpan", "SplunkAOCustomSpan", "GalileoCustomSpan → SplunkAOCustomSpan"), + Rule("GalileoBaseHandler", "SplunkAOBaseHandler", "GalileoBaseHandler → SplunkAOBaseHandler"), + Rule("GalileoAPIError", "SplunkAOAPIError", "GalileoAPIError → SplunkAOAPIError"), + Rule("GalileoLogger", "SplunkAOLogger", "GalileoLogger → SplunkAOLogger"), + # --- CrewAI handler --- + Rule("GalileoEventListener", "CrewAIEventListener", "GalileoEventListener → CrewAIEventListener"), + # --- ADK --- + Rule("GalileoObserver", "SplunkAOObserver", "GalileoObserver → SplunkAOObserver"), + Rule("GalileoADKCallback", "SplunkAOADKCallback", "GalileoADKCallback → SplunkAOADKCallback"), + Rule("GalileoADKPlugin", "SplunkAOADKPlugin", "GalileoADKPlugin → SplunkAOADKPlugin"), + Rule("galileo_retriever", "splunk_ao_retriever", "galileo_retriever → splunk_ao_retriever"), + # --- Metrics / evaluators (GalileoScorers before GalileoMetrics) --- + Rule("GalileoScorers", "SplunkAOEvaluators", "GalileoScorers → SplunkAOEvaluators"), + Rule("GalileoMetrics", "SplunkAOEvaluators", "GalileoMetrics → SplunkAOEvaluators"), + Rule("GalileoMetric", "SplunkAOEvaluator", "GalileoMetric → SplunkAOEvaluator"), + # SplunkAOMetric* renames (doc: SplunkAOMetric → SplunkAOEvaluator, SplunkAOMetrics → SplunkAOEvaluators) + Rule("SplunkAOMetrics", "SplunkAOEvaluators", "SplunkAOMetrics → SplunkAOEvaluators"), + Rule("SplunkAOMetric", "SplunkAOEvaluator", "SplunkAOMetric → SplunkAOEvaluator"), + # --- Context / config --- + Rule("galileo_context", "splunk_ao_context", "galileo_context → splunk_ao_context"), + Rule("convert_to_galileo_message", "convert_to_splunk_ao_message", "convert_to_galileo_message → convert_to_splunk_ao_message"), + # --- Domain: Metrics → Evaluators (bare names, word-boundary guarded) --- + # MetricSpec and LocalMetricConfig are NOT renamed — the repo keeps them as live names in splunk-ao. + # The rename proposal (EvaluatorSpec, LocalEvaluatorConfig) was NOT implemented in the final codebase. + Rule(r"\bBuiltInMetrics\b", "BuiltInEvaluators", "BuiltInMetrics → BuiltInEvaluators"), + Rule(r"\bLocalMetric\b", "LocalEvaluator", "LocalMetric → LocalEvaluator"), + Rule(r"\bCodeMetric\b", "CodeEvaluator", "CodeMetric → CodeEvaluator"), + Rule(r"\bLlmMetric\b", "LlmEvaluator", "LlmMetric → LlmEvaluator"), + Rule(r"\bMetrics\b", "Evaluators", "Metrics → Evaluators"), + Rule(r"\bMetric\b", "Evaluator", "Metric → Evaluator"), + # --- Domain: LogStream → AgentStream (bare names) --- + Rule(r"\bLogStreams\b", "AgentStreams", "LogStreams → AgentStreams"), + Rule(r"\bLogStream\b", "AgentStream", "LogStream → AgentStream"), + # --- Methods / functions: LogStream → AgentStream --- + Rule(r"\bcreate_log_stream\b", "create_agent_stream", "create_log_stream → create_agent_stream"), + Rule(r"\blist_log_streams\b", "list_agent_streams", "list_log_streams → list_agent_streams"), + Rule(r"\bget_log_stream\b", "get_agent_stream", "get_log_stream → get_agent_stream"), + Rule(r"\.logstreams\b", ".agent_streams", ".logstreams → .agent_streams"), + # --- Parameter / variable name: log_stream (bare identifier, not as a kwarg) --- + # Catches parameter declarations like `log_stream: str | None = None` and local + # variables like `effective_log_stream = ...` which the KWARG_RULES miss because + # the pattern `log_stream\s*=` requires `=` immediately after and skips `:` type annotations. + # log_streams (plural) is already covered by list_log_streams above; this handles singular. + Rule(r"\blog_stream\b", "agent_stream", "log_stream identifier → agent_stream"), + # --- Methods / functions: Metrics → Evaluators --- + Rule(r"\benable_metrics\b", "enable_evaluators", "enable_metrics → enable_evaluators"), + # NOTE: get_metrics() and set_metrics() are NOT renamed — they remain as live method names + # on the AgentStream object. Only the module-level get_evaluators() function is the new API. + Rule(r"\bcreate_custom_llm_metric\b", "create_custom_llm_evaluator", "create_custom_llm_metric → create_custom_llm_evaluator"), + Rule(r"\bdelete_metric\b", "delete_evaluator", "delete_metric → delete_evaluator"), + # --- Configuration attribute --- + Rule(r"\bgalileo_api_key\b", "splunk_ao_api_key", "galileo_api_key → splunk_ao_api_key"), + # Config file renames + # galileo-python-config.json must come before galileo-config.json to avoid a partial match + Rule("galileo-python-config.json", "splunk-ao-config.json", "galileo-python-config.json → splunk-ao-config.json"), + Rule("galileo-config.json", "splunk-ao-config.json", "galileo-config.json → splunk-ao-config.json"), + # --- OTel interop observe-key constant (splunk-ao-a2a package) --- + # GALILEO_OBSERVE_KEY is the Python constant *name* defined in splunk-ao-a2a/_constants.py. + # It should be renamed to SPLUNK_AO_OBSERVE_KEY. This is distinct from the string *value* + # "galileo_observe" (the A2A metadata key) which must stay unchanged for wire compatibility. + Rule(r"\bGALILEO_OBSERVE_KEY\b", "SPLUNK_AO_OBSERVE_KEY", "GALILEO_OBSERVE_KEY → SPLUNK_AO_OBSERVE_KEY"), + # --- galileo embedded inside an identifier, including attribute access patterns --- + # Handles: create_galileo_session, func._galileo_is_retriever, self._handler._galileo_logger, + # and docstring references like "sets _galileo_is_retriever on func". + # No lookbehind — matches _galileo_ after any non-word char (dot, space, quote, start-of-line) + # as well as mid-word (e.g. create_galileo_session where 'e' precedes '_'). + # Must come before the galileo_ prefix rule to avoid a partial match. + Rule(r"_galileo_", "_splunk_ao_", "_galileo_ in identifier or attribute → _splunk_ao_"), + # --- galileo at the END of a Python identifier after an underscore (e.g. _execute_without_galileo) --- + # \b fires between the final 'o' and a non-word char; (?<=_) ensures the preceding char is '_'. + Rule(r"(?<=_)galileo\b", "splunk_ao", "_galileo at end of identifier → _splunk_ao"), + # --- Generic galileo_ prefix on any Python identifier not already matched above --- + # Excludes galileo_core: galileo_core is a third-party dependency (not the galileo SDK) + # and must NOT be renamed. See WARNING_RULES for a galileo_core usage notice. + Rule(r"\bgalileo(?!_core)_", "splunk_ao_", "galileo_* identifier → splunk_ao_* (excludes galileo_core)"), +] + +# --------------------------------------------------------------------------- +# 3. Keyword argument renames +# --------------------------------------------------------------------------- +KWARG_RULES: list[Rule] = [ + # log_stream_name= must come before log_stream= to avoid a partial match + Rule( + pattern=r"\blog_stream_name\s*=", + replacement="agent_stream_name=", + description="log_stream_name= kwarg → agent_stream_name=", + ), + Rule( + pattern=r"\blog_stream\s*=", + replacement="agent_stream=", + description="log_stream= kwarg → agent_stream=", + ), + # logstream= (no underscore) variant used in some SDK versions. + # NOTE: applied only to Python files (PYTHON_RULES), not doc files (DOC_PROSE_RULES), + # to avoid rewriting logstream= inside string values like TRACELOOP_HEADERS="...". + Rule( + pattern=r"\blogstream\s*=", + replacement="agentstream=", + description="logstream= kwarg → agentstream=", + ), +] + +# --------------------------------------------------------------------------- +# 4. Environment variable string literals +# Matches the bare name inside any quote style, also in .env files. +# LOG_STREAM must come before the shorter PROJECT / API_KEY etc. +# --------------------------------------------------------------------------- +ENV_VAR_RULES: list[Rule] = [ + Rule("GALILEO_LOG_STREAM_ID", "SPLUNK_AO_AGENT_STREAM_ID", "GALILEO_LOG_STREAM_ID → SPLUNK_AO_AGENT_STREAM_ID"), + # GALILEO_LOGSTREAM (no underscore) must come before GALILEO_LOG_STREAM to avoid a partial match + Rule("GALILEO_LOGSTREAM", "SPLUNK_AO_AGENT_STREAM", "GALILEO_LOGSTREAM → SPLUNK_AO_AGENT_STREAM"), + Rule("GALILEO_LOG_STREAM", "SPLUNK_AO_AGENT_STREAM", "GALILEO_LOG_STREAM → SPLUNK_AO_AGENT_STREAM"), + Rule("GALILEO_INGEST_BETA_DISABLED", "SPLUNK_AO_INGEST_BETA_DISABLED", "GALILEO_INGEST_BETA_DISABLED → SPLUNK_AO_INGEST_BETA_DISABLED"), + Rule("GALILEO_LOGGING_DISABLED", "SPLUNK_AO_LOGGING_DISABLED", "GALILEO_LOGGING_DISABLED → SPLUNK_AO_LOGGING_DISABLED"), + Rule("GALILEO_DEFAULT_SCORER_JUDGES", "SPLUNK_AO_DEFAULT_SCORER_JUDGES", "GALILEO_DEFAULT_SCORER_JUDGES → SPLUNK_AO_DEFAULT_SCORER_JUDGES"), + Rule("GALILEO_DEFAULT_SCORER_MODEL", "SPLUNK_AO_DEFAULT_SCORER_MODEL", "GALILEO_DEFAULT_SCORER_MODEL → SPLUNK_AO_DEFAULT_SCORER_MODEL"), + Rule("GALILEO_CODE_VALIDATION_", "SPLUNK_AO_CODE_VALIDATION_", "GALILEO_CODE_VALIDATION_* → SPLUNK_AO_CODE_VALIDATION_*"), + Rule("GALILEO_CONSOLE_URL", "SPLUNK_AO_CONSOLE_URL", "GALILEO_CONSOLE_URL → SPLUNK_AO_CONSOLE_URL"), + Rule("GALILEO_SSO_ID_TOKEN", "SPLUNK_AO_SSO_ID_TOKEN", "GALILEO_SSO_ID_TOKEN → SPLUNK_AO_SSO_ID_TOKEN"), + Rule("GALILEO_SSO_PROVIDER", "SPLUNK_AO_SSO_PROVIDER", "GALILEO_SSO_PROVIDER → SPLUNK_AO_SSO_PROVIDER"), + Rule("GALILEO_PROJECT_ID", "SPLUNK_AO_PROJECT_ID", "GALILEO_PROJECT_ID → SPLUNK_AO_PROJECT_ID"), + Rule("GALILEO_JWT_TOKEN", "SPLUNK_AO_JWT_TOKEN", "GALILEO_JWT_TOKEN → SPLUNK_AO_JWT_TOKEN"), + Rule("GALILEO_API_ENDPOINT", "SPLUNK_AO_API_ENDPOINT", "GALILEO_API_ENDPOINT → SPLUNK_AO_API_ENDPOINT"), + Rule("GALILEO_API_KEY", "SPLUNK_AO_API_KEY", "GALILEO_API_KEY → SPLUNK_AO_API_KEY"), + Rule("GALILEO_API_URL", "SPLUNK_AO_API_URL", "GALILEO_API_URL → SPLUNK_AO_API_URL"), + Rule("GALILEO_USERNAME", "SPLUNK_AO_USERNAME", "GALILEO_USERNAME → SPLUNK_AO_USERNAME"), + Rule("GALILEO_PASSWORD", "SPLUNK_AO_PASSWORD", "GALILEO_PASSWORD → SPLUNK_AO_PASSWORD"), + Rule("GALILEO_PROJECT", "SPLUNK_AO_PROJECT", "GALILEO_PROJECT → SPLUNK_AO_PROJECT"), + Rule("GALILEO_LOG_LEVEL", "SPLUNK_AO_LOG_LEVEL", "GALILEO_LOG_LEVEL → SPLUNK_AO_LOG_LEVEL"), + Rule("GALILEO_MODE", "SPLUNK_AO_MODE", "GALILEO_MODE → SPLUNK_AO_MODE"), + Rule("GALILEO_HOME_DIR", "SPLUNK_AO_HOME_DIR", "GALILEO_HOME_DIR → SPLUNK_AO_HOME_DIR"), +] + +# --------------------------------------------------------------------------- +# 5. HTTP tracing header string literals +# --------------------------------------------------------------------------- +HEADER_RULES: list[Rule] = [ + Rule("X-Galileo-Trace-ID", "Splunk-AO-Trace-ID", "X-Galileo-Trace-ID → Splunk-AO-Trace-ID"), + Rule("X-Galileo-Parent-ID", "Splunk-AO-Parent-ID", "X-Galileo-Parent-ID → Splunk-AO-Parent-ID"), + # API key header — must come before BRAND_RULES so "Galileo-API-Key" → "Splunk-AO-API-Key" + # (hyphenated) rather than "Splunk AO-API-Key" (with space) which BRAND_RULES would produce. + Rule("Galileo-API-Key", "Splunk-AO-API-Key", "Galileo-API-Key → Splunk-AO-API-Key"), +] + +# --------------------------------------------------------------------------- +# 6. Documentation URL rewrites +# Must run before BRAND_RULES so full URLs are rewritten as atomic units +# rather than having their path fragments partially matched by symbol rules. +# The transformer's _sub_outside_urls guard does NOT apply here — these rules +# match the full URL and replace it with another URL, so they are applied via +# a separate pass that operates directly on URLs. +# --------------------------------------------------------------------------- +DOC_URL_RULES: list[Rule] = [ + Rule( + pattern=r"https://docs\.galileo\.ai/", + replacement="https://agent-observability-docs.splunk.com/", + description="docs.galileo.ai → agent-observability-docs.splunk.com", + ), + Rule( + pattern=r"/add-galileo-to-crewai/add-galileo-to-crewai\b", + replacement="/add-splunk-ao-to-crewai/add-splunk-ao-to-crewai", + description="add-galileo-to-crewai URL path → add-splunk-ao-to-crewai", + ), + # Filename references in docs: -galileo.md → -splunk-ao.md + # The generic galileo import rule excludes galileo.md (treats .md as a TLD), + # so this handles the case explicitly. + Rule( + pattern=r"-galileo\.md\b", + replacement="-splunk-ao.md", + description="-galileo.md filename reference → -splunk-ao.md", + ), + # Filename references in docs: -galileo.txt → -splunk-ao.txt + # (e.g. requirements-galileo.txt in code-fence install instructions) + Rule( + pattern=r"-galileo\.txt\b", + replacement="-splunk-ao.txt", + description="-galileo.txt filename reference → -splunk-ao.txt", + ), + # Specific doc page path: what-is-galileo → what-is-splunk-agent-observability + Rule( + pattern=r"/what-is-galileo\b", + replacement="/what-is-splunk-agent-observability", + description="what-is-galileo doc path → what-is-splunk-agent-observability", + ), + # Doc path restructure: getting-started/logging → concepts/logging/overview + Rule( + pattern=r"/getting-started/logging\b", + replacement="/concepts/logging/overview", + description="getting-started/logging doc path → concepts/logging/overview", + ), + # Doc path restructure: concepts/experiments/overview → sdk-api/experiments/experiments + Rule( + pattern=r"/concepts/experiments/overview\b", + replacement="/sdk-api/experiments/experiments", + description="concepts/experiments/overview → sdk-api/experiments/experiments", + ), +] + +# Placeholder string fix for doc files. +# The generic galileo → splunk_ao import rule (IMPORT_RULES) rewrites +# placeholder values like "your-galileo-api-key" → "your-splunk_ao-api-key" +# (underscore). In prose and code-fence examples the hyphenated form +# "your-splunk-ao-*" is correct. This pass corrects the over-rewrite. +# Must run AFTER PYTHON_RULES so it fixes what the import rule produced. +DOC_PLACEHOLDER_RULES: list[Rule] = [ + Rule( + pattern=r"\byour-splunk_ao-", + replacement="your-splunk-ao-", + description="your-splunk_ao-* placeholder → your-splunk-ao-* (hyphenated form in prose)", + ), + # Lowercase 'galileo' in prose (e.g. table cells, sentences) gets rewritten by the + # import rule to 'splunk_ao' (underscore) instead of 'Splunk AO' (brand name). + # Correct it here: match splunk_ao only when surrounded by non-identifier chars + # (spaces, punctuation, end-of-line) so Python identifiers like splunk_ao_context + # are not affected. + # Exclusions: + # (?=3.11; bump any lower floor in requires-python. + # Captures the opening quote so the replacement preserves the original quote style. + Rule( + pattern=r'(requires-python\s*=\s*)(["\'])>=3\.(?:8|9|10)', + replacement=r'\1\2>=3.11', + description="requires-python floor < 3.11 → >=3.11 (splunk-ao minimum)", + ), +] + BRAND_RULES diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/transformer.py b/splunk-ao-migration-tool/splunk_ao_migrate/transformer.py new file mode 100644 index 00000000..fcbd651e --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/transformer.py @@ -0,0 +1,156 @@ +""" +Applies migration rules to a string of source code. + +Each rule's pattern is treated as a plain string by default. +Patterns that begin with r"\" or contain regex metacharacters are used as-is; +plain strings are escaped before compiling so that dots, parentheses etc. in +class names are matched literally. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from .rules import Rule + + +@dataclass +class Match: + """A single substitution that was (or would be) applied.""" + line: int + original: str + replacement: str + rule_description: str + + +@dataclass +class TransformResult: + content: str + matches: list[Match] = field(default_factory=list) + warnings: list[Match] = field(default_factory=list) + + +def _compile(rule: Rule) -> re.Pattern[str]: + """ + Compile a rule pattern. + + If the pattern string starts with r'\b' or contains any regex + metacharacter (other than \b word boundaries), treat it as a raw regex. + Otherwise escape it for a literal match. + """ + raw_meta = re.compile(r"[.^$*+?{}[\]|()]|\\[bBdDwWsS]") + if raw_meta.search(rule.pattern): + return re.compile(rule.pattern) + return re.compile(re.escape(rule.pattern)) + + +# Matches a full URL token so substitutions can avoid rewriting inside URLs. +_URL_RE = re.compile(r"https?://\S+") + + +def _url_spans(line: str) -> list[tuple[int, int]]: + """Return (start, end) spans of every URL found in *line*.""" + return [(m.start(), m.end()) for m in _URL_RE.finditer(line)] + + +def _sub_outside_urls(compiled: re.Pattern[str], replacement: str, line: str) -> tuple[str, int]: + """ + Like compiled.subn(replacement, line) but skips matches that fall + inside a URL so that external links are never rewritten. + """ + url_spans = _url_spans(line) + if not url_spans: + return compiled.subn(replacement, line) + + def _in_url(start: int, end: int) -> bool: + return any(us <= start and end <= ue for us, ue in url_spans) + + out: list[str] = [] + count = 0 + prev = 0 + for m in compiled.finditer(line): + if _in_url(m.start(), m.end()): + out.append(line[prev:m.end()]) + else: + out.append(line[prev:m.start()]) + out.append(m.expand(replacement)) + count += 1 + prev = m.end() + out.append(line[prev:]) + return "".join(out), count + + +def transform_urls(content: str, rules: list[Rule]) -> TransformResult: + """ + Apply *rules* directly to *content* without skipping URL tokens. + + Use this exclusively for URL-rewrite rules (e.g. DOC_URL_RULES) where the + pattern itself *is* a URL and the URL-guard in :func:`transform` would + suppress the match. Matches and the updated content are returned in a + :class:`TransformResult`; no warning collection is performed. + """ + result = TransformResult(content=content) + lines = content.splitlines(keepends=True) + + for rule in rules: + compiled = _compile(rule) + new_lines: list[str] = [] + for lineno, line in enumerate(lines, start=1): + new_line, n = compiled.subn(rule.replacement, line) + if n: + result.matches.append(Match( + line=lineno, + original=line.rstrip("\n"), + replacement=new_line.rstrip("\n"), + rule_description=rule.description, + )) + new_lines.append(new_line) + lines = new_lines + + result.content = "".join(lines) + return result + + +def transform(content: str, rules: list[Rule], warning_rules: list[Rule] | None = None) -> TransformResult: + """ + Apply *rules* to *content* in order. + + Returns a TransformResult with the rewritten content, a list of applied + substitutions, and a list of warnings (from warning_rules). + URL tokens (https?://...) are never rewritten regardless of the rule. + """ + result = TransformResult(content=content) + lines = content.splitlines(keepends=True) + + for rule in rules: + compiled = _compile(rule) + new_lines: list[str] = [] + for lineno, line in enumerate(lines, start=1): + new_line, n = _sub_outside_urls(compiled, rule.replacement, line) + if n: + result.matches.append(Match( + line=lineno, + original=line.rstrip("\n"), + replacement=new_line.rstrip("\n"), + rule_description=rule.description, + )) + new_lines.append(new_line) + lines = new_lines + + result.content = "".join(lines) + + # Collect warnings (never modify content) + if warning_rules: + for rule in warning_rules: + compiled = _compile(rule) + for lineno, line in enumerate(content.splitlines(keepends=True), start=1): + if compiled.search(line): + result.warnings.append(Match( + line=lineno, + original=line.rstrip("\n"), + replacement="", + rule_description=rule.description, + )) + + return result From ec78796a588c6434b4f668478e845b1a90e0ed7f Mon Sep 17 00:00:00 2001 From: ridhima-splunk Date: Fri, 21 Aug 2026 10:53:52 -0700 Subject: [PATCH 2/2] removed unnecessary lines --- splunk-ao-migration-tool/splunk_ao_migrate/README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/README.md b/splunk-ao-migration-tool/splunk_ao_migrate/README.md index 500f4a40..ee3f0588 100644 --- a/splunk-ao-migration-tool/splunk_ao_migrate/README.md +++ b/splunk-ao-migration-tool/splunk_ao_migrate/README.md @@ -172,8 +172,7 @@ so `splunk-ao-migrate galileo-a2a/` will rename the directory itself to `splunk- ## Limitations - Rules are applied to raw text, so occurrences in comments and docstrings are also - rewritten. If you need comments and docstrings left untouched, use the AST-based tool - (`splunk_ao_migrate_ast`) instead. + rewritten. - URLs are not rewritten in Python, dependency, and environment files. In doc files (`.md`, `.rst`), only the known Galileo documentation URLs listed above are rewritten; all other external links are preserved as-is. @@ -186,6 +185,4 @@ so `splunk-ao-migrate galileo-a2a/` will rename the directory itself to `splunk- ## See also -- `splunk_ao_migrate_ast/` — AST-based tool (preserves comments and docstrings) -- `agent_migrate/` — AI agent tool (LLM-driven interactive migration) - `splunk-ao-migration-tool/README.md` — complete migration guide