Skip to content

Commit 9f72384

Browse files
committed
pr refactor and other changes
1 parent 62aa3a3 commit 9f72384

6 files changed

Lines changed: 95 additions & 246 deletions

File tree

build_scripts/migrate_prod_memory_schema.py

Lines changed: 39 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,26 @@
99
doc/contributing/10_release_process.md) or by a CD pipeline — never by normal
1010
application startup.
1111
12+
It constructs an AzureSQLMemory with skip_schema_migration=True (bypassing the
13+
runtime guard), then explicitly calls _run_schema_migration to upgrade to head.
14+
The environment checks ensure this only runs from a release branch.
15+
1216
Safety rails:
13-
- Requires an explicit --target-revision argument (no blind "upgrade head").
1417
- Validates the environment (release branch, clean working tree, no .dev version).
15-
- Validates the target revision exists in the local migration graph.
16-
- Reports current DB revision before and after migration.
17-
- Runs schema check after upgrade to confirm models match.
1818
- Interactive confirmation when running in a terminal.
1919
- Exits non-zero on any failure.
2020
2121
Usage:
22-
python build_scripts/migrate_prod_memory_schema.py \
23-
--target-revision c3d5e7f9a1b2
22+
python build_scripts/migrate_prod_memory_schema.py
2423
25-
The script reads the production connection string from the
26-
AZURE_SQL_DB_CONNECTION_STRING environment variable (same as AzureSQLMemory).
24+
The script reads the production connection string from
25+
AZURE_SQL_DB_CONNECTION_STRING_PROD (loaded from ~/.pyrit/.env).
2726
"""
2827

29-
import argparse
30-
import os
3128
import subprocess
3229
import sys
3330

3431
import dotenv
35-
from alembic import command
36-
from alembic.script import ScriptDirectory
37-
from alembic.util.exc import AutogenerateDiffsDetected
38-
from sqlalchemy import create_engine, text
3932

4033
from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
4134

@@ -51,77 +44,17 @@
5144
_YELLOW = "\033[93m"
5245
_RESET = "\033[0m"
5346

54-
_CONNECTION_STRING_ENV = "AZURE_SQL_DB_CONNECTION_STRING_PROD"
55-
5647

5748
def _print_error(message: str) -> None:
5849
"""Print an error message in red to stderr."""
5950
print(f"{_RED}ERROR: {message}{_RESET}", file=sys.stderr)
6051

6152

62-
def _print_warning(message: str) -> None:
63-
"""Print a warning message in yellow."""
64-
print(f"{_YELLOW}WARNING: {message}{_RESET}")
65-
66-
6753
def _print_success(message: str) -> None:
6854
"""Print a success message in green."""
6955
print(f"{_GREEN}{message}{_RESET}")
7056

7157

72-
def _get_current_revision(*, engine) -> str | None:
73-
"""
74-
Read the current Alembic revision from the database.
75-
76-
Returns None if no version table exists (fresh database).
77-
"""
78-
from sqlalchemy import inspect as sa_inspect
79-
80-
from pyrit.memory.migration import PYRIT_MEMORY_ALEMBIC_VERSION_TABLE
81-
82-
inspector = sa_inspect(engine)
83-
if PYRIT_MEMORY_ALEMBIC_VERSION_TABLE not in inspector.get_table_names():
84-
return None
85-
86-
with engine.connect() as conn:
87-
result = conn.execute(text(f"SELECT version_num FROM {PYRIT_MEMORY_ALEMBIC_VERSION_TABLE}"))
88-
row = result.fetchone()
89-
return row[0] if row else None
90-
91-
92-
def _validate_revision_exists(*, target_revision: str) -> bool:
93-
"""Check that the target revision exists in the local migration script directory."""
94-
95-
from pathlib import Path
96-
97-
script_location = Path(__file__).parent.parent / "pyrit" / "memory" / "alembic"
98-
# Build a minimal config to get the ScriptDirectory
99-
from alembic.config import Config
100-
101-
config = Config()
102-
config.set_main_option("script_location", str(script_location))
103-
script_dir = ScriptDirectory.from_config(config)
104-
105-
try:
106-
script_dir.get_revision(target_revision)
107-
return True
108-
except Exception:
109-
return False
110-
111-
112-
def _get_all_revision_ids() -> list[str]:
113-
"""Get all revision IDs from the local migration graph."""
114-
from pathlib import Path
115-
116-
from alembic.config import Config
117-
118-
script_location = Path(__file__).parent.parent / "pyrit" / "memory" / "alembic"
119-
config = Config()
120-
config.set_main_option("script_location", str(script_location))
121-
script_dir = ScriptDirectory.from_config(config)
122-
return [rev.revision for rev in script_dir.walk_revisions()]
123-
124-
12558
def _check_release_environment() -> list[str]:
12659
"""
12760
Validate that the script is running in a proper release environment.
@@ -130,7 +63,6 @@ def _check_release_environment() -> list[str]:
13063
"""
13164
issues: list[str] = []
13265

133-
# Check 1: Running from a release branch (releases/vX.Y.Z)
13466
try:
13567
branch = subprocess.check_output(
13668
["git", "rev-parse", "--abbrev-ref", "HEAD"],
@@ -145,7 +77,6 @@ def _check_release_environment() -> list[str]:
14577
except (subprocess.CalledProcessError, FileNotFoundError):
14678
issues.append("Could not determine current Git branch.")
14779

148-
# Check 2: Clean working tree (no uncommitted changes to memory files)
14980
try:
15081
dirty_files = subprocess.check_output(
15182
["git", "status", "--porcelain", "--", "pyrit/memory/"],
@@ -161,7 +92,6 @@ def _check_release_environment() -> list[str]:
16192
except (subprocess.CalledProcessError, FileNotFoundError):
16293
issues.append("Could not check Git working tree status.")
16394

164-
# Check 3: Not a .dev version (should be a release version)
16595
try:
16696
from pyrit import __version__
16797

@@ -176,120 +106,21 @@ def _check_release_environment() -> list[str]:
176106
return issues
177107

178108

179-
def _run_migration(*, connection_string: str, target_revision: str) -> int:
180-
"""
181-
Execute the migration against the target database.
182-
183-
Returns 0 on success, 1 on failure.
184-
"""
185-
from pathlib import Path
186-
187-
from alembic.config import Config
188-
189-
from pyrit.memory.migration import check_schema_migrations
190-
191-
engine = create_engine(connection_string)
192-
193-
# Step 1: Report current state
194-
current_rev = _get_current_revision(engine=engine)
195-
print(f"Current database revision: {current_rev or '(none — fresh database)'}")
196-
print(f"Target revision: {target_revision}")
197-
print()
198-
199-
if current_rev == target_revision:
200-
_print_success("Database is already at the target revision. Nothing to do.")
201-
engine.dispose()
202-
return 0
203-
204-
# Step 2: Run upgrade to specific revision (not head)
205-
print("Applying migrations...")
206-
try:
207-
script_location = Path(__file__).parent.parent / "pyrit" / "memory" / "alembic"
208-
with engine.begin() as connection:
209-
config = Config()
210-
config.set_main_option("script_location", str(script_location))
211-
config.attributes["connection"] = connection
212-
213-
# Stamp unversioned schemas if needed
214-
from pyrit.memory.migration import _validate_and_stamp_unversioned_memory_schema
215-
216-
_validate_and_stamp_unversioned_memory_schema(config=config, connection=connection)
217-
command.upgrade(config, target_revision)
218-
except Exception as e:
219-
_print_error(f"Migration failed: {e}")
220-
engine.dispose()
221-
return 1
222-
223-
# Step 3: Verify new revision
224-
new_rev = _get_current_revision(engine=engine)
225-
print(f"Database revision after migration: {new_rev}")
226-
227-
if new_rev != target_revision:
228-
_print_error(f"Expected revision {target_revision}, but database is at {new_rev}.")
229-
engine.dispose()
230-
return 1
231-
232-
# Step 4: Schema check — verify models match
233-
print("Verifying schema matches models...")
234-
try:
235-
check_schema_migrations(engine=engine)
236-
except AutogenerateDiffsDetected as e:
237-
_print_error(f"Schema check failed after migration: {e}")
238-
_print_error("The models in this codebase do not match the database schema.")
239-
engine.dispose()
240-
return 1
241-
242-
_print_success("Migration completed and verified successfully.")
243-
engine.dispose()
244-
return 0
245-
109+
def main() -> int:
110+
"""Entry point for production schema migration."""
111+
import argparse
246112

247-
def _build_parser() -> argparse.ArgumentParser:
248-
"""Build the CLI argument parser."""
249113
parser = argparse.ArgumentParser(
250-
description=(
251-
"Apply Alembic schema migrations to a production database. "
252-
"Validates release environment and requires an explicit target revision."
253-
),
254-
)
255-
parser.add_argument(
256-
"--target-revision",
257-
required=True,
258-
help="The exact Alembic revision ID to upgrade to (e.g., 'c3d5e7f9a1b2').",
259-
)
260-
parser.add_argument(
261-
"--connection-string-env",
262-
default=_CONNECTION_STRING_ENV,
263-
help=f"Environment variable containing the connection string. Default: {_CONNECTION_STRING_ENV}",
114+
description="Apply Alembic schema migrations to the production database.",
264115
)
265116
parser.add_argument(
266117
"--skip-environment-check",
267118
action="store_true",
268119
help="Skip release environment checks (branch, clean tree, version). Use only in CI with caution.",
269120
)
270-
return parser
271-
121+
args = parser.parse_args()
272122

273-
def main() -> int:
274-
"""Entry point for production schema migration."""
275-
args = _build_parser().parse_args()
276-
277-
# Safety rail 1: Require connection string
278-
connection_string = os.environ.get(args.connection_string_env)
279-
if not connection_string:
280-
_print_error(f"Environment variable '{args.connection_string_env}' is not set.")
281-
return 1
282-
283-
# Safety rail 2: Validate target revision exists in local migration graph
284-
if not _validate_revision_exists(target_revision=args.target_revision):
285-
available = _get_all_revision_ids()
286-
_print_error(
287-
f"Target revision '{args.target_revision}' not found in the local migration graph.\n"
288-
f"Available revisions: {', '.join(available)}"
289-
)
290-
return 1
291-
292-
# Safety rail 3: Verify release environment (branch, clean tree, version)
123+
# Safety rail: Verify release environment
293124
if not args.skip_environment_check:
294125
issues = _check_release_environment()
295126
if issues:
@@ -299,18 +130,39 @@ def main() -> int:
299130
_print_error("Fix the above issues or pass --skip-environment-check (CI only).")
300131
return 1
301132
else:
302-
_print_warning("Skipping release environment checks (--skip-environment-check).")
133+
print(f"{_YELLOW}WARNING: Skipping release environment checks.{_RESET}")
303134

304-
# Confirmation prompt (unless running in CI where stdin is not interactive)
135+
# Interactive confirmation
305136
if sys.stdin.isatty():
306-
print(f"About to migrate production database to revision: {args.target_revision}")
307-
print(f"Connection string source: ${args.connection_string_env}")
137+
print("About to migrate production database schema to head.")
308138
response = input("Type 'yes' to proceed: ")
309139
if response.strip().lower() != "yes":
310140
print("Aborted.")
311141
return 1
312142

313-
return _run_migration(connection_string=connection_string, target_revision=args.target_revision)
143+
# Construct AzureSQLMemory with skip_schema_migration=True to bypass the runtime guard,
144+
# then explicitly run migration.
145+
import os
146+
147+
from pyrit.memory import AzureSQLMemory
148+
149+
prod_conn = os.environ.get(AzureSQLMemory.AZURE_SQL_DB_CONNECTION_STRING_PROD)
150+
if not prod_conn:
151+
_print_error(f"Environment variable '{AzureSQLMemory.AZURE_SQL_DB_CONNECTION_STRING_PROD}' is not set.")
152+
return 1
153+
154+
try:
155+
memory = AzureSQLMemory(
156+
connection_string=prod_conn,
157+
skip_schema_migration=True,
158+
)
159+
print("Running schema migration...")
160+
memory._run_schema_migration()
161+
_print_success("Production schema migration completed and verified successfully.")
162+
return 0
163+
except Exception as e:
164+
_print_error(f"Migration failed: {e}")
165+
return 1
314166

315167

316168
if __name__ == "__main__":

doc/contributing/10_release_process.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,36 +220,27 @@ uv sync --frozen
220220
python -c "import pyrit; print(pyrit.__version__)" # verify: x.y.z (no .dev0)
221221
```
222222

223-
**Identify the target revision** — the Alembic head on this branch. We use an explicit
224-
revision (not `head`) so the migration is deterministic and tied to this exact release.
225-
226-
```bash
227-
python build_scripts/memory_migrations.py head
228-
```
229-
230-
This prints the revision ID (e.g., `c3d5e7f9a1b2`) to use as `<revision_id>` below.
231-
232223
**Run the migration** (reads `AZURE_SQL_DB_CONNECTION_STRING_PROD` from `~/.pyrit/.env`):
233224

234225
```bash
235-
python build_scripts/migrate_prod_memory_schema.py --target-revision <revision_id>
226+
python build_scripts/migrate_prod_memory_schema.py
236227
```
237228

238229
The script validates the environment (release branch, clean tree, no `.dev` version),
239-
confirms the target revision exists, applies migrations, and verifies the schema matches
240-
models. It exits non-zero on any failure, and migrations roll back automatically.
230+
constructs an `AzureSQLMemory` pointed at prod, and runs `_run_schema_migration()` which
231+
upgrades to head and verifies the schema matches models. Since you're on the release branch,
232+
head is the release revision.
241233

242-
**Verify prod is usable after migration.** This connects to prod using the check-only
243-
path (no schema modification) and confirms compatibility:
234+
**Verify prod is usable after migration:**
244235

245236
```bash
246237
python -c "from pyrit.memory import AzureSQLMemory; AzureSQLMemory()"
247238
```
248239

249240
If it exits without error, prod is ready.
250241

251-
If no schema changes landed in this release, the script reports "already at target revision"
252-
and exits cleanly. Still run it as confirmation.
242+
If no schema changes landed in this release, `_run_schema_migration` is a no-op.
243+
Still run it as confirmation.
253244

254245
**Rollback policy:** forward-fix only. Ship a new corrective migration rather than downgrading,
255246
since `downgrade()` risks data loss.

pyrit/memory/azure_sql_memory.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,28 @@ def __init__(
117117
prod_connection_string = default_values.get_non_required_value(
118118
env_var_name=self.AZURE_SQL_DB_CONNECTION_STRING_PROD
119119
)
120-
if not skip_schema_migration:
121-
if self._connection_string == prod_connection_string:
122-
# Production guard: verify schema compatibility without modifying the database.
123-
# Logs a warning on mismatch but does not block startup, so developers on
124-
# newer code can still query prod data.
120+
121+
is_prod = bool(prod_connection_string) and self._connection_string == prod_connection_string
122+
should_migrate = not is_prod and not skip_schema_migration
123+
124+
if should_migrate:
125+
# Non-production: run schema migration (upgrade + check).
126+
self._run_schema_migration(silent=silent)
127+
else:
128+
# Production or skip_schema_migration=True: verify schema compatibility
129+
# without modifying the database. Logs a warning on mismatch but does not
130+
# block startup, so developers on newer code can still query data.
131+
from alembic.util.exc import AutogenerateDiffsDetected, CommandError
132+
133+
try:
125134
self._check_schema_migration(silent=silent)
126-
else:
127-
# For non-production databases, run normal schema migration which will create/update tables as needed.
128-
self._run_schema_migration(silent=silent)
135+
except (AutogenerateDiffsDetected, CommandError) as e:
136+
logger.warning(
137+
"Schema mismatch detected. "
138+
"Your code models differ from the database schema. "
139+
"This may cause errors if your code references columns or tables that don't exist. "
140+
f"Schema was NOT modified. Details: {e}"
141+
)
129142

130143
super().__init__()
131144

0 commit comments

Comments
 (0)