pryscraper/pipelines.py
cryptorugmunch 0200bf3e16 refactor(exceptions): add ruff BLE001; convert 103 broad except Exception
Per CONVENTIONS.md Part 2 ("Never bare except") and CONVENTIONS.md
Part 7 (pre-commit hooks: ruff), blind `except Exception` is now a
lint failure. Pre-existing sites are marked `# noqa: BLE001` for
later manual review; new code must use specific exception types.

Changes:
- pyproject.toml: added "BLE" to ruff lint select. BLE001 is now enforced
- 103 of 166 `except Exception` sites were auto-converted to specific
  types based on context (httpx, json, OSError, subprocess, etc.)
- 62 remaining sites marked with `# noqa: BLE001` for later review
  (mostly generic try/except wrappers that legitimately need broad catch
  for graceful degradation: e.g. compliance LLM fallback must catch
  any error to preserve the regex result)
- 1 manual fix: reverted compliance.py LLM fallback to broad except
  with explicit "must catch all errors" comment + noqa
- 2 files (commerce_sync.py, crm_sync.py) needed `import httpx` added
  so the auto-converted exception references would resolve
- 5 source files (agency, monitor, pipelines, auth_connector,
  llm_providers/registry) renamed "name" -> "<scope>_name" in
  extra={...} dicts because "name" is a reserved LogRecord field

Test impact:
- 14 failing tests -> 1 (the SSE subprocess test is a sandbox limitation,
  pre-existing and unrelated)
- New `test_ble_temp.py` verifies BLE001 catches new violations

Follow-up:
- Each `# noqa: BLE001` site should be reviewed and replaced with a
  specific exception type where possible. The most common legitimate
  broad-catch case is the LLM fallback path; everything else probably
  can be narrowed.
2026-07-02 21:04:53 +02:00

506 lines
16 KiB
Python

