Skip to content

Commit 2784c95

Browse files
rlundeen2Copilot
andauthored
MAINT: Frontend core refactor (#1753)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 624f539 commit 2784c95

40 files changed

Lines changed: 4860 additions & 5188 deletions

doc/code/scenarios/0_scenarios.ipynb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,11 @@
392392
}
393393
],
394394
"source": [
395-
"from pyrit.cli.frontend_core import FrontendCore, print_scenarios_list_async\n",
395+
"from pyrit.backend.services.scenario_service import get_scenario_service\n",
396+
"from pyrit.cli._output import print_scenario_list\n",
396397
"\n",
397-
"await print_scenarios_list_async(context=FrontendCore()) # type: ignore"
398+
"response = await get_scenario_service().list_scenarios_async(limit=200) # type: ignore\n",
399+
"print_scenario_list(items=[s.model_dump() for s in response.items])"
398400
]
399401
},
400402
{

doc/code/scenarios/0_scenarios.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,11 @@ def _build_display_group(self, *, technique_name: str, seed_group_name: str) ->
165165
# ## Existing Scenarios
166166

167167
# %%
168-
from pyrit.cli.frontend_core import FrontendCore, print_scenarios_list_async
168+
from pyrit.backend.services.scenario_service import get_scenario_service
169+
from pyrit.cli._output import print_scenario_list
169170

170-
await print_scenarios_list_async(context=FrontendCore()) # type: ignore
171+
response = await get_scenario_service().list_scenarios_async(limit=200) # type: ignore
172+
print_scenario_list(items=[s.model_dump() for s in response.items])
171173

172174
# %% [markdown]
173175
#

doc/code/scenarios/2_custom_scenario_parameters.ipynb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,15 +290,14 @@
290290
}
291291
],
292292
"source": [
293-
"from pyrit.cli.frontend_core import format_scenario_metadata\n",
294-
"from pyrit.registry import ScenarioRegistry\n",
293+
"from pyrit.backend.services.scenario_service import get_scenario_service\n",
294+
"from pyrit.cli._output import print_scenario_list\n",
295295
"\n",
296296
"# Show scam (declares a parameter) and red_team_agent (none), so the\n",
297297
"# Supported Parameters section is visible in one and absent in the other.\n",
298298
"demo_names = {\"airt.scam\", \"foundry.red_team_agent\"}\n",
299-
"for metadata in ScenarioRegistry.get_registry_singleton().list_metadata():\n",
300-
" if metadata.registry_name in demo_names:\n",
301-
" format_scenario_metadata(scenario_metadata=metadata)"
299+
"response = await get_scenario_service().list_scenarios_async(limit=200) # type: ignore\n",
300+
"print_scenario_list(items=[s.model_dump() for s in response.items if s.scenario_name in demo_names])"
302301
]
303302
},
304303
{

doc/code/scenarios/2_custom_scenario_parameters.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,14 @@
182182
# CLI uses is callable programmatically:
183183

184184
# %%
185-
from pyrit.cli.frontend_core import format_scenario_metadata
186-
from pyrit.registry import ScenarioRegistry
185+
from pyrit.backend.services.scenario_service import get_scenario_service
186+
from pyrit.cli._output import print_scenario_list
187187

188188
# Show scam (declares a parameter) and red_team_agent (none), so the
189189
# Supported Parameters section is visible in one and absent in the other.
190190
demo_names = {"airt.scam", "foundry.red_team_agent"}
191-
for metadata in ScenarioRegistry.get_registry_singleton().list_metadata():
192-
if metadata.registry_name in demo_names:
193-
format_scenario_metadata(scenario_metadata=metadata)
191+
response = await get_scenario_service().list_scenarios_async(limit=200) # type: ignore
192+
print_scenario_list(items=[s.model_dump() for s in response.items if s.scenario_name in demo_names])
194193

195194
# %% [markdown]
196195
# Notice the `Supported Parameters:` section under `airt.scam`. It's absent

doc/myst.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,11 @@ project:
203203
children:
204204
- file: api/pyrit_analytics.md
205205
- file: api/pyrit_auth.md
206-
- file: api/pyrit_cli_frontend_core.md
207-
- file: api/pyrit_cli_pyrit_backend.md
206+
- file: api/pyrit_cli_api_client.md
208207
- file: api/pyrit_cli_pyrit_scan.md
209208
- file: api/pyrit_cli_pyrit_shell.md
210209
- file: api/pyrit_common.md
210+
- file: api/pyrit_common_cli_helpers.md
211211
- file: api/pyrit_datasets.md
212212
- file: api/pyrit_embedding.md
213213
- file: api/pyrit_exceptions.md

docker/start.sh

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,33 @@ if [ "$PYRIT_MODE" = "jupyter" ]; then
5757
exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --notebook-dir=/app/notebooks
5858
elif [ "$PYRIT_MODE" = "gui" ]; then
5959
echo "Starting PyRIT GUI on port 8000..."
60-
# Use Azure SQL if AZURE_SQL_SERVER is set (injected by Bicep), otherwise default to SQLite.
61-
# Note: AZURE_SQL_DB_CONNECTION_STRING is in the .env file (loaded by Python dotenv),
62-
# but we use AZURE_SQL_SERVER here because it's a direct env var from the Bicep template.
63-
# Build CLI arguments
64-
BACKEND_ARGS="--host 0.0.0.0 --port 8000"
60+
# The thin backend only takes --host/--port/--config-file/--log-level.
61+
# Translate AZURE_SQL_SERVER and PYRIT_INITIALIZER into a runtime config file
62+
# so the FastAPI lifespan (ConfigurationLoader) picks them up on startup.
63+
RUNTIME_CONFIG=/tmp/pyrit_runtime.yaml
64+
{
65+
if [ -n "$AZURE_SQL_SERVER" ]; then
66+
echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" >&2
67+
echo "memory_db_type: AzureSQL"
68+
else
69+
echo "Using SQLite database (AZURE_SQL_SERVER not set)" >&2
70+
echo "memory_db_type: SQLite"
71+
fi
72+
if [ -n "$PYRIT_INITIALIZER" ]; then
73+
echo "Using initializer: $PYRIT_INITIALIZER" >&2
74+
echo "initializers:"
75+
# Split comma-separated initializer names into a YAML list.
76+
IFS=',' read -ra INIT_NAMES <<<"$PYRIT_INITIALIZER"
77+
for name in "${INIT_NAMES[@]}"; do
78+
echo " - $(echo "$name" | xargs)"
79+
done
80+
fi
81+
} >"$RUNTIME_CONFIG"
6582

66-
if [ -n "$AZURE_SQL_SERVER" ]; then
67-
echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)"
68-
BACKEND_ARGS="$BACKEND_ARGS --database AzureSQL"
69-
else
70-
echo "Using SQLite database (AZURE_SQL_SERVER not set)"
71-
fi
72-
73-
if [ -n "$PYRIT_INITIALIZER" ]; then
74-
echo "Using initializer: $PYRIT_INITIALIZER"
75-
BACKEND_ARGS="$BACKEND_ARGS --initializers $PYRIT_INITIALIZER"
76-
fi
77-
78-
exec python -m pyrit.cli.pyrit_backend $BACKEND_ARGS
83+
exec python -m pyrit.backend.pyrit_backend \
84+
--host 0.0.0.0 \
85+
--port 8000 \
86+
--config-file "$RUNTIME_CONFIG"
7987
else
8088
echo "ERROR: Invalid PYRIT_MODE '$PYRIT_MODE'. Must be 'jupyter' or 'gui'"
8189
exit 1

