Mark the readiness test as integration; it requires live Postgres + Redis
which are not running in Cinnabox dev. Tests run on Talos prod include it
when invoked with: pytest -m integration.
- Add @pytest.mark.integration + skipif on _has_postgres() probe
- Register integration marker in pyproject.toml
- Default addopts = -m 'not integration' skips them in plain ============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/dev/pry
configfile: pyproject.toml
testpaths: tests
plugins: respx-0.23.1, cov-7.1.0, Faker-27.4.0, asyncio-1.4.0, anyio-4.14.1
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 622 items / 1 deselected / 621 selected
tests/test_adaptive.py ....... [ 1%]
tests/test_advanced_features.py .............. [ 3%]
tests/test_advanced_scraping.py ................. [ 6%]
tests/test_agency.py ....... [ 7%]
tests/test_ai_plugin.py ... [ 7%]
tests/test_alerter.py ..... [ 8%]
tests/test_api.py .... [ 9%]
tests/test_api_integration.py ........................... [ 13%]
tests/test_api_mcp.py ...s [ 14%]
tests/test_api_mcp_x402.py .............. [ 16%]
tests/test_api_scraping.py ........ [ 17%]
tests/test_api_surface_snapshot.py .... [ 18%]
tests/test_api_templates.py ...... [ 19%]
tests/test_apify_schema.py ..... [ 20%]
tests/test_auth_connector.py ..... [ 20%]
tests/test_block_detector.py .......... [ 22%]
tests/test_cache.py ....... [ 23%]
tests/test_camoufox.py ... [ 24%]
tests/test_commerce_sync.py .. [ 24%]
tests/test_compliance.py ............. [ 26%]
tests/test_costing.py .... [ 27%]
tests/test_crm_sync.py ...... [ 28%]
tests/test_db.py ......... [ 29%]
tests/test_destinations.py ....... [ 30%]
tests/test_email_scraper.py ......... [ 32%]
tests/test_enrichment.py .......... [ 33%]
tests/test_extractor.py ....... [ 34%]
tests/test_fallback_integration.py ............ [ 36%]
tests/test_freshness.py ..... [ 37%]
tests/test_gdpr.py ...... [ 38%]
tests/test_infrastructure.py ........... [ 40%]
tests/test_intelligence.py ........................... [ 44%]
tests/test_jobqueue.py ...... [ 45%]
tests/test_lazy_load.py ..... [ 46%]
tests/test_llm_fallback.py ...... [ 47%]
tests/test_llm_providers.py ...... [ 48%]
tests/test_logging_config.py ........ [ 49%]
tests/test_markdown_gen.py ...... [ 50%]
tests/test_marketplace.py ........... [ 52%]
tests/test_mcp_production.py .......... [ 54%]
tests/test_monitor.py ............... [ 56%]
tests/test_network.py ...... [ 57%]
tests/test_parser.py ...... [ 58%]
tests/test_pipeline.py ..... [ 59%]
tests/test_pipelines.py ....... [ 60%]
tests/test_proxy_manager.py ............ [ 62%]
tests/test_quality.py ........... [ 64%]
tests/test_ratelimit.py ...... [ 65%]
tests/test_real_features.py ........ [ 66%]
tests/test_reconciliation.py ......... [ 67%]
tests/test_referrals.py .............. [ 70%]
tests/test_reports.py ....... [ 71%]
tests/test_resilience.py .............. [ 73%]
tests/test_retry.py ........................... [ 77%]
tests/test_review.py ...... [ 78%]
tests/test_scraper.py ........ [ 80%]
tests/test_sdk.py ...... [ 80%]
tests/test_secrets_backend.py ......... [ 82%]
tests/test_seo_monitor.py ...... [ 83%]
tests/test_settings.py ......... [ 84%]
tests/test_shadow_dom.py ......... [ 86%]
tests/test_structure_monitor.py F...... [ 87%]
tests/test_templates.py ...... [ 88%]
tests/test_training_data.py ......... [ 89%]
tests/test_ultimate_scraper.py ............ [ 91%]
tests/test_url_guard.py ........................ [ 95%]
tests/test_x402_mcp_spec.py ........................... [100%]
=================================== FAILURES ===================================
__________________________ test_check_selectors_empty __________________________
@pytest.mark.asyncio
async def test_check_selectors_empty():
> result = await check_selectors("https://example.com", [])
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_structure_monitor.py:36:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
structure_monitor.py:46: in check_selectors
resp = await client.get(
../.local/lib/python3.12/site-packages/httpx/_client.py:1768: in get
return await self.request(
../.local/lib/python3.12/site-packages/httpx/_client.py:1540: in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_client.py:1629: in send
response = await self._send_handling_auth(
../.local/lib/python3.12/site-packages/httpx/_client.py:1657: in _send_handling_auth
response = await self._send_handling_redirects(
../.local/lib/python3.12/site-packages/httpx/_client.py:1694: in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_client.py:1730: in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_transports/default.py:394: in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:256: in handle_async_request
raise exc from None
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:229: in handle_async_request
await self._close_connections(closing)
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:345: in _close_connections
await connection.aclose()
../.local/lib/python3.12/site-packages/httpcore/_async/connection.py:173: in aclose
await self._connection.aclose()
../.local/lib/python3.12/site-packages/httpcore/_async/http11.py:258: in aclose
await self._network_stream.aclose()
../.local/lib/python3.12/site-packages/httpcore/_backends/anyio.py:53: in aclose
await self._stream.aclose()
../.local/lib/python3.12/site-packages/anyio/streams/tls.py:236: in aclose
await self.transport_stream.aclose()
../.local/lib/python3.12/site-packages/anyio/_backends/_asyncio.py:1374: in aclose
self._transport.close()
/usr/lib/python3.12/asyncio/selector_events.py:1211: in close
super().close()
/usr/lib/python3.12/asyncio/selector_events.py:875: in close
self._loop.call_soon(self._call_connection_lost, None)
/usr/lib/python3.12/asyncio/base_events.py:795: in call_soon
self._check_closed()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <_UnixSelectorEventLoop running=False closed=True debug=False>
def _check_closed(self):
if self._closed:
> raise RuntimeError('Event loop is closed')
E RuntimeError: Event loop is closed
/usr/lib/python3.12/asyncio/base_events.py:541: RuntimeError
=============================== warnings summary ===============================
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_css_v1_extract_css_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_llm_v1_extract_llm_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_quality_check_v1_quality_check_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_with_review_v1_extract_with_review_post" shadows an attribute in parent "BaseModel"
return meta(
tests/test_api_integration.py::TestHealthEndpoints::test_live_returns_200
/home/dev/.local/lib/python3.12/site-packages/_pytest/unraisableexception.py:67: PytestUnraisableExceptionWarning: Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x7c2702c51120>
Traceback (most recent call last):
File "/usr/lib/python3.12/asyncio/base_subprocess.py", line 126, in __del__
self.close()
File "/usr/lib/python3.12/asyncio/base_subprocess.py", line 104, in close
proto.pipe.close()
File "/usr/lib/python3.12/asyncio/unix_events.py", line 767, in close
self.write_eof()
File "/usr/lib/python3.12/asyncio/unix_events.py", line 753, in write_eof
self._loop.call_soon(self._call_connection_lost, None)
File "/usr/lib/python3.12/asyncio/base_events.py", line 795, in call_soon
self._check_closed()
File "/usr/lib/python3.12/asyncio/base_events.py", line 541, in _check_closed
raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
tests/test_api_integration.py::TestDetectBlockEndpoint::test_detect_block_success
tests/test_api_integration.py::TestErrorHandling::test_422_on_invalid_json
/home/dev/.local/lib/python3.12/site-packages/httpx/_models.py:408: DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content.
headers, stream = encode_request(
tests/test_jobqueue.py::test_jobqueue_create_job_returns_id
tests/test_jobqueue.py::test_jobqueue_get_job_exists
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:56: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
"created_at": datetime.utcnow().isoformat(),
tests/test_jobqueue.py::test_jobqueue_create_job_returns_id
tests/test_jobqueue.py::test_jobqueue_get_job_exists
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:57: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
"updated_at": datetime.utcnow().isoformat(),
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:93: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
self._local_jobs[job_id]["updated_at"] = datetime.utcnow().isoformat()
tests/test_retry.py::TestAsyncRetry::test_passes_args_and_kwargs
/home/dev/pry/tests/test_retry.py:259: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
fn(url, timeout=timeout)
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
tests/test_sdk.py::test_prycrawl_health_returns_dict
/home/dev/.local/lib/python3.12/site-packages/_pytest/python.py:167: RuntimeWarning: coroutine 'PryCrawl.health' was never awaited
result = testfunction(**testargs)
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_structure_monitor.py::test_check_selectors_empty - RuntimeE...
= 1 failed, 619 passed, 1 skipped, 1 deselected, 26 warnings in 90.04s (0:01:30) =
Tests: 2 passed, 1 deselected in TestHealthEndpoints (was 2 pass + 1 fail).
|
||
|---|---|---|
| .actor | ||
| .forgejo | ||
| .github | ||
| alembic | ||
| browser-extension | ||
| docs/adr | ||
| llm_providers | ||
| pry_mcp | ||
| routers | ||
| shopify-app | ||
| stealth_scripts | ||
| templates | ||
| tests | ||
| wordpress-plugin | ||
| workers | ||
| .commitlintrc.yml | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .secretsallow | ||
| account_manager.py | ||
| actor_marketplace.py | ||
| adaptive.py | ||
| advanced.py | ||
| agency.py | ||
| AGENTS.md | ||
| ai_plugin.py | ||
| alembic.ini | ||
| alerter.py | ||
| anomaly.py | ||
| api.py | ||
| apify_actor.py | ||
| apify_schema.py | ||
| ARCHITECTURE.md | ||
| AUDIT-2026-Q3.md | ||
| AUDIT.md | ||
| auth.py | ||
| auth_connector.py | ||
| automator.py | ||
| behavioral_biometrics.py | ||
| browser_pool.py | ||
| cache.py | ||
| camoufox_integration.py | ||
| captcha_solver.py | ||
| cli.py | ||
| client.py | ||
| commerce_sync.py | ||
| compliance.py | ||
| CONTRIBUTING.md | ||
| cookie_warmer.py | ||
| costing.py | ||
| crm_sync.py | ||
| db.py | ||
| DECISIONS.md | ||
| dedup.py | ||
| DEPLOYMENT.md | ||
| deps.py | ||
| destinations.py | ||
| DEVELOPMENT.md | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| Dockerfile.apify | ||
| email_scraper.py | ||
| enrichment.py | ||
| errors.py | ||
| extraction.py | ||
| extractor.py | ||
| FEATURES.md | ||
| freshness.py | ||
| gdpr.py | ||
| gdpr_real.py | ||
| glama.json | ||
| graphql_discovery.py | ||
| intelligence.py | ||
| jobqueue.py | ||
| lazy_load.py | ||
| LICENSE | ||
| LICENSE-BSL-STEALTH | ||
| LICENSING_PRICING_STRATEGY.md | ||
| llm_features.py | ||
| logging_config.py | ||
| Makefile | ||
| markdown_gen.py | ||
| mcp_production.py | ||
| mcp_server.py | ||
| mcp_sse.py | ||
| MCP_X402_AUDIT.md | ||
| monitor.py | ||
| network.py | ||
| observability.py | ||
| ocr_extractor.py | ||
| openapi.json | ||
| parser.py | ||
| paths.py | ||
| pdf_extractor.py | ||
| pipeline.py | ||
| pipelines.py | ||
| PLAN.md | ||
| proxy_manager.py | ||
| proxy_referrals.py | ||
| pry_sdk.py | ||
| pryextras.py | ||
| pryfile.py | ||
| pulsemcp.json | ||
| pyproject.toml | ||
| quality.py | ||
| ratelimit.py | ||
| README.md | ||
| reconciliation.py | ||
| referrals.py | ||
| reports.py | ||
| reports_real.py | ||
| requirements.lock | ||
| requirements.txt | ||
| resilience.py | ||
| retry.py | ||
| review.py | ||
| ROADMAP.md | ||
| schema_extraction.py | ||
| scraper.py | ||
| secrets_backend.py | ||
| SECURITY.md | ||
| seo_monitor.py | ||
| sessions.py | ||
| settings.py | ||
| shadow_dom.py | ||
| signup_automator.py | ||
| smithery.yaml | ||
| STATUS.md | ||
| stealth_engine.py | ||
| structure_monitor.py | ||
| tasks.py | ||
| template_engine.py | ||
| test.sh | ||
| TESTING.md | ||
| tls_fingerprint.py | ||
| training_data.py | ||
| ultimate_scraper.py | ||
| url_guard.py | ||
| USAGE.md | ||
| webhook_delivery.py | ||
| websocket_scraper.py | ||
| wrangler.toml | ||
| x402.py | ||
| x402_middleware.py | ||
//: # (Copyright (c) 2026 Rug Munch Media LLC)
Pry — Open any website
Self-hosted web scraping + browser automation API. Cloudflare bypass, document parsing, AI summarization. No API keys needed.
Quickstart
# Install (PyPI 'pry' is taken — install from this repo)
pip install -e .
# or:
# pip install "pry-scraper @ git+https://git.rugmunch.io/RugMunchMedia/pryscraper.git"
# Start the server
pry serve
# Scrape a URL
pry open https://example.com
# With JSON extraction
pry open https://store.com/product --json --schema product.json
# Crawl a site
pry crawl https://docs.com --max-pages 20 -o data.json
Docker
docker compose up -d
# Pry on :8005 (host) → :8002 (container), FlareSolverr on :8192 (host) → :8191 (container)
CLI Reference
| Command | Description |
|---|---|
pry open <url> |
Scrape URL to clean markdown |
pry watch <url> |
Monitor page for changes |
pry crawl <url> |
Crawl multiple pages |
pry batch <file> |
Batch scrape URLs from file |
pry parse <url> |
Parse PDF/DOCX/image |
pry ss <url> |
Take screenshot |
pry run [pry.yml] |
Execute job file |
pry serve |
Start API server |
API
POST /v1/scrape
{"url": "https://example.com", "bypassCloudflare": true, "formats": ["markdown"]}
POST /v1/crawl
{"url": "https://docs.com", "maxPages": 10, "maxDepth": 2}
POST /v1/automate
{"steps": [{"action": "navigate", "url": "https://...", {"action": "click", "selector": "#btn"}]]}
POST /v1/vision
Analyze images with free OpenRouter vision models. Auto-fallback across 5 models.
{"url": "https://...", "question": "What is shown?"}
POST /v1/extract
Extract structured data with JSON schema + optional AI fallback.
POST /v1/batch
Scrape up to 50 URLs in parallel.
POST /v1/compare
Diff two URLs.
POST /v1/summarize
AI summarization via local Ollama (free, private).
GET /health
Service health + cache stats + active sessions.
Python SDK
from pry_sdk import PryCrawl
mc = PryCrawl("http://localhost:8005")
result = await mc.scrape("https://example.com")
print(result["data"]["markdown"])
from pry_sdk import PryCrawlSync
mc = PryCrawlSync("http://localhost:8005")
result = mc.scrape("https://example.com")
SDK Methods
| Method | Description |
|---|---|
scrape(url, **opts) |
Scrape to markdown |
scrape_json(url, schema, **opts) |
Scrape with JSON extraction |
crawl(url, max_pages, **opts) |
Crawl site |
map(url, limit) |
Discover URLs |
parse(url) |
Parse document |
automate(steps, **opts) |
Browser automation |
screenshot(url) |
Screenshot page |
health() |
Service health |
MCP (AI Agent Integration)
POST /mcp/call
{"name": "pry_scrape", "arguments": {"url": "https://..."}}
Compatible with Claude, Hermes, Cursor, and any MCP client.
Features
- 4-tier anti-detection: Direct → FlareSolverr → Playwright → Googlebot
- Cloudflare bypass: Automatic via FlareSolverr
- Document parsing: PDF, DOCX, images (OCR), CSV, JSON
- Browser automation: Login flows, form filling, sessions
- Vision AI: Free OpenRouter vision models with auto-fallback
- Diff tracking: Page change monitoring with webhooks
- Batch processing: Parallel scrape with templates
- SEO analysis: Title, meta, headings, keywords, readability
- Schema extraction: JSON-LD, Open Graph, microdata
- Export formats: JSON, CSV (RSS/TXT/SQL not yet implemented — see FEATURES.md)
- Rate limiting: Per-IP token bucket (default 120 RPM)
- Caching: LRU + Redis with TTL-based invalidation
- WebSocket streaming: Real-time job progress
- Circuit breaker: Per-domain backoff on failures
Architecture
┌─────────────┐ ┌──────────────┐ ┌──────────┐
│ Client │→ │ Pry API │→ │ Scraper │
│ (CLI/SDK) │ │ (FastAPI) │ │ Engine │
└─────────────┘ └──────┬───────┘ └────┬─────┘
│ ├─ Direct HTTP
│ ├─ FlareSolverr
│ ├─ Playwright
│ └─ Googlebot
│
┌────┴────┐
│ Cache │
│ Redis │
│ Rate │
└─────────┘
Deployment
Docker Compose (recommended)
docker compose up -d
Bare metal
pip install -e .
playwright install chromium
# Optional: docker run -d -p 8192:8191 ghcr.io/flaresolverr/flaresolverr
pry serve
Environment
| Variable | Default | Description |
|---|---|---|
PRY_URL |
http://localhost:8005 |
API endpoint for CLI |
TOR_ENABLED |
false |
Enable Tor routing |
PROXY_URL |
— | HTTP/SOCKS proxy URL |
RATE_LIMIT_RPM |
120 |
Requests per minute |
OPENROUTER_API_KEY |
— | For vision endpoint |
Development
make install # Install with dev deps
make dev # Start hot-reload server
make lint # ruff check
make format # ruff format
make typecheck # mypy
make test # pytest
make check # Full audit
License
Pry is dual-licensed to balance community openness with the protection of its advanced anti-detection and stealth capabilities.
-
Most code (core functionality, utilities, and standard modules) is licensed under the MIT License. This permissive license encourages community contributions and wide adoption. See LICENSE.
-
The anti-detection / stealth module is licensed under the BSL 1.1 (Business Source License). This module is free to use for non-production purposes; a commercial license is required for any production use. See LICENSE-BSL-STEALTH.
Additional Use Grant
Under the BSL 1.1, an Additional Use Grant permits free use of the stealth module for:
- Personal, non-commercial projects
- Non-production testing and evaluation
- Academic research
- Non-commercial open source software development
- A 90-day evaluation period for any other purpose
For commercial licensing (production use, enterprise deployment, or embedding in proprietary products), please contact: enterprise@rugmunch.io