diff --git a/AGENTS.md b/AGENTS.md index 7a6f7509..08bfc5ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,10 +95,10 @@ not the main API documentation contract. | `tests/` | pytest 9, xdist, respx, socket blocking, timeout and coverage plugins | | `splunk-ao-a2a/` | Independently released native-OTel A2A instrumentation; uv/Hatch | | `splunk-ao-adk/` | Independently released Google ADK handler integration; uv/Hatch | -| `splunk-ao-migration-tool/` | Migration documentation and examples, not a buildable package | +| `splunk-ao-migration-tool/` | uv workspace; `splunk-ao-migrate` CLI built from `splunk_ao_migrate/pyproject.toml` | | `src/splunk_ao/resources/` | OpenAPI-generated transport client; never hand-edit | -The three buildable packages have independent versions, CI, and release workflows. Only the root `poetry.lock` is +The four buildable packages have independent versions, CI, and release workflows. Only the root `poetry.lock` is tracked; A2A/ADK `uv.lock` files are ignored and may be created locally by uv. Validate every package a change touches. CI supports Python 3.11–3.14; root CI also spans Linux, macOS, and Windows. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index faac9a7a..e73c88c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,15 +6,16 @@ referenced code and tests; code remains authoritative. ## Repository Topology -The repository contains three independently built and released packages: +The repository contains four independently built and released packages: | Package | Source | Purpose | Tooling | |---|---|---|---| | `splunk-ao` | `src/splunk_ao/` | Core API, logging, integrations, CRUD, OTLP export | Poetry | | `splunk-ao-a2a` | `splunk-ao-a2a/src/splunk_ao_a2a/` | A2A client/server native OTel instrumentation | uv/Hatch | | `splunk-ao-adk` | `splunk-ao-adk/src/splunk_ao_adk/` | Google ADK handler/plugin integration | uv/Hatch | +| `splunk-ao-migrate` | `splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/` | galileo → splunk-ao migration CLI | uv/Hatch | -`splunk-ao-migration-tool/` currently contains migration documentation and examples. `docs/` contains repository +`splunk-ao-migration-tool/` is a uv workspace; `splunk_ao_migrate/pyproject.toml` defines the `splunk-ao-migrate` package. `docs/` contains repository documentation; generated API references are produced by `scripts/create_docs.py`. ## Core SDK Layers diff --git a/splunk-ao-migration-tool/pyproject.toml b/splunk-ao-migration-tool/pyproject.toml new file mode 100644 index 00000000..f2407424 --- /dev/null +++ b/splunk-ao-migration-tool/pyproject.toml @@ -0,0 +1,13 @@ +# Workspace root — not a buildable package itself. +# Run `uv sync --dev` here to get both packages and their dev deps. +# Run `uv run pytest` here to execute all tests across the workspace. + +[tool.uv.workspace] +members = [ + "splunk_ao_migrate", +] + +[tool.pytest.ini_options] +testpaths = [ + "splunk_ao_migrate/tests", +] 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..a9945347 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/README.md @@ -0,0 +1,196 @@ +# 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 +``` + +> Run from `splunk-ao-migration-tool/` (the workspace root). + +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 (from the workspace root) +python splunk_ao_migrate/src/splunk_ao_migrate/migrate.py --dry-run src/ + +# Run as a module (after uv sync) +python -m splunk_ao_migrate.migrate --dry-run src/ + +# Run with uv (from the workspace root) +uv run python splunk_ao_migrate/src/splunk_ao_migrate/migrate.py --dry-run src/ +``` + +## Package layout + +``` +splunk_ao_migrate/ ← package root (uv workspace member) + pyproject.toml ← package metadata and entry point declaration + README.md ← this file + src/ + splunk_ao_migrate/ ← Python package (src layout) + migrate.py ← CLI entry point (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 +``` + +## 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`, `CodeMetric` → `CodeEvaluator`, `BuiltInMetrics` → `BuiltInEvaluators`, `Metrics` → `Evaluators` +- **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`, `enable_metrics` → `enable_evaluators` +- **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` +- OTel endpoint path: `https://api.galileo.ai/otel/traces` → `https://api.galileo.ai/otel/v1/traces` +- `GALILEO_*` env-var string literals → `SPLUNK_AO_*` (e.g. `GALILEO_API_KEY`, `GALILEO_API_ENDPOINT`, `GALILEO_CONSOLE_URL`, `GALILEO_HOME_DIR`) +- `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"` → `"splunk_ao_observe"` (used as the A2A metadata key in `splunk-ao-a2a`) +- `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 URLs are rewritten (applied to all file types, not just docs): + - `https://api.galileo.ai/otel/traces` → `https://api.galileo.ai/otel/v1/traces` (OTel endpoint path) + - `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**: three corrections applied after the prose pass: + - `your-splunk_ao-*` → `your-splunk-ao-*` (hyphenated placeholder form) + - `splunk_ao-*` → `splunk-ao-*` (hyphenated package/dir names in prose, e.g. `splunk_ao-adk` → `splunk-ao-adk`) + - bare `splunk_ao` in prose position (not followed by `_`, `.`, `-`, or `/`) → `Splunk AO` (brand name) + +All other URLs (`https?://...`) are **not rewritten** — external links remain intact. + +### Dependency files + +- `galileo` → `splunk-ao` (galileo-specific version constraint also removed — version numbers are galileo release numbers and have no meaning for `splunk-ao`) +- `galileo[otel]` → `splunk-ao` (the `otel` extras group does not exist in `splunk-ao`; the extra and the galileo-specific version constraint are both dropped) +- `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_KEY`, `GALILEO_API_ENDPOINT`, `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, all `Metric`/`Evaluator` renames are suppressed for + the entire file (including call sites) so internal types like `Metrics` from + `galileo_core.schemas.logging.step` are not incorrectly renamed. Review the file to + confirm the suppressed renames are correct. +- **Dynamic env-var construction** (`f"GALILEO_{key}"`) — cannot be auto-rewritten; + update manually +- **Lowercase `galileo` in string literals** — may be a hostname, URL, or other non-SDK + usage (e.g. `"https://api.galileo.ai/..."`, `"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. +- The OTel endpoint URL (`https://api.galileo.ai/otel/traces` → `.../otel/v1/traces`) is + rewritten in all file types. All other 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**: when a file imports from `galileo_core`, all + `Metric`/`Evaluator` renames (`Metrics`, `LlmMetric`, etc.) are suppressed for that + entire file — including call sites on lines that do not contain `galileo_core` — + because those names are `galileo_core` internals that must not be renamed. Other + identifiers in the same file (e.g. `_ADK_ROLE_TO_GALILEO`, `_map_adk_role_to_galileo`) + are still renamed. Review the output of files that trigger the `galileo_core` warning. + +## See also + +- `splunk-ao-migration-tool/README.md` — complete migration guide 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..a599f432 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/pyproject.toml @@ -0,0 +1,30 @@ +[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] +packages = ["src/splunk_ao_migrate"] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/__init__.py b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/__init__.py new file mode 100644 index 00000000..fcd9f16f --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/src/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/src/splunk_ao_migrate/migrate.py b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/migrate.py new file mode 100644 index 00000000..de1f1742 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/migrate.py @@ -0,0 +1,457 @@ +#!/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_migrate/README.md for the complete migration guide. +""" + +from __future__ import annotations + +import argparse +import os +import py_compile +import shutil +import sys +import tempfile +from pathlib import Path + +# Allow running directly without installing. +# __file__ is /src/splunk_ao_migrate/migrate.py so parent.parent is src/. +_SRC_DIR = Path(__file__).parent.parent +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +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, + PROTECT_SYMBOLS_PATTERN, + PYTHON_RULES, + URL_RULES, + WARNING_RULES, +) +from splunk_ao_migrate.transformer import transform, transform_urls + +# --------------------------------------------------------------------------- +# File classification +# --------------------------------------------------------------------------- + +_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"}: + return "env" + if path.suffix in {".md", ".rst"}: + return "doc" + return "skip" + + +# --------------------------------------------------------------------------- +# Per-file migration +# --------------------------------------------------------------------------- + +def migrate_file(path: Path, dry_run: bool, protect_in_scope: bool = False) -> 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: + with open(path, encoding="utf-8", newline="") as fh: + content = fh.read() + 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, + # api.galileo.ai/otel/traces → api.galileo.ai/otel/v1/traces, etc.). + # transform_urls bypasses the URL guard so URL-pattern rules actually match. + url_tr = transform_urls(content, URL_RULES + 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 + # Run warning pass against original content so line numbers are accurate. + result.warnings = transform(content, [], WARNING_RULES).warnings + tr_content = placeholder_tr.content + elif kind == "python": + # Pass 1: rewrite URL strings that would be suppressed by the URL guard. + url_tr = transform_urls(content, URL_RULES) + tr = transform(url_tr.content, PYTHON_RULES, WARNING_RULES, python_mode=True) + result.matches = url_tr.matches + tr.matches + result.warnings = tr.warnings + tr_content = tr.content + # Validate that rewritten Python still compiles; skip write if broken. + if tr_content != content and not _python_compiles(path, tr_content): + result.skipped = True + result.skip_reason = "rewritten content does not compile — skipped; review manually" + return result + elif kind in ("dep", "toml"): + # When Protect is in scope, skip dep/toml files entirely so the galileo + # dependency is preserved — Protect is not available in splunk-ao. + if protect_in_scope: + result.skipped = True + result.skip_reason = "Protect feature in scope — galileo dependency must be kept; review manually" + return result + # Do not pass WARNING_RULES here: in dep/toml files every "galileo" string + # literal is the package name (already auto-renamed), never a non-SDK reference. + # The Protect pre-scan warning is emitted at the CLI level, not per-file. + url_tr = transform_urls(content, URL_RULES) + tr = transform(url_tr.content, DEP_RULES) + result.matches = url_tr.matches + tr.matches + result.warnings = [] + tr_content = tr.content + else: # env + url_tr = transform_urls(content, URL_RULES) + tr = transform(url_tr.content, ENV_FILE_RULES) + result.matches = url_tr.matches + tr.matches + result.warnings = [] + tr_content = tr.content + + # Only write when content actually changed. + if tr_content != content and not dry_run: + _write_atomic(path, tr_content) + + return result + + +def _python_compiles(path: Path, content: str) -> bool: + """Return True if *content* is valid Python, False otherwise (with a warning printed).""" + fd, tmp = tempfile.mkstemp(suffix=".py") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + py_compile.compile(tmp, doraise=True) + return True + except py_compile.PyCompileError as exc: + print( + f" ⚠ Migration of {path} would produce invalid Python: {exc}", + file=sys.stderr, + ) + return False + finally: + try: + os.unlink(tmp) + except OSError: + pass + + +def _write_atomic(path: Path, content: str) -> None: + """Write content to path atomically via a temp file, preserving permissions.""" + dir_ = path.parent + fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".splunk_ao_migrate_") + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh: + fh.write(content) + # Preserve original file permissions (mode, timestamps). + shutil.copystat(path, tmp) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Directory walk +# --------------------------------------------------------------------------- + +_SKIP_DIRS = { + ".git", "__pycache__", ".venv", "venv", "env", + "node_modules", ".tox", "dist", "build", + "site-packages", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".eggs", +} + + +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. + + For a file argument only that file is considered — the parent directory is + never walked. Nonexistent paths are skipped. + """ + candidates: list[Path] = [] + + for root in roots: + p = Path(root) + if not p.exists(): + # Nonexistent path — skip (collect_paths already warned about it) + continue + if p.is_file(): + # Only rename the file itself, never walk its parent directory. + if "galileo" in p.name.lower(): + candidates.append(p) + else: + # Directory: walk and collect all entries containing "galileo". + # Check the root dir name itself (os.walk never yields the top entry). + if "galileo" in p.name.lower(): + candidates.append(p) + for dirpath, dirnames, filenames in os.walk(p): + 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 + # Group matches by line number and show one before/after pair per line. + by_line: dict[int, tuple[str, str]] = {} + for m in file_result.matches: + if m.line not in by_line: + by_line[m.line] = (m.original, m.replacement) + else: + # Subsequent rules on the same line: keep first 'original', update 'replacement'. + by_line[m.line] = (by_line[m.line][0], m.replacement) + + print(f"\n--- {_rel(file_result.path)}") + for lineno in sorted(by_line): + original, replacement = by_line[lineno] + print(f" line {lineno:>4}: - {original}") + print(f" + {replacement}") + + +def _rel(path: str) -> str: + try: + return os.path.relpath(path) + except ValueError: + return path + + +# --------------------------------------------------------------------------- +# Protect pre-scan +# --------------------------------------------------------------------------- + +def _scan_for_protect(paths: list[Path]) -> bool: + """ + Return True if any Python file in *paths* imports a Protect symbol. + + When Protect is in scope the bare 'galileo' dependency must NOT be removed + from requirements / pyproject.toml — Protect is not available in splunk-ao. + """ + import re + pat = re.compile(PROTECT_SYMBOLS_PATTERN) + for p in paths: + if p.suffix != ".py": + continue + try: + text = p.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if pat.search(text): + return True + return False + + +# --------------------------------------------------------------------------- +# 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 + + # Pre-scan for Protect usage before touching any dependency files. + protect_in_scope = _scan_for_protect(paths) + if protect_in_scope: + print( + "⚠ Protect feature usage detected. The 'galileo' dependency will NOT be " + "removed from requirements / pyproject.toml — Protect is not available in " + "splunk-ao. Review dependency files manually.", + file=sys.stderr, + ) + + reporter = Reporter() + + for path in paths: + file_result = migrate_file(path, dry_run=args.dry_run, protect_in_scope=protect_in_scope) + 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: + has_warnings = reporter.print_report(dry_run=args.dry_run) + else: + has_warnings = reporter.has_warnings + + # Return non-zero if warnings were emitted so the tool is usable in CI. + return 1 if has_warnings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/reporter.py b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/reporter.py new file mode 100644 index 00000000..2f13913e --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/reporter.py @@ -0,0 +1,98 @@ +""" +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) + + @property + def has_warnings(self) -> bool: + return any(r.warnings for r in self._results) + + def print_report(self, dry_run: bool = False) -> bool: + """Print the migration report and return True if any warnings were emitted.""" + 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" + changed_label = "Files would change:" if dry_run else "Files changed: " + + print() + print("splunk-ao-migrate — Migration Report") + print("=" * 45) + print(f"Files scanned: {total_scanned}") + print(f"{changed_label} {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. What gets migrated and warnings reference: 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. What gets migrated and warnings reference: splunk_ao_migrate/README.md") + print() + + return bool(warnings_all) + + +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/src/splunk_ao_migrate/rules.py b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/rules.py new file mode 100644 index 00000000..681af6ba --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/rules.py @@ -0,0 +1,681 @@ +""" +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 + + +@dataclass +class Rule: + pattern: str + replacement: str + description: str + is_warning: bool = False + # Brand rules insert whitespace ("Splunk AO") and are only safe in prose + # positions (comments, docstrings). The transformer skips brand-rule matches + # that fall in code-token positions when processing Python files. + is_brand: bool = False + + +# --------------------------------------------------------------------------- +# 1. Import rewrites +# --------------------------------------------------------------------------- +# galileo.metric → splunk_ao.evaluator (must come before generic galileo.X rule) + +# Protect symbols: when any of these appear in an import the galileo dependency +# must NOT be removed from requirements/pyproject.toml. Exposed so migrate.py +# can do a pre-scan before touching dep files. +PROTECT_SYMBOLS_PATTERN = ( + r"\binvoke_protect\b|\bainvoke_protect\b|\bcreate_protect_stage\b" + r"|\bget_protect_stage\b|\bpause_protect_stage\b" + r"|\bresume_protect_stage\b|\bupdate_protect_stage\b" +) + +# --------------------------------------------------------------------------- +# 0. URL rules — applied via transform_urls() (bypasses the URL guard) +# --------------------------------------------------------------------------- +# These rules rewrite URL strings whose patterns ARE URLs and would therefore +# be suppressed by the URL guard in transform(). They are applied as a +# separate transform_urls() pass for every file type. +URL_RULES: list[Rule] = [ + Rule( + pattern=r"https://api\.galileo\.ai/otel/traces\b", + replacement="https://api.galileo.ai/otel/v1/traces", + description="api.galileo.ai/otel/traces → api.galileo.ai/otel/v1/traces", + ), +] + +IMPORT_RULES: list[Rule] = [ + # Config-file renames must come before the generic galileo rule whose + # lookahead would otherwise rewrite "galileo-python-config.json" to + # "splunk_ao-python-config.json" (underscore, wrong). + 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", + ), + 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. + # These rules are suppressed on any line containing 'galileo_core' — see transformer.py. + 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 names: log_stream and log_stream_name --- + # Catches both kwarg usage (log_stream=, log_stream_name=) and typed parameter + # declarations like `log_stream: str | None = None` or `log_stream_name: str = None`. + # log_stream_name must come before log_stream to avoid a partial match + # (log_stream_name contains log_stream as a prefix but \blog_stream\b won't fire + # inside log_stream_name because _ is a word char, so the two rules are independent; + # ordering here is for clarity and future-proofing). + # log_streams (plural) is already covered by list_log_streams above. + Rule(r"\blog_stream_name\b", "agent_stream_name", "log_stream_name identifier → agent_stream_name"), + 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"), + # --- OTel interop observe-key constant and wire value (splunk-ao-a2a package) --- + # GALILEO_OBSERVE_KEY is the Python constant *name* defined in splunk-ao-a2a/_constants.py. + # It is renamed to SPLUNK_AO_OBSERVE_KEY. + Rule(r"\bGALILEO_OBSERVE_KEY\b", "SPLUNK_AO_OBSERVE_KEY", "GALILEO_OBSERVE_KEY → SPLUNK_AO_OBSERVE_KEY"), + # The A2A wire value also changed: splunk-ao-a2a uses "splunk_ao_observe", not "galileo_observe". + # Rewrite the string literal so context propagation continues to work after migration. + Rule(r'"galileo_observe"', '"splunk_ao_observe"', '"galileo_observe" wire value → "splunk_ao_observe"'), + Rule(r"'galileo_observe'", "'splunk_ao_observe'", "'galileo_observe' wire value → 'splunk_ao_observe'"), + # --- 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 +# --------------------------------------------------------------------------- +# NOTE: log_stream= and log_stream_name= are NOT listed here. The \blog_stream\b +# rule in SYMBOL_RULES (above) already rewrites both forms — the bare identifier +# is renamed first, making any "\blog_stream\s*=" rule unreachable (it would never +# match because 'log_stream' has already become 'agent_stream' by the time KWARG_RULES +# fires). The \blog_stream\b rule also catches typed parameter declarations like +# `log_stream: str | None = None` which a kwarg-only pattern would miss. +KWARG_RULES: list[Rule] = [ + # logstream= (no underscore) variant used in some SDK versions. + # There is no corresponding \blogstream\b rule in SYMBOL_RULES, so this + # rule is the only way to catch the no-underscore form. + # 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_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_KEY", "SPLUNK_AO_API_KEY", "GALILEO_API_KEY → SPLUNK_AO_API_KEY"), + Rule("GALILEO_API_ENDPOINT", "SPLUNK_AO_API_ENDPOINT", "GALILEO_API_ENDPOINT → SPLUNK_AO_API_ENDPOINT"), + 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"), + # X-Galileo-SDK → Splunk-AO-SDK (used in src/splunk_ao/experiments.py) + Rule("X-Galileo-SDK", "Splunk-AO-SDK", "X-Galileo-SDK → Splunk-AO-SDK"), + # 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)", + ), + # Hyphenated package/dir names produced by IMPORT_RULES in doc files: + # galileo-adk → splunk_ao-adk, galileo-python → splunk_ao-python etc. + # Correct the underscore to a hyphen so the canonical hyphenated package + # name is used in prose. Must come before the bare splunk_ao prose rule. + Rule( + pattern=r"\bsplunk_ao-", + replacement="splunk-ao-", + description="splunk_ao- hyphenated prefix → splunk-ao- (package name in prose)", + ), + # Package-manager install operands: pip/uv/poetry install galileo → splunk-ao. + # These must produce the hyphenated package name, not the prose brand name. + # Must come before the splunk_ao prose rule so it fires on the post-import-rule form. + # Use a capturing group + backreference instead of variable-width lookbehind + # (Python re does not support variable-length lookbehinds). + Rule( + pattern=r"((?:pip install|uv add|poetry add)\s+)splunk_ao(\[[\w,]+\])?", + replacement=r"\1splunk-ao\2", + description="pip/uv/poetry install operand splunk_ao → splunk-ao", + ), + # Idempotency guard: if splunk-ao already appears as an install operand, leave it alone. + # (No rule needed — the pattern above only matches splunk_ao, not splunk-ao.) + # 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: + # (?=1.46.2 or >=1.32.1,<2.0.0. + # Must come before the bare galileo rule so the bracket is consumed here, not left behind. + Rule( + r"\bgalileo\[otel\](?:[><=!~^,][^\s;\"'\]]*)*", + "splunk-ao", + "galileo[otel] → splunk-ao (otel extra and galileo-specific version constraint removed)", + ), + Rule(r"\bgalileo-adk\b", "splunk-ao-adk", "galileo-adk → splunk-ao-adk"), + Rule(r"\bgalileo-a2a\b", "splunk-ao-a2a", "galileo-a2a → splunk-ao-a2a"), + # galileo_a2a Python package/module identifier → splunk_ao_a2a + Rule(r"\bgalileo_a2a\b", "splunk_ao_a2a", "galileo_a2a → splunk_ao_a2a"), + # galileo_adk Python package/module identifier → splunk_ao_adk + Rule(r"\bgalileo_adk\b", "splunk_ao_adk", "galileo_adk → splunk_ao_adk"), + # uv sources key: { galileo = ... } → { splunk-ao = ... } + # TOML bare keys permit dashes, so no quoting is needed. + Rule( + pattern=r"\bgalileo(\s*=\s*\{)", + replacement=r"splunk-ao\1", + description="sources = { galileo = ... } → sources = { splunk-ao = ... }", + ), + # pyproject.toml project name: name = "galileo-*" → name = "splunk-ao-*" + # Must come before the bare galileo rule whose lookahead excludes hyphen-prefixed names. + Rule( + pattern=r'(name\s*=\s*["\'])galileo-', + replacement=r'\1splunk-ao-', + description='name = "galileo-*" → name = "splunk-ao-*" (pyproject.toml project name)', + ), + # bare 'galileo' package name (not as part of galileo-adk/galileo_a2a etc.) + # Exclusions: + # (?=1.20.0,<2.0.0) + # have no meaning for splunk-ao and can cause resolution failures. + Rule( + r"(?<=!~^,][^\s;\"'\)]*)*\)|(?:[><=!~^,][^\s;\"'\]]*)*)", + "splunk-ao", + "galileo → splunk-ao (package name, galileo-specific version constraint removed)", + ), + # GALILEO_* env-var strings inside pyproject.toml pytest env = [...] blocks + # (same substitutions as ENV_VAR_RULES but applied in the TOML context) +] + ENV_VAR_RULES + [ + # splunk-ao requires Python >=3.11; bump any lower floor in requires-python. + # Anchored to the major.minor boundary so ">=3.10.1" becomes ">=3.11" (not ">=3.11.1"). + # Captures the opening quote so the replacement preserves the original quote style. + Rule( + pattern=r'(requires-python\s*=\s*)(["\'])>=3\.(?:8|9|10)(?:\.\d+)?', + replacement=r'\1\2>=3.11', + description="requires-python floor < 3.11 → >=3.11 (splunk-ao minimum)", + ), + # Poetry python version constraint: python = "^3.x" / ">=3.x" → >=3.11 + Rule( + pattern=r'(python\s*=\s*["\'])\^3\.(?:8|9|10)(?:\.\d+)?(["\'])', + replacement=r'\1^3.11\2', + description="Poetry python = \"^3.x\" floor < 3.11 → ^3.11", + ), + Rule( + pattern=r'(python\s*=\s*["\'])>=3\.(?:8|9|10)(?:\.\d+)?(["\'])', + replacement=r'\1>=3.11\2', + description="Poetry python = \">=3.x\" floor < 3.11 → >=3.11", + ), +] + BRAND_RULES diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/transformer.py b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/transformer.py new file mode 100644 index 00000000..8e616270 --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/src/splunk_ao_migrate/transformer.py @@ -0,0 +1,256 @@ +""" +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, PROTECT_SYMBOLS_PATTERN + + +@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+") + +# Matches the start of an inline comment — a '#' that is not inside a string. +# We use a simple heuristic: find the first '#' that is not preceded by an odd +# number of quotes on the same line. For the vast majority of real Python code +# this is accurate enough; pathological cases are caught by py_compile anyway. +_COMMENT_RE = re.compile(r"(?:\"\"\".*?\"\"\"|\'\'\'.*?\'\'\'|\"[^\"]*\"|\'[^\']*\')|#", re.DOTALL) + + +def _comment_start(line: str) -> int: + """ + Return the character index of the first unquoted '#' on *line*, or + len(line) if the line contains no comment. + """ + for m in _COMMENT_RE.finditer(line): + if m.group() == "#": + return m.start() + return len(line) + + +def _is_docstring_line(line: str) -> bool: + """Return True if the line is purely a docstring / string-literal line.""" + stripped = line.lstrip() + return stripped.startswith(('"""', "'''", '"', "'")) + +# Rules whose replacements are Metric/Evaluator variants should not fire on +# any line in a file that imports from galileo_core — those types are internal +# and must not be renamed. The suppression is file-scoped (not line-scoped) +# because call sites like `metrics=Metrics(...)` appear on lines that do not +# themselves contain 'galileo_core', yet the name was imported from it. +# This set matches the .replacement values of those rules. +_GALILEO_CORE_SKIP_REPLACEMENTS = frozenset({ + "Evaluators", "Evaluator", "BuiltInEvaluators", + "LocalEvaluator", "CodeEvaluator", "LlmEvaluator", +}) + +_GALILEO_CORE_IMPORT_RE = re.compile(r"\bgalileo_core\b") + +# Lines importing Protect symbols must not be renamed — Protect must stay +# imported from 'galileo', not 'splunk_ao'. +_PROTECT_LINE_RE = re.compile(PROTECT_SYMBOLS_PATTERN) + + +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, + brand: bool = False, + python: bool = False, +) -> tuple[str, int]: + """ + Like compiled.subn(replacement, line) but skips matches that fall + inside a URL so that external links are never rewritten. + + When *brand* is True and *python* is True, also skips matches that fall + in a code-token position — i.e. before the first unquoted '#' on the line + on a non-docstring line. This prevents "Galileo = 1" becoming + "Splunk AO = 1" (SyntaxError) while still rewriting "# Galileo logger" + and docstring lines correctly. In doc/prose mode (*python* is False), + brand renames are always applied (no code-position guard needed). + """ + url_spans = _url_spans(line) + comment_pos = _comment_start(line) if (brand and python) else len(line) + is_doc = _is_docstring_line(line) if (brand and python) else False + + def _skip(start: int, end: int) -> bool: + # Always skip matches inside URLs. + if any(us <= start and end <= ue for us, ue in url_spans): + return True + # For brand rules in Python files: skip matches before the first '#' + # on a non-docstring line to avoid SyntaxErrors in code-token positions. + if brand and python and not is_doc and start < comment_pos: + return True + return False + + if not url_spans and not brand: + return compiled.subn(replacement, line) + + out: list[str] = [] + count = 0 + prev = 0 + for m in compiled.finditer(line): + if _skip(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, + python_mode: bool = False, +) -> 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. + + *python_mode* activates code-position gating for brand rules (``is_brand=True``): + matches before the first unquoted ``#`` on a non-docstring line are skipped + to avoid producing syntactically invalid Python (e.g. ``GALILEO = 1`` must + not become ``SPLUNK AO = 1``). Leave False for doc/prose content where brand + names may appear freely anywhere on the line. + + Special behaviour: rules whose replacement is a bare Metric/Evaluator + variant (e.g. "Evaluators", "Evaluator") are suppressed for the entire + file if the file imports from galileo_core — those types are galileo_core + internals and must not be renamed. The check is file-scoped (not + line-scoped) because call sites like ``metrics=Metrics(...)`` appear on + lines that do not themselves contain 'galileo_core'. + """ + result = TransformResult(content=content) + lines = content.splitlines(keepends=True) + + # File-level flag: suppress Metric→Evaluator renames across the whole file + # if any line imports from galileo_core. + file_has_galileo_core = _GALILEO_CORE_IMPORT_RE.search(content) is not None + + for rule in rules: + # Warning-only rules in a rule list are skipped during substitution; + # they are collected separately via the warning_rules parameter. + if rule.is_warning: + continue + # Suppress Metric→Evaluator rules for the entire file when galileo_core + # is imported — those types are internal and must not be renamed. + if file_has_galileo_core and rule.replacement in _GALILEO_CORE_SKIP_REPLACEMENTS: + continue + compiled = _compile(rule) + new_lines: list[str] = [] + for lineno, line in enumerate(lines, start=1): + # Never rewrite lines that import Protect symbols — those must remain + # as `from galileo import invoke_protect …`. + if _PROTECT_LINE_RE.search(line) and "galileo" in line: + new_lines.append(line) + continue + new_line, n = _sub_outside_urls( + compiled, rule.replacement, line, brand=rule.is_brand, python=python_mode + ) + 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 diff --git a/splunk-ao-migration-tool/splunk_ao_migrate/tests/test_rules.py b/splunk-ao-migration-tool/splunk_ao_migrate/tests/test_rules.py new file mode 100644 index 00000000..a353be5f --- /dev/null +++ b/splunk-ao-migration-tool/splunk_ao_migrate/tests/test_rules.py @@ -0,0 +1,711 @@ +""" +Table-driven regression tests for splunk_ao_migrate rules and transformer. + +Each test group covers one rule area and is structured as: + # Given: source content + # When: transform is applied + # Then: expected output / warnings + +Run with: uv run pytest +""" + +from __future__ import annotations + +import pathlib +import py_compile +import tempfile +import os + +import pytest + +from splunk_ao_migrate.rules import ( + DEP_RULES, + DOC_PLACEHOLDER_RULES, + DOC_PROSE_RULES, + DOC_URL_RULES, + ENV_FILE_RULES, + PYTHON_RULES, + URL_RULES, + WARNING_RULES, +) +from splunk_ao_migrate.transformer import transform, transform_urls +from splunk_ao_migrate.migrate import collect_path_renames, migrate_file + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def py(src: str) -> str: + """Apply URL_RULES then PYTHON_RULES + WARNING_RULES to src and return the rewritten content.""" + url_tr = transform_urls(src, URL_RULES) + return transform(url_tr.content, PYTHON_RULES, WARNING_RULES, python_mode=True).content + + +def py_warnings(src: str) -> list[str]: + """Return the warning descriptions produced for src.""" + url_tr = transform_urls(src, URL_RULES) + return [w.rule_description for w in transform(url_tr.content, PYTHON_RULES, WARNING_RULES, python_mode=True).warnings] + + +def doc(src: str) -> str: + """Apply the three-pass doc pipeline to src and return rewritten content.""" + url_tr = transform_urls(src, URL_RULES + DOC_URL_RULES) + prose_tr = transform(url_tr.content, DOC_PROSE_RULES, WARNING_RULES) + ph_tr = transform_urls(prose_tr.content, DOC_PLACEHOLDER_RULES) + return ph_tr.content + + +def dep(src: str) -> str: + """Apply URL_RULES then DEP_RULES to src and return rewritten content.""" + url_tr = transform_urls(src, URL_RULES) + return transform(url_tr.content, DEP_RULES).content + + +def dep_warnings(src: str) -> list[str]: + """Return warning descriptions for dep/toml content (no WARNING_RULES on this branch).""" + return [w.rule_description for w in transform(src, DEP_RULES).warnings] + + +def compiles(src: str) -> bool: + """Return True if src is valid Python.""" + fd, tmp = tempfile.mkstemp(suffix=".py") + try: + os.write(fd, src.encode()) + os.close(fd) + py_compile.compile(tmp, doraise=True) + return True + except py_compile.PyCompileError: + return False + finally: + try: + os.unlink(tmp) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# 1. Import rules +# --------------------------------------------------------------------------- + +class TestImportRules: + def test_generic_galileo_module(self): + # Given: a galileo module import + # When: transformed + # Then: module renamed to splunk_ao + assert py("import galileo\n") == "import splunk_ao\n" + + def test_from_galileo_import(self): + assert "from splunk_ao import" in py("from galileo import galileo_context\n") + + def test_galileo_metric_module(self): + # galileo.metric must become splunk_ao.evaluator, not splunk_ao.metric + assert "splunk_ao.evaluator" in py("from galileo.metric import LlmMetric\n") + assert "splunk_ao.metric" not in py("from galileo.metric import LlmMetric\n") + + def test_galileo_a2a_hyphenated_literal(self): + # "galileo-a2a" in a string must become "splunk-ao-a2a" (hyphenated) + assert '"splunk-ao-a2a"' in py('pkg = "galileo-a2a"\n') + + def test_galileo_adk_identifier(self): + assert "splunk_ao_adk" in py("from galileo_adk import plugin\n") + + def test_galileo_a2a_identifier(self): + assert "splunk_ao_a2a" in py("from galileo_a2a import client\n") + + def test_config_file_rename_python_config(self): + # galileo-python-config.json must become splunk-ao-config.json + # (not splunk_ao-python-config.json which the generic rule would produce) + result = py('path = "galileo-python-config.json"\n') + assert "splunk-ao-config.json" in result + assert "splunk_ao" not in result + + def test_config_file_rename_galileo_config(self): + result = py('path = "galileo-config.json"\n') + assert "splunk-ao-config.json" in result + + def test_otel_traces_endpoint_path_updated(self): + # api.galileo.ai/otel/traces → api.galileo.ai/otel/v1/traces + result = py('url = "https://api.galileo.ai/otel/traces"\n') + assert "https://api.galileo.ai/otel/v1/traces" in result + + def test_otel_v1_traces_endpoint_unchanged(self): + # already correct path must not be double-rewritten + result = py('url = "https://api.galileo.ai/otel/v1/traces"\n') + assert result == 'url = "https://api.galileo.ai/otel/v1/traces"\n' + + def test_domain_name_not_renamed(self): + # galileo.ai as a domain (in a URL) must not be renamed + result = py('url = "https://galileo.ai/login"\n') + assert "galileo.ai" in result + + def test_protect_import_line_left_intact(self): + # Given: a line importing a Protect symbol + # When: transformed + # Then: the entire line is left unchanged and a warning is emitted + src = "from galileo import invoke_protect\n" + assert "galileo import invoke_protect" in py(src) + assert any("Protect" in w for w in py_warnings(src)) + + def test_protect_mixed_with_normal_import_on_same_line(self): + # A line with both a Protect symbol and galileo_context: whole line left intact + src = "from galileo import invoke_protect, galileo_context\n" + assert "galileo import invoke_protect" in py(src) + + def test_normal_import_on_separate_line_still_renamed(self): + src = "from galileo import galileo_context\nfrom galileo import invoke_protect\n" + result = py(src) + assert "from splunk_ao import splunk_ao_context" in result + assert "galileo import invoke_protect" in result + + +# --------------------------------------------------------------------------- +# 2. Symbol rules +# --------------------------------------------------------------------------- + +class TestSymbolRules: + @pytest.mark.parametrize("old,new", [ + ("GalileoLogger", "SplunkAOLogger"), + ("GalileoCallback", "SplunkAOCallback"), + ("GalileoAsyncCallback", "SplunkAOAsyncCallback"), + ("GalileoSpanProcessor", "SplunkAOSpanProcessor"), + ("add_galileo_span_processor", "add_splunk_ao_span_processor"), + ("start_galileo_span", "start_splunk_ao_span"), + ("GalileoPythonConfig", "SplunkAOConfig"), + ("GalileoMiddleware", "SplunkAOMiddleware"), + ("GalileoADKPlugin", "SplunkAOADKPlugin"), + ("GalileoEventListener", "CrewAIEventListener"), + ("GalileoMetric", "SplunkAOEvaluator"), + ("GalileoMetrics", "SplunkAOEvaluators"), + ("galileo_context", "splunk_ao_context"), + ("LogStream", "AgentStream"), + ("LogStreams", "AgentStreams"), + ("create_log_stream", "create_agent_stream"), + ("list_log_streams", "list_agent_streams"), + ("enable_metrics", "enable_evaluators"), + ("delete_metric", "delete_evaluator"), + ("create_custom_llm_metric", "create_custom_llm_evaluator"), + ("BuiltInMetrics", "BuiltInEvaluators"), + ("LlmMetric", "LlmEvaluator"), + ("LocalMetric", "LocalEvaluator"), + ("CodeMetric", "CodeEvaluator"), + ]) + def test_symbol_renamed(self, old, new): + assert new in py(f"x = {old}\n") + + def test_metric_renamed(self): + assert "Evaluator" in py("result: Metric = ...\n") + + def test_metrics_renamed(self): + assert "Evaluators" in py("scorers: Metrics = []\n") + + def test_galileo_core_metrics_not_renamed(self): + # Given: Metrics imported from galileo_core + # When: transformed + # Then: Metrics is preserved on that line (galileo_core types must not be renamed) + src = "from galileo_core.schemas.metrics import Metrics\n" + result = py(src) + assert "Metrics" in result + assert "Evaluators" not in result + + def test_galileo_core_metric_not_renamed(self): + src = "from galileo_core.schemas.metrics import Metric\n" + result = py(src) + assert "Metric" in result + assert "Evaluator" not in result + + def test_galileo_core_warning_emitted(self): + src = "from galileo_core.schemas.metrics import Metrics\n" + assert any("galileo_core" in w for w in py_warnings(src)) + + def test_galileo_core_suppression_is_file_scoped(self): + # The suppression is FILE-scoped: any Metric/Evaluator rename is suppressed + # across the entire file if the file imports from galileo_core, even on + # lines that do not themselves contain 'galileo_core'. + # This prevents `metrics=Metrics(...)` call sites (different lines from the + # import) from being incorrectly renamed to `metrics=Evaluators(...)`. + src = "from galileo_core.schemas.logging.step import Metrics\nmetrics=Metrics(duration_ns=0)\n" + result = py(src) + lines = result.splitlines() + assert "Metrics" in lines[0] # import line: galileo_core kept, Metrics not renamed + assert "Metrics" in lines[1] # call site: Metrics NOT renamed (file-scoped suppression) + assert "Evaluators" not in result + + def test_galileo_observe_key_constant_renamed(self): + assert "SPLUNK_AO_OBSERVE_KEY" in py("key = GALILEO_OBSERVE_KEY\n") + + def test_galileo_observe_wire_value_renamed(self): + # Given: the A2A wire value string + # When: transformed + # Then: wire value updated to match splunk-ao-a2a + assert '"splunk_ao_observe"' in py('k = "galileo_observe"\n') + + def test_galileo_embedded_in_identifier(self): + assert "_splunk_ao_logger" in py("self._galileo_logger\n") + + def test_log_stream_bare_identifier(self): + assert "agent_stream" in py("log_stream: str = None\n") + + def test_log_stream_name_identifier(self): + # log_stream_name is caught by the \blog_stream_name\b SYMBOL_RULE (not a kwarg rule) + assert "agent_stream_name=" in py("foo(log_stream_name=x)\n") + + def test_log_stream_name_typed_param(self): + # \blog_stream_name\b catches typed parameter declarations too + assert "agent_stream_name: str" in py("def fn(log_stream_name: str = ''):\n") + + def test_log_stream_kwarg_via_symbol_rule(self): + # log_stream= is caught by the \blog_stream\b SYMBOL_RULE (not a kwarg rule) + assert "agent_stream=" in py("foo(log_stream=x)\n") + + def test_logstreams_attribute(self): + assert ".agent_streams" in py("p.logstreams\n") + + +# --------------------------------------------------------------------------- +# 3. Kwarg rules +# --------------------------------------------------------------------------- + +class TestKwargRules: + def test_logstream_kwarg_in_python(self): + # logstream= (no underscore) has no SYMBOL_RULE counterpart — caught by KWARG_RULES + assert "agentstream=" in py("foo(logstream=x)\n") + + +# --------------------------------------------------------------------------- +# 4. Env-var rules +# --------------------------------------------------------------------------- + +class TestEnvVarRules: + @pytest.mark.parametrize("old,new", [ + ("GALILEO_API_KEY", "SPLUNK_AO_API_KEY"), + ("GALILEO_API_ENDPOINT", "SPLUNK_AO_API_ENDPOINT"), + ("GALILEO_PROJECT", "SPLUNK_AO_PROJECT"), + ("GALILEO_LOG_STREAM", "SPLUNK_AO_AGENT_STREAM"), + ("GALILEO_LOGSTREAM", "SPLUNK_AO_AGENT_STREAM"), + ("GALILEO_LOG_STREAM_ID", "SPLUNK_AO_AGENT_STREAM_ID"), + ("GALILEO_CONSOLE_URL", "SPLUNK_AO_CONSOLE_URL"), + ("GALILEO_API_URL", "SPLUNK_AO_API_URL"), + ("GALILEO_HOME_DIR", "SPLUNK_AO_HOME_DIR"), + ]) + def test_env_var_renamed(self, old, new): + assert new in py(f'os.environ.get("{old}")\n') + + +# --------------------------------------------------------------------------- +# 5. Header rules +# --------------------------------------------------------------------------- + +class TestHeaderRules: + @pytest.mark.parametrize("old,new", [ + ("X-Galileo-Trace-ID", "Splunk-AO-Trace-ID"), + ("X-Galileo-Parent-ID", "Splunk-AO-Parent-ID"), + ("X-Galileo-SDK", "Splunk-AO-SDK"), + ("Galileo-API-Key", "Splunk-AO-API-Key"), + ]) + def test_header_renamed(self, old, new): + assert new in py(f'headers = {{"{old}": value}}\n') + + def test_unknown_x_galileo_header_not_mangled(self): + # Unknown X-Galileo-* headers must not produce "X-Splunk AO-*" (space = invalid) + result = py('h = {"X-Galileo-Custom": x}\n') + assert "X-Splunk AO" not in result + assert "Splunk AO" not in result + + +# --------------------------------------------------------------------------- +# 6. Brand rules — position-aware (Python files) +# --------------------------------------------------------------------------- + +class TestBrandRulesPython: + def test_galileo_in_comment_renamed(self): + assert "# Splunk AO logger" in py("# Galileo logger\n") + + def test_galileo_all_caps_in_comment_renamed(self): + assert "SPLUNK AO" in py("# GALILEO API KEY CHECK\n") + + def test_galileo_in_inline_comment_renamed(self): + result = py("x = foo() # Galileo integration\n") + assert "Splunk AO integration" in result + + def test_galileo_in_docstring_renamed(self): + assert "Splunk AO" in py('"""Initialize the Galileo logger."""\n') + + def test_galileo_in_indented_docstring_renamed(self): + assert "Splunk AO" in py(' """Initialize the Galileo logger."""\n') + + def test_galileo_code_position_not_renamed(self): + # Given: Galileo as a code token (identifier, class name, constant) + # When: transformed + # Then: not renamed — would produce SyntaxError + assert "Splunk AO" not in py("GALILEO = 1\n") + assert "Splunk AO" not in py("class Galileo:\n pass\n") + assert "Splunk AO" not in py("x = Galileo()\n") + + def test_code_position_output_still_compiles(self): + # Given: various Galileo identifiers in code positions + src = "GALILEO = 1\nclass Galileo:\n pass\nx = Galileo()\n" + result = py(src) + assert compiles(result) + + def test_inline_comment_code_part_untouched(self): + # Code before # must not be renamed; comment after # must be renamed + result = py("result = Galileo() # returns Galileo instance\n") + assert "Galileo()" in result # code token preserved + assert "Splunk AO instance" in result # comment renamed + + def test_galileo_ai_brand_in_comment(self): + assert "Splunk AO" in py("# Visit Galileo.ai for docs\n") + + +# --------------------------------------------------------------------------- +# 7. Brand rules — doc files (all positions safe) +# --------------------------------------------------------------------------- + +class TestBrandRulesDoc: + def test_galileo_in_prose_renamed(self): + assert "Splunk AO" in doc("Galileo provides observability.\n") + + def test_galileo_in_heading_renamed(self): + assert "Splunk AO" in doc("# Galileo Integration\n") + + def test_galileo_url_not_renamed(self): + result = doc("See https://docs.galileo.ai/overview for details.\n") + assert "galileo.ai" not in result # URL rewritten by DOC_URL_RULES + assert "agent-observability-docs.splunk.com" in result + + +# --------------------------------------------------------------------------- +# 8. Doc pipeline +# --------------------------------------------------------------------------- + +class TestDocPipeline: + def test_url_rewritten(self): + result = doc("See https://docs.galileo.ai/getting-started.\n") + assert "agent-observability-docs.splunk.com" in result + + def test_pip_install_operand(self): + # Given: pip install galileo in docs + # When: doc pipeline applied + # Then: pip install splunk-ao (hyphenated, not "Splunk AO") + result = doc("pip install galileo\n") + assert "pip install splunk-ao" in result + assert "Splunk AO" not in result + + def test_pip_install_with_extra(self): + result = doc("pip install galileo[langchain]\n") + assert "pip install splunk-ao[langchain]" in result + + def test_uv_add_operand(self): + result = doc("uv add galileo\n") + assert "uv add splunk-ao" in result + + def test_poetry_add_operand(self): + result = doc("poetry add galileo\n") + assert "poetry add splunk-ao" in result + + def test_idempotency_pip_install(self): + # Given: already-migrated pip install line + # When: doc pipeline applied again + # Then: output is unchanged (idempotent) + already_migrated = "pip install splunk-ao\n" + assert doc(already_migrated) == already_migrated + + def test_idempotency_prose(self): + # Given: already-migrated prose + # When: doc pipeline applied again + # Then: output is unchanged + already_migrated = "Splunk AO provides observability.\n" + assert doc(already_migrated) == already_migrated + + def test_logstream_in_env_var_string_not_renamed(self): + # logstream= inside a TRACELOOP_HEADERS value must not be renamed + src = 'TRACELOOP_HEADERS="..., logstream=default, ..."\n' + result = doc(src) + assert "logstream=default" in result + assert "agentstream=" not in result + + def test_your_galileo_placeholder_hyphenated(self): + # your-galileo-api-key placeholder must become your-splunk-ao-api-key (hyphen) + result = doc("your-galileo-api-key\n") + assert "your-splunk-ao-api-key" in result + assert "your-splunk_ao-api-key" not in result + + def test_hyphenated_package_name_in_prose_not_mangled(self): + # galileo-adk → splunk_ao-adk (by IMPORT_RULES) → splunk-ao-adk (by placeholder rule) + # Must NOT become "Splunk AO-adk" + result = doc("Contributing to galileo-adk\n") + assert "splunk-ao-adk" in result + assert "Splunk AO-adk" not in result + assert "splunk_ao-adk" not in result + + def test_hyphenated_package_python_in_prose(self): + # galileo-python → splunk-ao-python in prose + result = doc("part of the galileo-python monorepo\n") + assert "splunk-ao-python" in result + assert "Splunk AO-python" not in result + + def test_path_token_not_renamed_to_brand(self): + # src/galileo/ → src/splunk_ao/ — path token must keep underscore form, not become prose + result = doc("├── src/galileo/ ← Main SDK\n") + assert "src/splunk_ao/" in result + assert "src/Splunk AO/" not in result + + +# --------------------------------------------------------------------------- +# 9. Dep/toml rules +# --------------------------------------------------------------------------- + +class TestDepRules: + def test_galileo_package_renamed(self): + assert "splunk-ao" in dep('dependencies = ["galileo>=1.32"]\n') + + def test_galileo_version_constraint_stripped(self): + # galileo-specific version numbers have no meaning for splunk-ao + result = dep('dependencies = ["galileo>=1.20.0,<2.0.0"]\n') + assert '"splunk-ao"' in result + assert "1.20.0" not in result + assert "<2.0.0" not in result + + def test_galileo_poetry_parenthesised_version_stripped(self): + # Poetry uses parenthesised form: galileo (>=1.20.0,<2.0.0) + result = dep('dependencies = ["galileo (>=1.20.0,<2.0.0)"]\n') + assert '"splunk-ao"' in result + assert "1.20.0" not in result + assert "<2.0.0" not in result + + def test_galileo_exact_version_stripped(self): + result = dep("galileo==1.32.1\n") + assert result.strip() == "splunk-ao" + + def test_galileo_version_stripped_requirements_txt(self): + result = dep("galileo>=1.46.2\n") + assert result.strip() == "splunk-ao" + + def test_galileo_otel_extra_and_version_stripped(self): + # galileo[otel] → splunk-ao: the [otel] extra does not exist in splunk-ao, + # and the galileo-specific version constraint is meaningless for splunk-ao. + result = dep('dependencies = ["galileo[otel]>=1.32.1"]\n') + assert '"splunk-ao"' in result + assert "[otel]" not in result + assert "1.32.1" not in result + + def test_galileo_otel_range_constraint_stripped(self): + # Both bounds of a version range are dropped + result = dep('dependencies = ["galileo[otel]>=1.32.1,<2.0.0"]\n') + assert '"splunk-ao"' in result + assert "1.32.1" not in result + assert "<2.0.0" not in result + + def test_galileo_otel_in_requirements_txt(self): + # Plain requirements.txt line: version stripped too + result = dep("galileo[otel]>=1.46.2\n") + assert result.strip() == "splunk-ao" + + def test_galileo_adk_dep_renamed(self): + assert "splunk-ao-adk" in dep('dependencies = ["galileo-adk>=1.0"]\n') + + def test_galileo_a2a_dep_renamed(self): + assert "splunk-ao-a2a" in dep('dependencies = ["galileo-a2a>=1.0"]\n') + + def test_requires_python_floor_bumped(self): + assert '>=3.11"' in dep('requires-python = ">=3.10"\n') + + def test_requires_python_floor_with_patch_not_mangled(self): + # >=3.10.1 should become >=3.11, not >=3.11.1 + result = dep('requires-python = ">=3.10.1"\n') + assert ">=3.11" in result + assert "3.11.1" not in result + + def test_poetry_python_floor_bumped_caret(self): + assert "^3.11" in dep('python = "^3.10"\n') + + def test_poetry_python_floor_bumped_gte(self): + assert ">=3.11" in dep('python = ">=3.10"\n') + + def test_no_spurious_string_literal_warning(self): + # Given: galileo as a dep string in pyproject.toml + # When: dep rules applied (no WARNING_RULES on this branch) + # Then: no string-literal warning emitted + warnings = dep_warnings('dependencies = ["galileo>=1.32"]\n') + assert not any("non-SDK usage" in w for w in warnings) + + def test_uv_sources_key_renamed(self): + result = dep("galileo = {git = ...}\n") + assert "splunk-ao" in result + + +# --------------------------------------------------------------------------- +# 10. Env file rules +# --------------------------------------------------------------------------- + +class TestEnvFileRules: + def test_env_var_renamed(self): + result = transform("GALILEO_API_KEY=abc\n", ENV_FILE_RULES).content + assert "SPLUNK_AO_API_KEY=abc" in result + + def test_galileo_api_key_header_renamed(self): + result = transform('TRACELOOP_HEADERS="Galileo-API-Key=key"\n', ENV_FILE_RULES).content + assert "Splunk-AO-API-Key" in result + + def test_placeholder_value_hyphenated(self): + result = transform("SPLUNK_AO_API_KEY=your-galileo-api-key\n", ENV_FILE_RULES).content + assert "your-splunk-ao-api-key" in result + + +# --------------------------------------------------------------------------- +# 11. Warning rules +# --------------------------------------------------------------------------- + +class TestWarningRules: + def test_protect_symbol_emits_warning(self): + src = "from galileo import invoke_protect\n" + assert any("Protect" in w for w in py_warnings(src)) + + def test_galileo_core_emits_warning(self): + src = "from galileo_core.schemas.metrics import Metrics\n" + assert any("galileo_core" in w for w in py_warnings(src)) + + def test_lowercase_galileo_string_literal_emits_warning(self): + src = 'question = "what moons did galileo discover"\n' + assert any("non-SDK usage" in w for w in py_warnings(src)) + + def test_dynamic_env_var_emits_warning(self): + src = 'key = "GALILEO_" + suffix\n' + assert any("Dynamic" in w for w in py_warnings(src)) + + +# --------------------------------------------------------------------------- +# 12. collect_path_renames +# --------------------------------------------------------------------------- + +class TestCollectPathRenames: + def test_file_arg_only_renames_that_file(self, tmp_path): + # Given: a file arg plus an unrelated sibling containing "galileo" + # When: collect_path_renames called with just the file + # Then: only the target file is in the rename list, not the sibling + target = tmp_path / "galileo_helper.py" + sibling = tmp_path / "unrelated_galileo_notes.md" + target.write_text("x") + sibling.write_text("y") + + renames = collect_path_renames([str(target)]) + renamed_names = [new.name for _, new in renames] + + assert "splunk_ao_helper.py" in renamed_names + assert not any("unrelated" in n for n in renamed_names) + + def test_nonexistent_path_skipped(self, tmp_path): + # Given: a nonexistent path + # When: collect_path_renames called + # Then: no renames produced, no crash + renames = collect_path_renames([str(tmp_path / "does_not_exist")]) + assert renames == [] + + def test_directory_arg_renames_children(self, tmp_path): + # Given: a directory containing galileo-named files + # When: collect_path_renames called with the directory + # Then: all galileo-named entries are in the rename list + (tmp_path / "galileo_agent.py").write_text("x") + (tmp_path / "no_match.py").write_text("y") + + renames = collect_path_renames([str(tmp_path)]) + renamed_names = [new.name for _, new in renames] + + assert "splunk_ao_agent.py" in renamed_names + assert "no_match.py" not in renamed_names + + def test_deepest_first_ordering(self, tmp_path): + # Given: nested galileo directories + # When: collect_path_renames called + # Then: deepest paths come first (so child renames happen before parent) + nested = tmp_path / "galileo_pkg" / "galileo_sub" + nested.mkdir(parents=True) + (nested / "galileo_file.py").write_text("x") + + renames = collect_path_renames([str(tmp_path)]) + paths = [str(old) for old, _ in renames] + + # The deepest path must appear before shallower ones + file_idx = next(i for i, p in enumerate(paths) if "galileo_file" in p) + sub_idx = next(i for i, p in enumerate(paths) if p.endswith("galileo_sub")) + pkg_idx = next(i for i, p in enumerate(paths) if p.endswith("galileo_pkg")) + + assert file_idx < sub_idx < pkg_idx + + +# --------------------------------------------------------------------------- +# 13. migrate_file — protect_in_scope skips dep/toml files +# --------------------------------------------------------------------------- + +class TestMigrateFileProtect: + def test_dep_file_skipped_when_protect_in_scope(self, tmp_path): + # Given: a requirements.txt that would normally be migrated + # When: migrate_file called with protect_in_scope=True + # Then: file is skipped and galileo dependency is preserved + req = tmp_path / "requirements.txt" + req.write_text("galileo>=1.32\n") + + result = migrate_file(req, dry_run=False, protect_in_scope=True) + + assert result.skipped + assert "Protect" in result.skip_reason + assert req.read_text() == "galileo>=1.32\n" # file untouched + + def test_toml_file_skipped_when_protect_in_scope(self, tmp_path): + # Given: a pyproject.toml with galileo dependency + # When: migrate_file called with protect_in_scope=True + # Then: file is skipped and content unchanged + toml = tmp_path / "pyproject.toml" + toml.write_text('dependencies = ["galileo>=1.32"]\n') + + result = migrate_file(toml, dry_run=False, protect_in_scope=True) + + assert result.skipped + assert toml.read_text() == 'dependencies = ["galileo>=1.32"]\n' + + def test_dep_file_migrated_when_protect_not_in_scope(self, tmp_path): + # Given: a requirements.txt with galileo dependency + # When: migrate_file called with protect_in_scope=False (default) + # Then: galileo is renamed to splunk-ao + req = tmp_path / "requirements.txt" + req.write_text("galileo>=1.32\n") + + result = migrate_file(req, dry_run=False, protect_in_scope=False) + + assert not result.skipped + assert req.read_text() == "splunk-ao\n" + + def test_python_file_not_affected_by_protect_in_scope(self, tmp_path): + # Given: a Python file with galileo import + # When: migrate_file called with protect_in_scope=True + # Then: Python file is still migrated normally (protect only gates dep/toml) + py_file = tmp_path / "main.py" + py_file.write_text("from galileo import galileo_context\n") + + result = migrate_file(py_file, dry_run=False, protect_in_scope=True) + + assert not result.skipped + assert "splunk_ao_context" in py_file.read_text() + + +# --------------------------------------------------------------------------- +# 14. Python output always compiles +# --------------------------------------------------------------------------- + +class TestPythonOutputCompiles: + @pytest.mark.parametrize("src", [ + "GALILEO = 1\n", + "class Galileo:\n pass\n", + "x = Galileo()\n", + "from galileo import galileo_context\n", + "from galileo_core.schemas.metrics import Metrics\nresult: Metrics = ...\n", + "# Galileo logger\nGALILEO = 1\n", + 'TRACELOOP_HEADERS="Galileo-API-Key=key, logstream=default"\n', + ]) + def test_output_is_valid_python(self, src): + # Given: any source input + # When: PYTHON_RULES applied + # Then: output is valid Python (never produces SyntaxError) + result = py(src) + assert compiles(result), f"Output does not compile:\n{result}"