frontend/dev.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def _find_pids_on_port(port):
141141
def stop_servers():
142142
"""Stop all running servers"""
143143
print("🛑 Stopping servers...")
144-
backend_pids = find_pids_by_pattern("pyrit.cli.pyrit_backend")
144+
backend_pids = find_pids_by_pattern("pyrit.backend.pyrit_backend")
145145
frontend_pids = find_pids_by_pattern("node.*vite")
146146
# Also find any parent dev.py processes (detached wrappers)
147147
wrapper_pids = find_pids_by_pattern("frontend/dev.py")
@@ -163,6 +163,10 @@ def start_backend(*, config_file: str | None = None, initializers: list[str] | N
163163
Configuration (initializers, database, env files) is read automatically
164164
from ~/.pyrit/.pyrit_conf by the pyrit_backend CLI via ConfigurationLoader,
165165
unless overridden with *config_file*.
166+
167+
When *initializers* is supplied without a *config_file*, a tiny temporary
168+
runtime config is written to forward those names — ``pyrit_backend`` only
169+
accepts ``--config-file`` now (no ``--initializers`` flag).
166170
"""
167171
print("🚀 Starting backend on port 8000...")
168172

@@ -178,20 +182,30 @@ def start_backend(*, config_file: str | None = None, initializers: list[str] | N
178182
cmd = [
179183
sys.executable,
180184
"-m",
181-
"pyrit.cli.pyrit_backend",
185+
"pyrit.backend.pyrit_backend",
182186
"--host",
183187
"localhost",
184188
"--port",
185189
"8000",
186190
"--log-level",
187191
"info",
188192
]
189-
if config_file:
190-
cmd.extend(["--config-file", config_file])
191193

192-
# Add initializers if specified
193-
if initializers:
194-
cmd.extend(["--initializers"] + initializers)
194+
# Resolve config-file: explicit wins; otherwise synthesize one from initializers.
195+
effective_config_file = config_file
196+
if effective_config_file is None and initializers:
197+
import tempfile
198+
199+
fd, synthesized_path = tempfile.mkstemp(suffix=".yaml", prefix="pyrit_dev_", text=True)
200+
with os.fdopen(fd, "w") as synthesized:
201+
synthesized.write("initializers:\n")
202+
for name in initializers:
203+
synthesized.write(f" - {name}\n")
204+
effective_config_file = synthesized_path
205+
print(f" Wrote initializer overrides to {effective_config_file}")
206+
207+
if effective_config_file:
208+
cmd.extend(["--config-file", effective_config_file])
195209

196210
# Pipe stdout/stderr so dev.py controls output ordering
197211
return subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
@@ -456,7 +470,7 @@ def main():
456470
elif command == "backend":
457471
print("🚀 Starting backend only...")
458472
# Kill stale backend processes
459-
stale = find_pids_by_pattern("pyrit.cli.pyrit_backend")
473+
stale = find_pids_by_pattern("pyrit.backend.pyrit_backend")
460474
if stale:
461475
print(f" Killing stale backend PIDs: {stale}")
462476
kill_pids(stale)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ all = [
157157
]
158158

159159
[project.scripts]
160-
pyrit_backend = "pyrit.cli.pyrit_backend:main"
160+
pyrit_backend = "pyrit.backend.pyrit_backend:main"
161161
pyrit_scan = "pyrit.cli.pyrit_scan:main"
162162
pyrit_shell = "pyrit.cli.pyrit_shell:main"
163163

pyrit/backend/main.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
targets,
3131
version,
3232
)
33-
from pyrit.memory import CentralMemory
33+
from pyrit.setup.configuration_loader import ConfigurationLoader
3434

3535
# Check for development mode from environment variable
3636
DEV_MODE = os.getenv("PYRIT_DEV_MODE", "false").lower() == "true"
@@ -40,17 +40,38 @@
4040

4141
@asynccontextmanager
4242
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
43-
"""Manage application startup and shutdown lifecycle."""
44-
# Initialization is handled by the pyrit_backend CLI before uvicorn starts.
45-
# Running 'uvicorn pyrit.backend.main:app' directly is not supported;
46-
# use 'pyrit_backend' instead.
47-
try:
48-
CentralMemory.get_memory_instance()
49-
except ValueError:
50-
logger.warning(
51-
"CentralMemory is not initialized. "
52-
"Start the server via 'pyrit_backend' CLI instead of running uvicorn directly."
53-
)
43+
"""
44+
Initialize PyRIT on startup using the config file, then yield.
45+
46+
Config resolution order:
47+
1. ``PYRIT_CONFIG_FILE`` env var (if set)
48+
2. ``~/.pyrit/.pyrit_conf`` (if it exists)
49+
3. Built-in defaults (SQLite, no initializers)
50+
"""
51+
config_file_env = os.getenv("PYRIT_CONFIG_FILE")
52+
config_file = Path(config_file_env) if config_file_env else None
53+
54+
config = ConfigurationLoader.load_with_overrides(config_file=config_file)
55+
await config.initialize_pyrit_async()
56+
57+
# Expose config values to route handlers via app.state
58+
default_labels: dict[str, str] = {}
59+
if config.operator:
60+
default_labels["operator"] = config.operator
61+
if config.operation:
62+
default_labels["operation"] = config.operation
63+
app.state.default_labels = default_labels
64+
app.state.max_concurrent_scenario_runs = config.max_concurrent_scenario_runs
65+
app.state.allow_custom_initializers = config.allow_custom_initializers
66+
67+
if config.allow_custom_initializers:
68+
logger.warning("Custom initializer registration is ENABLED (allow_custom_initializers: true).")
69+
70+
# Mount the bundled frontend (or print a dev/missing-frontend notice).
71+
# Done here rather than at module load so test imports of `pyrit.backend.main`
72+
# don't emit noise and don't perform filesystem side effects.
73+
setup_frontend()
74+
5475
yield
5576

5677

@@ -124,7 +145,3 @@ def setup_frontend() -> None:
124145
print(" The frontend must be built and included in the package.")
125146
print(" Run: python build_scripts/prepare_package.py")
126147
print(" API endpoints will still work but the UI won't be available.")
127-
128-
129-
# Set up frontend at module load time (needed when running via uvicorn)
130-
setup_frontend()

pyrit/backend/models/scenarios.py

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from pydantic import BaseModel, Field
1616

17-
from pyrit.backend.models.attacks import AttackSummary
1817
from pyrit.backend.models.common import PaginationInfo
1918

2019

@@ -25,7 +24,8 @@ class ScenarioParameterSummary(BaseModel):
2524
description: str = Field(..., description="Human-readable description of the parameter")
2625
default: str | None = Field(None, description="Default value as a display string, or None if required")
2726
param_type: str = Field(..., description="Type of the parameter as a display string (e.g., 'int', 'str')")
28-
choices: str | None = Field(None, description="Allowed values as a display string, or None if unconstrained")
27+
choices: list[str] | None = Field(None, description="Allowed values as strings, or None if unconstrained")
28+
is_list: bool = Field(False, description="True when the parameter accepts a list of values (e.g., list[str])")
2929

3030

3131
class RegisteredScenario(BaseModel):
@@ -124,28 +124,3 @@ class ScenarioRunListResponse(BaseModel):
124124
"""Response for listing scenario runs."""
125125

126126
items: list[ScenarioRunSummary] = Field(..., description="List of scenario runs")
127-
128-
129-
# ============================================================================
130-
# Scenario Results Detail Models
131-
# ============================================================================
132-
133-
134-
class AtomicAttackResults(BaseModel):
135-
"""Results grouped by atomic attack name."""
136-
137-
atomic_attack_name: str = Field(..., description="Name of the atomic attack (strategy)")
138-
display_group: str | None = Field(None, description="Display group label for UI grouping")
139-
results: list[AttackSummary] = Field(..., description="Individual attack results")
140-
success_count: int = Field(0, ge=0, description="Number of successful attacks")
141-
failure_count: int = Field(0, ge=0, description="Number of failed attacks")
142-
total_count: int = Field(0, ge=0, description="Total number of attack results")
143-
total_retries: int = Field(0, ge=0, description="Sum of retries across all attacks in this group")
144-
error_count: int = Field(0, ge=0, description="Number of attacks with errors")
145-
146-
147-
class ScenarioRunDetail(BaseModel):
148-
"""Full detailed results of a scenario run."""
149-
150-
run: ScenarioRunSummary = Field(..., description="The scenario run summary")
151-
attacks: list[AtomicAttackResults] = Field(..., description="Results grouped by atomic attack")

0 commit comments

Comments
 (0)