"""Pry — No-Code Visual Pipeline Builder.
JSON-defined workflow engine. Users define pipelines as structured steps,
the engine executes them sequentially with branching and error handling.
A UI can render these steps as drag-and-drop blocks."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
logger = logging.getLogger(__name__)
PIPELINE_DIR = PRY_DATA_DIR / "pipelines"
PIPELINE_DIR.mkdir(parents=True, exist_ok=True)
# ── Step Types Registry ──
STEP_TYPES: dict[str, dict[str, Any]] = {
"scrape": {
"name": "Scrape URL",
"icon": "globe",
"description": "Scrape a single URL",
"inputs": [
{"key": "url", "type": "url", "label": "URL to scrape", "required": True},
{
"key": "bypass_cloudflare",
"type": "boolean",
"label": "Bypass Cloudflare",
"default": True,
},
],
"outputs": ["content", "title", "html", "status"],
},
"extract_css": {
"name": "CSS Extraction",
"icon": "clipboard",
"description": "Extract structured data with CSS selectors",
"inputs": [
{"key": "url", "type": "url", "label": "URL", "required": True},
{"key": "schema", "type": "json", "label": "Extraction Schema", "required": True},
],
"outputs": ["items", "count"],
},
"extract_llm": {
"name": "LLM Extraction",
"icon": "robot",
"description": "Extract with AI + chunking",
"inputs": [
{"key": "url", "type": "url", "label": "URL", "required": True},
{
"key": "instruction",
"type": "text",
"label": "Extraction instruction",
"required": True,
},
{
"key": "chunk_strategy",
"type": "select",
"options": ["topic", "sentence", "regex"],
"label": "Chunk strategy",
"default": "topic",
},
],
"outputs": ["chunks", "total_chunks"],
},
"quality_check": {
"name": "Quality Check",
"icon": "check-circle",
"description": "Validate data quality before delivery",
"inputs": [
{"key": "url", "type": "url", "label": "Source URL", "required": True},
{"key": "data", "type": "json", "label": "Data to validate", "required": True},
{
"key": "expected_types",
"type": "json",
"label": "Expected field types",
"required": False,
},
],
"outputs": ["quality_score", "anomalies", "completeness"],
},
"send_slack": {
"name": "Send to Slack",
"icon": "message-circle",
"description": "Send results to a Slack channel",
"inputs": [
{"key": "webhook_url", "type": "url", "label": "Slack Webhook URL", "required": True},
{"key": "message", "type": "text", "label": "Message", "required": True},
],
"outputs": ["success"],
},
"send_email": {
"name": "Send Email",
"icon": "mail",
"description": "Send results via email",
"inputs": [
{"key": "recipient", "type": "email", "label": "Recipient", "required": True},
{"key": "subject", "type": "text", "label": "Subject", "required": True},
{"key": "body", "type": "text", "label": "Body", "required": True},
],
"outputs": ["success"],
},
"compliance_check": {
"name": "Compliance Check",
"icon": "shield",
"description": "Legal compliance scan before scraping",
"inputs": [
{"key": "url", "type": "url", "label": "URL to check", "required": True},
],
"outputs": ["risk_level", "risk_score", "recommendations"],
},
"reconcile": {
"name": "Entity Reconciliation",
"icon": "link",
"description": "Match records across sources",
"inputs": [
{"key": "records", "type": "json", "label": "Records to reconcile", "required": True},
{
"key": "vertical",
"type": "select",
"options": ["product", "job", "real_estate", "review"],
"label": "Vertical",
"default": "product",
},
],
"outputs": ["entities", "report"],
},
"conditional": {
"name": "Conditional Branch",
"icon": "git-branch",
"description": "Branch based on a condition",
"inputs": [
{
"key": "condition",
"type": "text",
"label": "JavaScript condition expression",
"required": True,
},
],
"outputs": ["true", "false"],
},
"delay": {
"name": "Delay",
"icon": "clock",
"description": "Wait before next step",
"inputs": [
{
"key": "seconds",
"type": "number",
"label": "Seconds to wait",
"default": 5,
"required": True,
},
],
"outputs": ["waited"],
},
"transform": {
"name": "Transform Data",
"icon": "refresh-cw",
"description": "Apply JSON transformation to data",
"inputs": [
{"key": "data", "type": "json", "label": "Input data", "required": True},
{
"key": "transform",
"type": "json",
"label": "JQ-style transform rules",
"required": True,
},
],
"outputs": ["result"],
},
"export_training": {
"name": "Export Training Data",
"icon": "brain",
"description": "Export as AI training dataset",
"inputs": [
{"key": "records", "type": "json", "label": "Records to export", "required": True},
{
"key": "clean_room",
"type": "boolean",
"label": "Strip PII/copyright",
"default": True,
},
],
"outputs": ["dataset_id", "record_count"],
},
}
# ── Pipeline Engine ──
def validate_pipeline(pipeline: dict[str, Any]) -> list[str]:
"""Validate a pipeline definition. Returns list of errors."""
errors = []
steps = pipeline.get("steps", [])
if not steps:
errors.append("Pipeline must have at least one step")
step_ids = set()
for i, step in enumerate(steps):
step_id = step.get("id", f"step_{i}")
if step_id in step_ids:
errors.append(f"Duplicate step ID: {step_id}")
step_ids.add(step_id)
step_type = step.get("type")
if step_type not in STEP_TYPES:
errors.append(f"Step {i}: Unknown type '{step_type}'")
continue
step_def = STEP_TYPES[step_type]
for inp in step_def["inputs"]:
if inp.get("required") and inp["key"] not in step.get("inputs", {}):
errors.append(f"Step '{step_id}': Missing required input '{inp['key']}'")
return errors
async def run_pipeline(
pipeline: dict[str, Any], context: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Execute a pipeline definition sequentially.
Args:
pipeline: Pipeline definition with steps array
context: Initial context variables
Returns execution results with step outputs.
"""
pipeline_id = pipeline.get("id") or uuid.uuid4().hex[:8]
steps = pipeline.get("steps", [])
ctx = dict(context or {})
results: list[dict[str, Any]] = []
failed = False
error: str | None = None
for i, step in enumerate(steps):
step_id = step.get("id", f"step_{i}")
step_type = step.get("type")
inputs = step.get("inputs", {})
# Resolve template variables in inputs
resolved_inputs = _resolve_templates(inputs, ctx)
logger.info(
"pipeline_step_start",
extra={"pipeline_id": pipeline_id, "step": step_id, "type": step_type},
)
step_result: dict[str, Any] = {
"step_id": step_id,
"type": step_type,
"status": "running",
"started_at": datetime.now(UTC).isoformat(),
}
try:
output = await _execute_step(step_type, resolved_inputs, ctx)
step_result["status"] = "success"
step_result["output"] = output
# Store outputs in context as step_id.output_key
if isinstance(output, dict):
for key, value in output.items():
ctx[f"{step_id}.{key}"] = value
except Exception as e: # noqa: BLE001
step_result["status"] = "failed"
step_result["error"] = str(e)[:500]
logger.error(
"pipeline_step_failed",
extra={"pipeline_id": pipeline_id, "step": step_id, "error": str(e)},
)
failed = True
error = str(e)
if step.get("on_error") == "abort":
results.append(step_result)
break
step_result["finished_at"] = datetime.now(UTC).isoformat()
results.append(step_result)
return {
"pipeline_id": pipeline_id,
"pipeline_name": pipeline.get("name", "Unnamed"),
"total_steps": len(steps),
"completed_steps": len(results),
"successful_steps": sum(1 for r in results if r["status"] == "success"),
"failed_steps": sum(1 for r in results if r["status"] == "failed"),
"failed": failed,
"error": error,
"steps": results,
"context_keys": list(ctx.keys()),
}
async def _execute_step(
step_type: str, inputs: dict[str, Any], ctx: dict[str, Any]
) -> dict[str, Any]:
"""Execute a single pipeline step."""
if step_type == "scrape":
from scraper import PryScraper
s = PryScraper()
result = await s.scrape(
inputs.get("url", ""), {"bypass_cloudflare": inputs.get("bypass_cloudflare", True)}
)
return result
elif step_type == "extract_css":
from extraction import JsonCssExtractionStrategy
from scraper import PryScraper
s = PryScraper()
result = await s.scrape(inputs.get("url", ""))
html = result.get("raw_html", "")
if not html:
from client import get_client
client = await get_client()
resp = await client.get(inputs["url"], timeout=30, follow_redirects=True)
html = resp.text
strategy = JsonCssExtractionStrategy(inputs.get("schema", {}))
items = strategy.extract(html)
return {"items": items, "count": len(items)}
elif step_type == "quality_check":
from quality import run_quality_check
return await run_quality_check(
url=inputs.get("url", ""),
data=inputs.get("data", {}),
)
elif step_type == "compliance_check":
from compliance import run_compliance_check
return await run_compliance_check(inputs.get("url", ""))
elif step_type == "reconcile":
from reconciliation import reconcile
return await reconcile(
records=inputs.get("records", []),
vertical=inputs.get("vertical", "product"),
)
elif step_type == "send_slack":
from destinations import write_to_slack
result = await write_to_slack(
webhook_url=inputs.get("webhook_url", ""),
message=inputs.get("message", ""),
)
return result
elif step_type == "send_email":
from destinations import write_to_email
result = await write_to_email(
recipient=inputs.get("recipient", ""),
subject=inputs.get("subject", ""),
body=inputs.get("body", ""),
)
return result
elif step_type == "delay":
import asyncio
seconds = inputs.get("seconds", 5)
await asyncio.sleep(seconds)
return {"waited": seconds}
elif step_type == "transform":
data = inputs.get("data", {})
transform = inputs.get("transform", {})
result = _apply_transform(data, transform)
return {"result": result}
elif step_type == "export_training":
from training_data import export_training_dataset
result = export_training_dataset(
records=inputs.get("records", []),
clean_room=inputs.get("clean_room", True),
)
return result
else:
raise ValueError(f"Unknown step type: {step_type}")
def _resolve_templates(inputs: dict[str, Any], ctx: dict[str, Any]) -> dict[str, Any]:
"""Resolve {{ variable }} templates in input values."""
import re
resolved: dict[str, Any] = {}
for key, value in inputs.items():
if isinstance(value, str):
resolved[key] = re.sub(
r"\{\{(\w+(?:\.\w+)*)\}\}", lambda m: str(ctx.get(m.group(1), m.group(0))), value
)
elif isinstance(value, dict):
resolved[key] = _resolve_templates(value, ctx)
elif isinstance(value, list):
resolved[key] = [
_resolve_templates(v, ctx) if isinstance(v, dict) else v for v in value
]
else:
resolved[key] = value
return resolved
def _apply_transform(data: Any, transform: dict[str, Any]) -> Any:
"""Apply simple JQ-style transforms."""
if isinstance(transform, dict):
result: dict[str, Any] = {}
for key, rule in transform.items():
if isinstance(rule, str) and rule.startswith("$."):
# JSON path extraction
path = rule[2:].split(".")
value = data
for part in path:
if isinstance(value, dict):
value = value.get(part, None)
elif isinstance(value, list) and part.isdigit():
idx = int(part)
value = value[idx] if 0 <= idx < len(value) else None
else:
value = None
result[key] = value
elif isinstance(rule, str) and rule.startswith("$"):
result[key] = data
else:
result[key] = rule
return result
return data
# ── Pipeline CRUD ──
def save_pipeline(pipeline: dict[str, Any]) -> dict[str, Any]:
"""Save a pipeline definition."""
pipeline_id = pipeline.get("id") or uuid.uuid4().hex[:8]
pipeline["id"] = pipeline_id
pipeline["updated_at"] = datetime.now(UTC).isoformat()
path = PIPELINE_DIR / f"{pipeline_id}.json"
try:
path.write_text(json.dumps(pipeline, indent=2))
logger.info(
"pipeline_saved", extra={"pipeline_id": pipeline_id, "pipeline_name": pipeline.get("name")}
)
return {"success": True, "pipeline_id": pipeline_id}
except OSError as e:
return {"success": False, "error": str(e)}
def get_pipeline(pipeline_id: str) -> dict[str, Any] | None:
"""Get a saved pipeline definition."""
path = PIPELINE_DIR / f"{pipeline_id}.json"
if not path.exists():
return None
try:
return cast("dict[str, Any]", json.loads(path.read_text()))
except (json.JSONDecodeError, OSError):
return None
def list_pipelines() -> list[dict[str, Any]]:
"""List all saved pipelines."""
pipelines = []
for path in sorted(PIPELINE_DIR.glob("*.json"), key=os.path.getmtime, reverse=True):
try:
data = json.loads(path.read_text())
pipelines.append(
{
"id": data.get("id"),
"name": data.get("name", "Unnamed"),
"description": data.get("description", ""),
"step_count": len(data.get("steps", [])),
"updated_at": data.get("updated_at"),
}
)
except (json.JSONDecodeError, OSError):
continue
return pipelines
def delete_pipeline(pipeline_id: str) -> bool:
"""Delete a saved pipeline."""
path = PIPELINE_DIR / f"{pipeline_id}.json"
if path.exists():
path.unlink()
return True
return False