agentfriendly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ """
2
+ agentfriendly — Python SDK
3
+
4
+ Make your Python web application agent-friendly.
5
+
6
+ Supports FastAPI, Django, Flask, and any ASGI/WSGI framework.
7
+
8
+ Quick start:
9
+ pip install agentfriendly[fastapi]
10
+
11
+ FastAPI:
12
+ from fastapi import FastAPI
13
+ from agentfriendly.adapters.fastapi import AgentFriendlyMiddleware
14
+ from agentfriendly import AgentFriendlyConfig
15
+
16
+ app = FastAPI()
17
+ app.add_middleware(AgentFriendlyMiddleware, config=AgentFriendlyConfig())
18
+ """
19
+
20
+ from .config import (
21
+ AccessConfig,
22
+ AgentFriendlyConfig,
23
+ AnalyticsConfig,
24
+ ContentConfig,
25
+ DetectionConfig,
26
+ DiscoveryConfig,
27
+ MonetizationConfig,
28
+ MultiTenancyConfig,
29
+ PrivacyConfig,
30
+ ToolsConfig,
31
+ )
32
+ from .middleware import (
33
+ AgentFriendlyMiddleware,
34
+ ContentInstructions,
35
+ EarlyResponse,
36
+ OrchestratorResult,
37
+ get_agent_context,
38
+ )
39
+ from .multitenancy import issue_delegation_token, revoke_session, validate_delegation_token
40
+ from .types import (
41
+ TIER_ORDER,
42
+ AgentContext,
43
+ AgentEntry,
44
+ DetectionSignal,
45
+ TenantContext,
46
+ TrustTier,
47
+ VerifiedIdentity,
48
+ meets_minimum_tier,
49
+ )
50
+
51
+ __all__ = [
52
+ # Config
53
+ "AgentFriendlyConfig",
54
+ "DetectionConfig",
55
+ "DiscoveryConfig",
56
+ "ContentConfig",
57
+ "AnalyticsConfig",
58
+ "AccessConfig",
59
+ "PrivacyConfig",
60
+ "ToolsConfig",
61
+ "MonetizationConfig",
62
+ "MultiTenancyConfig",
63
+ # Middleware
64
+ "AgentFriendlyMiddleware",
65
+ "get_agent_context",
66
+ "OrchestratorResult",
67
+ "EarlyResponse",
68
+ "ContentInstructions",
69
+ # Types
70
+ "TrustTier",
71
+ "DetectionSignal",
72
+ "AgentContext",
73
+ "AgentEntry",
74
+ "VerifiedIdentity",
75
+ "TenantContext",
76
+ "TIER_ORDER",
77
+ "meets_minimum_tier",
78
+ # Multi-tenancy
79
+ "issue_delegation_token",
80
+ "validate_delegation_token",
81
+ "revoke_session",
82
+ ]
@@ -0,0 +1,18 @@
1
+ """Layer 4 — Access Control"""
2
+
3
+ from .policy_engine import (
4
+ PolicyDecision,
5
+ PolicyResult,
6
+ evaluate_policy,
7
+ generate_robots_txt_ai_section,
8
+ )
9
+ from .rate_limiter import InMemoryRateLimiter, get_rate_limit_key
10
+
11
+ __all__ = [
12
+ "evaluate_policy",
13
+ "PolicyDecision",
14
+ "PolicyResult",
15
+ "generate_robots_txt_ai_section",
16
+ "InMemoryRateLimiter",
17
+ "get_rate_limit_key",
18
+ ]
@@ -0,0 +1,137 @@
1
+ """
2
+ Layer 4 — Access Control Policy Engine
3
+
4
+ Mirrors packages/core/src/access/policy-engine.ts
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+
13
+ from ..config import AccessConfig, AgentTypePolicy
14
+ from ..types import AgentContext
15
+
16
+ PolicyDecision = Literal["allow", "deny", "rate-limit"]
17
+
18
+ CATEGORY_UA_REPRESENTATIVES: dict[str, list[str]] = {
19
+ "training-crawler": [
20
+ "GPTBot", "ClaudeBot", "Google-Extended", "CCBot",
21
+ "Bytespider", "anthropic-ai", "Meta-ExternalAgent",
22
+ "Amazonbot", "Applebot-Extended", "cohere-ai", "AI2Bot",
23
+ ],
24
+ "search-bot": [
25
+ "OAI-SearchBot", "ChatGPT-User", "PerplexityBot",
26
+ "YouBot", "DuckAssistBot",
27
+ ],
28
+ "interactive-agent": [
29
+ "GoogleAgent-URLContext", "Claude-Web", "Claude-SearchBot",
30
+ ],
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PolicyResult:
36
+ decision: PolicyDecision
37
+ reason: str
38
+ status_code: int | None
39
+
40
+
41
+ _ALLOW = PolicyResult(decision="allow", reason="No matching deny rules", status_code=None)
42
+
43
+
44
+ def _deny(reason: str) -> PolicyResult:
45
+ return PolicyResult(decision="deny", reason=reason, status_code=403)
46
+
47
+
48
+ def _apply_agent_policy(
49
+ policy: AgentTypePolicy,
50
+ path: str,
51
+ allow_patterns: list[str],
52
+ ) -> PolicyResult | None:
53
+ """Apply a named policy. Returns None to continue evaluation, or a final result."""
54
+ if policy == "deny-all":
55
+ return _deny("Agent type policy: deny-all")
56
+ if policy == "allow-all":
57
+ return None
58
+ if policy == "allow-public":
59
+ if allow_patterns and not any(fnmatch.fnmatch(path, p) for p in allow_patterns):
60
+ return _deny("Agent type policy: allow-public — path not in allow list")
61
+ return None
62
+ return None
63
+
64
+
65
+ def evaluate_policy(context: AgentContext, config: AccessConfig) -> PolicyResult:
66
+ """Evaluate the access policy for an agent request."""
67
+ if not context.is_agent:
68
+ return _ALLOW
69
+
70
+ path = context.path
71
+ allow_patterns = config.allow or []
72
+
73
+ # 1. Per-operator overrides
74
+ if config.operators and context.matched_agent:
75
+ op_policy = config.operators.get(context.matched_agent.operator)
76
+ if op_policy:
77
+ result = _apply_agent_policy(op_policy, path, allow_patterns)
78
+ if result:
79
+ return result
80
+
81
+ # 2. Per-category overrides
82
+ if config.agent_types and context.agent_category:
83
+ cat_policy = config.agent_types.get(context.agent_category)
84
+ if cat_policy:
85
+ result = _apply_agent_policy(cat_policy, path, allow_patterns)
86
+ if result:
87
+ return result
88
+
89
+ # 3. Suspected-agent policy
90
+ if context.tier == "suspected-agent":
91
+ sus_policy = config.agent_types.get("suspected-agent") if config.agent_types else None
92
+ if sus_policy:
93
+ result = _apply_agent_policy(sus_policy, path, allow_patterns)
94
+ if result:
95
+ return result
96
+
97
+ # 4. Route-level deny rules
98
+ deny_patterns = config.deny or []
99
+ if deny_patterns and any(fnmatch.fnmatch(path, p) for p in deny_patterns):
100
+ if allow_patterns and any(fnmatch.fnmatch(path, p) for p in allow_patterns):
101
+ return _ALLOW
102
+ return _deny(f'Path "{path}" matches deny pattern')
103
+
104
+ return _ALLOW
105
+
106
+
107
+ def generate_robots_txt_ai_section(config: AccessConfig) -> str:
108
+ """Generate the AI/agent section of robots.txt from config."""
109
+ lines = [
110
+ "# AI Agent Access Control — generated by agentfriendly",
111
+ "# Modify these rules via access.agent_types in AgentFriendlyConfig",
112
+ "",
113
+ ]
114
+
115
+ for category, policy in (config.agent_types or {}).items():
116
+ reps = CATEGORY_UA_REPRESENTATIVES.get(str(category), [])
117
+ if not reps:
118
+ continue
119
+
120
+ lines.append(f"# {category} — policy: {policy}")
121
+ for ua in reps:
122
+ lines.append(f"User-agent: {ua}")
123
+
124
+ if policy == "deny-all":
125
+ lines.append("Disallow: /")
126
+ elif policy == "allow-public":
127
+ denied = [p for p in (config.deny or []) if "*" not in p]
128
+ for path in denied:
129
+ lines.append(f"Disallow: {path}")
130
+ if not denied:
131
+ lines.append("Allow: /")
132
+ else:
133
+ lines.append("Allow: /")
134
+
135
+ lines.append("")
136
+
137
+ return "\n".join(lines)
@@ -0,0 +1,66 @@
1
+ """Layer 4 — In-Memory Rate Limiter"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import defaultdict
7
+
8
+ from ..types import AgentContext
9
+
10
+
11
+ class InMemoryRateLimiter:
12
+ """
13
+ Simple sliding window rate limiter.
14
+ Thread-safe for single-process deployments.
15
+ """
16
+
17
+ def __init__(self, max_requests: int, window_seconds: int = 60) -> None:
18
+ self.max_requests = max_requests
19
+ self.window_seconds = window_seconds
20
+ self._windows: dict[str, list[float]] = defaultdict(list)
21
+
22
+ def check(self, key: str) -> bool:
23
+ """Returns True if the request is within the limit, False if rate-limited."""
24
+ now = time.monotonic()
25
+ window_start = now - self.window_seconds
26
+ timestamps = self._windows[key]
27
+
28
+ # Prune old timestamps
29
+ self._windows[key] = [t for t in timestamps if t > window_start]
30
+
31
+ if len(self._windows[key]) >= self.max_requests:
32
+ return False
33
+
34
+ self._windows[key].append(now)
35
+ return True
36
+
37
+ def get_count(self, key: str) -> int:
38
+ now = time.monotonic()
39
+ window_start = now - self.window_seconds
40
+ return sum(1 for t in self._windows[key] if t > window_start)
41
+
42
+ def clear(self) -> None:
43
+ self._windows.clear()
44
+
45
+
46
+ def get_rate_limit_key(
47
+ context: AgentContext,
48
+ key_by: str = "identity",
49
+ ) -> str:
50
+ """Derive the rate limit key from an agent context."""
51
+ if key_by == "ip":
52
+ return (
53
+ context.headers.get("x-forwarded-for", "").split(",")[0].strip()
54
+ or context.headers.get("x-real-ip", "unknown-ip")
55
+ )
56
+ if key_by == "ua":
57
+ return context.user_agent or "no-ua"
58
+ # Default: "identity"
59
+ if context.verified_identity:
60
+ return context.verified_identity.agent_id
61
+ if context.matched_agent:
62
+ return context.matched_agent.agent_name
63
+ return (
64
+ context.headers.get("x-forwarded-for", "").split(",")[0].strip()
65
+ or "unknown"
66
+ )
@@ -0,0 +1,3 @@
1
+ """
2
+ Framework adapters for the agentfriendly Python SDK.
3
+ """
@@ -0,0 +1,142 @@
1
+ """
2
+ Django Middleware Adapter
3
+
4
+ Add to MIDDLEWARE in settings.py:
5
+
6
+ MIDDLEWARE = [
7
+ "agentfriendly.adapters.django.AgentFriendlyMiddleware",
8
+ # ... other middleware
9
+ ]
10
+
11
+ Configure in settings.py:
12
+
13
+ AGENTFRIENDLY = {
14
+ "detection": {"proactive_markdown": "known"},
15
+ "content": {"markdown": True},
16
+ }
17
+
18
+ Access context in views:
19
+
20
+ from agentfriendly import get_agent_context
21
+
22
+ def my_view(request):
23
+ ctx = get_agent_context()
24
+ # or via request
25
+ ctx = getattr(request, "agent_context", None)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from django.conf import settings # type: ignore[import-untyped]
31
+ from django.http import HttpRequest, HttpResponse # type: ignore[import-untyped]
32
+
33
+ from ..config import AgentFriendlyConfig
34
+ from ..content.html_to_markdown import html_to_markdown
35
+ from ..middleware import AgentFriendlyMiddleware as CoreMiddleware
36
+
37
+
38
+ def _load_config_from_settings() -> AgentFriendlyConfig:
39
+ """Load config from Django settings.AGENTFRIENDLY dict."""
40
+ raw = getattr(settings, "AGENTFRIENDLY", {})
41
+ if not isinstance(raw, dict):
42
+ return AgentFriendlyConfig()
43
+ return _dict_to_config(raw)
44
+
45
+
46
+ def _dict_to_config(raw: dict[str, object]) -> AgentFriendlyConfig:
47
+ """Convert a plain settings dict to AgentFriendlyConfig."""
48
+ from ..config import (
49
+ AccessConfig,
50
+ AnalyticsConfig,
51
+ ContentConfig,
52
+ DetectionConfig,
53
+ DiscoveryConfig,
54
+ MonetizationConfig,
55
+ MultiTenancyConfig,
56
+ PrivacyConfig,
57
+ ToolsConfig,
58
+ )
59
+
60
+ def section(name: str, cls): # type: ignore[no-untyped-def]
61
+ data = raw.get(name, {})
62
+ if isinstance(data, dict):
63
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
64
+ return cls()
65
+
66
+ return AgentFriendlyConfig(
67
+ detection=section("detection", DetectionConfig),
68
+ discovery=section("discovery", DiscoveryConfig),
69
+ content=section("content", ContentConfig),
70
+ analytics=section("analytics", AnalyticsConfig),
71
+ access=section("access", AccessConfig),
72
+ privacy=section("privacy", PrivacyConfig),
73
+ tools=section("tools", ToolsConfig),
74
+ monetization=section("monetization", MonetizationConfig),
75
+ multi_tenancy=section("multi_tenancy", MultiTenancyConfig),
76
+ debug=bool(raw.get("debug", False)),
77
+ )
78
+
79
+
80
+ class AgentFriendlyMiddleware:
81
+ """Django middleware class (get_response pattern)."""
82
+
83
+ def __init__(self, get_response): # type: ignore[no-untyped-def]
84
+ self.get_response = get_response
85
+ config = _load_config_from_settings()
86
+ self._sdk = CoreMiddleware(config)
87
+
88
+ def __call__(self, request: HttpRequest): # type: ignore[no-untyped-def]
89
+ import asyncio
90
+
91
+ # Run the async SDK process in the sync Django context
92
+ loop = asyncio.new_event_loop()
93
+ try:
94
+ result = loop.run_until_complete(
95
+ self._sdk.process(
96
+ method=request.method,
97
+ path=request.path,
98
+ headers={k.lower(): v for k, v in request.META.items() if k.startswith("HTTP_")},
99
+ url=request.build_absolute_uri(),
100
+ )
101
+ )
102
+ finally:
103
+ loop.close()
104
+
105
+ # Attach context to the request
106
+ request.agent_context = result.context # type: ignore[attr-defined]
107
+
108
+ # Serve early responses
109
+ if result.early_response:
110
+ er = result.early_response
111
+ response = HttpResponse(
112
+ content=er.body,
113
+ status=er.status,
114
+ content_type=er.content_type,
115
+ )
116
+ for key, value in er.headers.items():
117
+ response[key] = value
118
+ return response
119
+
120
+ # Let Django handle the request
121
+ response = self.get_response(request)
122
+
123
+ # Inject agent headers
124
+ for key, value in result.content_instructions.agent_headers.items():
125
+ response[key] = value
126
+
127
+ # Convert HTML→Markdown for agent requests
128
+ if result.content_instructions.convert_to_markdown:
129
+ ct = response.get("Content-Type", "")
130
+ if "text/html" in ct:
131
+ body = response.content.decode("utf-8", errors="replace")
132
+ md_result = html_to_markdown(
133
+ body,
134
+ request.build_absolute_uri(),
135
+ result.content_instructions.additional_strip_selectors,
136
+ )
137
+ response.content = md_result.markdown.encode("utf-8")
138
+ response["Content-Type"] = "text/markdown; charset=utf-8"
139
+ response["x-markdown-tokens"] = str(md_result.estimated_tokens)
140
+ del response["Content-Length"]
141
+
142
+ return response
@@ -0,0 +1,113 @@
1
+ """
2
+ FastAPI / Starlette Adapter
3
+
4
+ Add as ASGI middleware:
5
+
6
+ from agentfriendly.adapters.fastapi import AgentFriendlyMiddleware as AFMiddleware
7
+ from agentfriendly import AgentFriendlyConfig
8
+
9
+ app.add_middleware(
10
+ AFMiddleware,
11
+ config=AgentFriendlyConfig(
12
+ detection=DetectionConfig(proactive_markdown="known"),
13
+ ),
14
+ )
15
+
16
+ Access context in route handlers:
17
+
18
+ from agentfriendly import get_agent_context
19
+
20
+ @app.get("/docs")
21
+ async def docs(request: Request):
22
+ ctx = get_agent_context()
23
+ return {"is_agent": ctx.is_agent if ctx else False}
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Awaitable, Callable
29
+
30
+ from starlette.middleware.base import BaseHTTPMiddleware
31
+ from starlette.requests import Request
32
+ from starlette.responses import Response
33
+
34
+ from ..config import AgentFriendlyConfig
35
+ from ..content.html_to_markdown import html_to_markdown
36
+ from ..middleware import AgentFriendlyMiddleware as CoreMiddleware
37
+
38
+
39
+ class AgentFriendlyMiddleware(BaseHTTPMiddleware):
40
+ """
41
+ Starlette/FastAPI ASGI middleware.
42
+ Compatible with FastAPI, Starlette, and any ASGI framework.
43
+ """
44
+
45
+ def __init__(self, app: object, config: AgentFriendlyConfig | None = None) -> None:
46
+ super().__init__(app) # type: ignore[arg-type]
47
+ self._sdk = CoreMiddleware(config)
48
+
49
+ async def dispatch(
50
+ self,
51
+ request: Request,
52
+ call_next: Callable[[Request], Awaitable[Response]],
53
+ ) -> Response:
54
+ # Build header dict (lowercased)
55
+ headers = {k.lower(): v for k, v in request.headers.items()}
56
+ path = request.url.path
57
+ url = str(request.url)
58
+
59
+ result = await self._sdk.process(
60
+ method=request.method,
61
+ path=path,
62
+ headers=headers,
63
+ url=url,
64
+ )
65
+
66
+ # Attach context to request state for downstream access
67
+ request.state.agent_context = result.context
68
+
69
+ # Serve early responses directly
70
+ if result.early_response:
71
+ er = result.early_response
72
+ return Response(
73
+ content=er.body,
74
+ status_code=er.status,
75
+ headers=er.headers,
76
+ media_type=er.content_type,
77
+ )
78
+
79
+ # Let the route handler produce a response
80
+ response = await call_next(request)
81
+
82
+ # Inject agent headers
83
+ for key, value in result.content_instructions.agent_headers.items():
84
+ response.headers[key] = value
85
+
86
+ # Convert HTML→Markdown for agent requests
87
+ if result.content_instructions.convert_to_markdown:
88
+ content_type = response.headers.get("content-type", "")
89
+ if "text/html" in content_type:
90
+ body_bytes = b""
91
+ async for chunk in response.body_iterator: # type: ignore[attr-defined]
92
+ body_bytes += chunk if isinstance(chunk, bytes) else chunk.encode()
93
+ html_body = body_bytes.decode("utf-8", errors="replace")
94
+
95
+ md_result = html_to_markdown(
96
+ html_body,
97
+ url,
98
+ result.content_instructions.additional_strip_selectors,
99
+ )
100
+
101
+ new_headers = dict(response.headers)
102
+ new_headers["content-type"] = "text/markdown; charset=utf-8"
103
+ new_headers["x-markdown-tokens"] = str(md_result.estimated_tokens)
104
+ new_headers.pop("content-length", None)
105
+
106
+ return Response(
107
+ content=md_result.markdown.encode("utf-8"),
108
+ status_code=response.status_code,
109
+ headers=new_headers,
110
+ media_type="text/markdown",
111
+ )
112
+
113
+ return response
@@ -0,0 +1,99 @@
1
+ """
2
+ Flask Adapter
3
+
4
+ Usage:
5
+
6
+ from flask import Flask
7
+ from agentfriendly.adapters.flask import init_app
8
+ from agentfriendly import AgentFriendlyConfig
9
+
10
+ app = Flask(__name__)
11
+ init_app(
12
+ app,
13
+ config=AgentFriendlyConfig(
14
+ detection=DetectionConfig(proactive_markdown="known"),
15
+ ),
16
+ )
17
+
18
+ Access context in views:
19
+
20
+ from agentfriendly import get_agent_context
21
+
22
+ @app.route("/docs")
23
+ def docs():
24
+ ctx = get_agent_context()
25
+ from flask import g
26
+ ctx = g.agent_context # also available on Flask's g object
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+
33
+ from flask import Flask, Response, g, make_response # type: ignore[import-untyped]
34
+ from flask import request as flask_request
35
+
36
+ from ..config import AgentFriendlyConfig
37
+ from ..content.html_to_markdown import html_to_markdown
38
+ from ..middleware import AgentFriendlyMiddleware as CoreMiddleware
39
+
40
+
41
+ def init_app(app: Flask, config: AgentFriendlyConfig | None = None) -> None:
42
+ """Register AgentFriendly before/after request hooks on a Flask app."""
43
+ sdk = CoreMiddleware(config)
44
+
45
+ @app.before_request
46
+ def before_request() -> Response | None: # type: ignore[return]
47
+ headers = {k.lower(): v for k, v in flask_request.headers.items()}
48
+ loop = asyncio.new_event_loop()
49
+ try:
50
+ result = loop.run_until_complete(
51
+ sdk.process(
52
+ method=flask_request.method,
53
+ path=flask_request.path,
54
+ headers=headers,
55
+ url=flask_request.url,
56
+ )
57
+ )
58
+ finally:
59
+ loop.close()
60
+
61
+ g.agent_result = result
62
+ g.agent_context = result.context
63
+
64
+ # Serve early responses before the route handler
65
+ if result.early_response:
66
+ er = result.early_response
67
+ resp = make_response(er.body, er.status)
68
+ resp.content_type = er.content_type
69
+ for key, value in er.headers.items():
70
+ resp.headers[key] = value
71
+ return resp
72
+
73
+ return None
74
+
75
+ @app.after_request
76
+ def after_request(response: Response) -> Response:
77
+ result = getattr(g, "agent_result", None)
78
+ if not result:
79
+ return response
80
+
81
+ # Inject agent headers
82
+ for key, value in result.content_instructions.agent_headers.items():
83
+ response.headers[key] = value
84
+
85
+ # Convert HTML→Markdown for agent requests
86
+ if result.content_instructions.convert_to_markdown:
87
+ ct = response.content_type or ""
88
+ if "text/html" in ct:
89
+ body = response.get_data(as_text=True)
90
+ md_result = html_to_markdown(
91
+ body,
92
+ flask_request.url,
93
+ result.content_instructions.additional_strip_selectors,
94
+ )
95
+ response.set_data(md_result.markdown)
96
+ response.content_type = "text/markdown; charset=utf-8"
97
+ response.headers["x-markdown-tokens"] = str(md_result.estimated_tokens)
98
+
99
+ return response