pryscraper/pry_sdk.py
cryptorugmunch bb77eb5f35 chore(license): re-license to dual MIT (core) + BSL 1.1 (stealth)
Re-license Pry from full Proprietary to a dual-license model:

- Core engine, extraction, templates (80+), MCP server, x402 payment rail,
  CLI, SDK, browser extension, WordPress plugin, Shopify app, and
  llm_providers: MIT (see LICENSE)
- Anti-detection / stealth subset (15 files): BSL 1.1 with Change Date
  2029-01-01 (see LICENSE-BSL-STEALTH)

BSL files (anti-detection moat):
  ultimate_scraper.py, stealth_engine.py, stealth_scripts/*.js (6),
  camoufox_integration.py, tls_fingerprint.py, cookie_warmer.py,
  behavioral_biometrics.py, adaptive.py, browser_pool.py, network.py,
  captcha_solver.py, shadow_dom.py, lazy_load.py, signup_automator.py,
  auth_connector.py

This enables community contributions to the core engine (templates,
integrations, MCP tools) while protecting the anti-detection techniques
that constitute the actual competitive moat. BSL Additional Use Grant
permits free non-production use; production deployment requires a
commercial license from enterprise@rugmunch.io.

Changes:
- Replace proprietary LICENSE with MIT LICENSE + new LICENSE-BSL-STEALTH
- Add SPDX-License-Identifier headers to 300+ source files
- Add docs/adr/0002-dual-licensing.md (ADR documenting the decision)
- Update README.md: new License section with BSL Additional Use Grant
- Update LICENSING_PRICING_STRATEGY.md: Section 3 (PryScraper) for dual license
- Update AGENTS.md: license line in header + new rule 8 (PRs touching BSL rejected)
- Update pyproject.toml: license = "MIT AND BSL-1.1" + classifiers + license-files
- Update DECISIONS.md index with ADR-0002
- Update STATUS.md (2026-07-03) and PLAN.md sprint goals

Refs: ADR-0002
2026-07-02 19:49:21 +02:00

136 lines
5 KiB
Python

"""Pry Python SDK — simple client library.
Usage:
from pry_sdk import PryCrawl
mc = PryCrawl("http://localhost:8002")
result = await mc.scrape("https://example.com")
"""
# 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 asyncio
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_LOOP: asyncio.AbstractEventLoop | None = None
def _get_or_create_loop() -> asyncio.AbstractEventLoop:
"""Get the running event loop or create a persistent one for sync use."""
global _LOOP
try:
return asyncio.get_running_loop()
except RuntimeError:
if _LOOP is None or _LOOP.is_closed():
_LOOP = asyncio.new_event_loop()
asyncio.set_event_loop(_LOOP)
return _LOOP
class PryCrawl:
"""Python SDK for Pry API."""
def __init__(
self, base_url: str = "http://localhost:8002", api_key: str | None = None, timeout: int = 60
):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._headers = {"Content-Type": "application/json"}
if api_key:
self._headers["Authorization"] = f"Bearer {api_key}"
async def _post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout, headers=self._headers) as client:
resp = await client.post(f"{self.base_url}{path}", json=data)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
return result
async def scrape(self, url: str, **options: Any) -> dict[str, Any]:
"""Scrape a single URL to markdown."""
return await self._post("/v1/scrape", {"url": url, **options})
async def scrape_json(self, url: str, schema: dict[str, str], **options: Any) -> dict[str, Any]:
"""Scrape with structured JSON extraction."""
return await self._post(
"/v1/scrape", {"url": url, "formats": ["json"], "jsonSchema": schema, **options}
)
async def crawl(self, url: str, max_pages: int = 10, **options: Any) -> dict[str, Any]:
"""Crawl multiple pages from a URL."""
return await self._post("/v1/crawl", {"url": url, "maxPages": max_pages, **options})
async def map(self, url: str, limit: int = 50) -> dict[str, Any]:
"""Discover URLs on a site."""
return await self._post("/v1/map", {"url": url, "limit": limit})
async def parse(self, url: str) -> dict[str, Any]:
"""Parse a document (PDF, DOCX, image)."""
return await self._post("/v1/parse", {"url": url})
async def automate(self, steps: list[dict[str, Any]], **options: Any) -> dict[str, Any]:
"""Run browser automation steps."""
return await self._post("/v1/automate", {"steps": steps, **options})
async def screenshot(self, url: str) -> dict[str, Any]:
"""Take a screenshot of a URL."""
return await self._post("/v1/screenshot", {"url": url})
async def health(self) -> dict[str, Any]:
"""Check service health."""
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{self.base_url}/health")
result: dict[str, Any] = resp.json()
return result
class PryCrawlSync:
"""Synchronous wrapper for Pry SDK."""
def __init__(
self, base_url: str = "http://localhost:8002", api_key: str | None = None, timeout: int = 60
):
self._client = PryCrawl(base_url, api_key, timeout)
def _run(self, coro: Any) -> Any:
loop = _get_or_create_loop()
return loop.run_until_complete(coro)
def scrape(self, url: str, **options: Any) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.scrape(url, **options))
return result
def scrape_json(self, url: str, schema: dict[str, str], **options: Any) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.scrape_json(url, schema, **options))
return result
def crawl(self, url: str, max_pages: int = 10, **options: Any) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.crawl(url, max_pages, **options))
return result
def map(self, url: str, limit: int = 50) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.map(url, limit))
return result
def parse(self, url: str) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.parse(url))
return result
def automate(self, steps: list[dict[str, Any]], **options: Any) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.automate(steps, **options))
return result
def screenshot(self, url: str) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.screenshot(url))
return result
def health(self) -> dict[str, Any]:
result: dict[str, Any] = self._run(self._client.health())
return result