polari-sdk 0.2.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.
polari/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """Polari SDK - Official Python SDK for Polari API.
2
+
3
+ Example:
4
+ >>> from polari import PolariClient, ArticleInput
5
+ >>>
6
+ >>> client = PolariClient(api_key="pk_...")
7
+ >>> article = ArticleInput(
8
+ ... title="Breaking News",
9
+ ... content="Article content here...",
10
+ ... url="https://example.com/article"
11
+ ... )
12
+ >>> result = await client.layer0.analyze(article)
13
+ >>> print(f"Quality: {result.quality_score}")
14
+ """
15
+
16
+ from .client import PolariClient
17
+ from .config import PolariConfig
18
+ from .exceptions import (
19
+ AuthenticationError,
20
+ ConfigurationError,
21
+ NetworkError,
22
+ PolariAPIError,
23
+ PolariError,
24
+ ProcessingError,
25
+ RateLimitError,
26
+ RetryExhaustedError,
27
+ ServerError,
28
+ TimeoutError,
29
+ ValidationError,
30
+ )
31
+ from .types import (
32
+ # Input types
33
+ ArticleInput,
34
+ # Layer result types
35
+ Layer0Result,
36
+ Layer1Result,
37
+ # Layer 2 result types
38
+ ClusterResult,
39
+ ClusterBatchResult,
40
+ ClusterSummary,
41
+ ClusterStats,
42
+ ClusterMember,
43
+ # Shared types
44
+ HealthStatus,
45
+ Metrics,
46
+ CostSummary,
47
+ # Deprecated — retained for import compatibility, will be removed in v1.0
48
+ Layer2Result,
49
+ Layer3Result,
50
+ GraphNode,
51
+ GraphEdge,
52
+ Entity,
53
+ )
54
+
55
+ __version__ = "0.2.0"
56
+
57
+ __all__ = [
58
+ # Main client
59
+ "PolariClient",
60
+ "PolariConfig",
61
+ # Input types
62
+ "ArticleInput",
63
+ # Layer 0 results
64
+ "Layer0Result",
65
+ # Layer 1 results
66
+ "Layer1Result",
67
+ # Layer 2 results
68
+ "ClusterResult",
69
+ "ClusterBatchResult",
70
+ "ClusterSummary",
71
+ "ClusterStats",
72
+ "ClusterMember",
73
+ # Shared types
74
+ "HealthStatus",
75
+ "Metrics",
76
+ "CostSummary",
77
+ # Exceptions
78
+ "PolariError",
79
+ "PolariAPIError",
80
+ "AuthenticationError",
81
+ "RateLimitError",
82
+ "ValidationError",
83
+ "ServerError",
84
+ "NetworkError",
85
+ "TimeoutError",
86
+ "ConfigurationError",
87
+ "ProcessingError",
88
+ "RetryExhaustedError",
89
+ # Deprecated — will be removed in v1.0
90
+ "Layer2Result",
91
+ "Layer3Result",
92
+ "GraphNode",
93
+ "GraphEdge",
94
+ "Entity",
95
+ ]
polari/client.py ADDED
@@ -0,0 +1,190 @@
1
+ """Polari SDK main client."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ import httpx
7
+
8
+ from .config import PolariConfig
9
+ from .exceptions import ConfigurationError
10
+ from .layers.layer0 import Layer0
11
+ from .types import CostSummary, HealthStatus, Metrics
12
+ from .utils import CostTracker, MetricsCollector
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PolariClient:
18
+ """Main entry point for Polari SDK.
19
+
20
+ Example:
21
+ >>> client = PolariClient(api_key="pk_...")
22
+ >>> result = await client.layer0.analyze(article)
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str,
28
+ base_url: str = "http://localhost:8000",
29
+ timeout: int = 30,
30
+ max_retries: int = 3,
31
+ enable_metrics: bool = True,
32
+ enable_cost_tracking: bool = True,
33
+ log_level: str = "INFO",
34
+ **config_overrides,
35
+ ):
36
+ """Initialize Polari SDK client.
37
+
38
+ Args:
39
+ api_key: Polari API key
40
+ base_url: Base API URL (Layer 0). Layer 1-3 URLs come from config or defaults.
41
+ timeout: Request timeout in seconds
42
+ max_retries: Maximum retry attempts
43
+ enable_metrics: Enable performance metrics collection
44
+ enable_cost_tracking: Enable cost tracking
45
+ log_level: Logging level
46
+ **config_overrides: Additional config overrides (e.g. layer1_url, layer2_url)
47
+ """
48
+ logging.basicConfig(
49
+ level=getattr(logging, log_level.upper()),
50
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
51
+ )
52
+
53
+ self.config = PolariConfig(
54
+ api_key=api_key,
55
+ base_url=base_url,
56
+ timeout=timeout,
57
+ max_retries=max_retries,
58
+ enable_metrics=enable_metrics,
59
+ enable_cost_tracking=enable_cost_tracking,
60
+ log_level=log_level,
61
+ **config_overrides,
62
+ )
63
+
64
+ self._metrics = MetricsCollector(enabled=enable_metrics)
65
+ self._cost_tracker = CostTracker(enabled=enable_cost_tracking)
66
+
67
+ self._http_client = httpx.AsyncClient(
68
+ headers={
69
+ "Authorization": f"Bearer {api_key}",
70
+ "Content-Type": "application/json",
71
+ "User-Agent": "polari-sdk-python/0.2.0",
72
+ },
73
+ timeout=httpx.Timeout(
74
+ timeout=timeout,
75
+ connect=self.config.connect_timeout,
76
+ read=self.config.read_timeout,
77
+ ),
78
+ )
79
+
80
+ # Lazy-initialized layer instances
81
+ self._layer0 = None
82
+ self._layer1 = None
83
+ self._layer2 = None
84
+ self._layer3 = None
85
+
86
+ @property
87
+ def layer0(self) -> "Layer0":
88
+ """Layer 0: Token Intelligence (port 8003)."""
89
+ if self._layer0 is None:
90
+ from polari.layers.layer0 import Layer0
91
+ self._layer0 = Layer0(self)
92
+ return self._layer0
93
+
94
+ @property
95
+ def layer1(self):
96
+ """Layer 1: Semantic Analysis (port 8004)."""
97
+ if self._layer1 is None:
98
+ from polari.layers.layer1 import Layer1
99
+ layer1_url = getattr(self.config, "layer1_url", "http://localhost:8004")
100
+ self._layer1 = Layer1(self, base_url=layer1_url)
101
+ return self._layer1
102
+
103
+ @property
104
+ def layer2(self):
105
+ """Layer 2: Story Clustering (port 8005)."""
106
+ if self._layer2 is None:
107
+ from polari.layers.layer2 import Layer2
108
+ layer2_url = getattr(self.config, "layer2_url", "http://localhost:8005")
109
+ self._layer2 = Layer2(self, base_url=layer2_url)
110
+ return self._layer2
111
+
112
+ @property
113
+ def layer3(self):
114
+ """Layer 3: Intelligence Graph (port 8006)."""
115
+ if self._layer3 is None:
116
+ from polari.layers.layer3 import Layer3
117
+ layer3_url = getattr(self.config, "layer3_url", "http://localhost:8006")
118
+ self._layer3 = Layer3(self, base_url=layer3_url)
119
+ return self._layer3
120
+
121
+ async def health_check(self) -> HealthStatus:
122
+ """Check API health across all four layers concurrently.
123
+
124
+ Returns:
125
+ HealthStatus with per-layer results and overall status:
126
+ healthy — all four layers responding
127
+ degraded — at least one layer responding
128
+ down — no layers responding
129
+ """
130
+ import asyncio
131
+ import time
132
+
133
+ start = time.time()
134
+ results = await asyncio.gather(
135
+ self.layer0.health_check(),
136
+ self.layer1.health_check(),
137
+ self.layer2.health_check(),
138
+ self.layer3.health_check(),
139
+ return_exceptions=True,
140
+ )
141
+ layers_status = {
142
+ "layer0": results[0] is True,
143
+ "layer1": results[1] is True,
144
+ "layer2": results[2] is True,
145
+ "layer3": results[3] is True,
146
+ }
147
+ latency_ms = (time.time() - start) * 1000
148
+
149
+ if all(layers_status.values()):
150
+ status = "healthy"
151
+ elif any(layers_status.values()):
152
+ status = "degraded"
153
+ else:
154
+ status = "down"
155
+
156
+ return HealthStatus(status=status, layers=layers_status, latency_ms=latency_ms)
157
+
158
+
159
+ def get_metrics(self) -> Metrics:
160
+ """Get performance metrics."""
161
+ return self._metrics.get_metrics()
162
+
163
+ def get_cost_summary(self) -> CostSummary:
164
+ """Get API cost summary."""
165
+ return self._cost_tracker.get_cost_summary()
166
+
167
+ async def close(self) -> None:
168
+ """Close HTTP client and cleanup resources."""
169
+ await self._http_client.aclose()
170
+ logger.info("PolariClient closed")
171
+
172
+ async def __aenter__(self) -> "PolariClient":
173
+ return self
174
+
175
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
176
+ await self.close()
177
+
178
+ @classmethod
179
+ def from_env(cls, **overrides) -> "PolariClient":
180
+ """Create client from environment variables (POLARI_API_KEY)."""
181
+ config = PolariConfig.from_env(**overrides)
182
+ return cls(
183
+ api_key=config.api_key,
184
+ base_url=config.base_url,
185
+ timeout=config.timeout,
186
+ max_retries=config.max_retries,
187
+ enable_metrics=config.enable_metrics,
188
+ enable_cost_tracking=config.enable_cost_tracking,
189
+ log_level=config.log_level,
190
+ )
polari/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """Configuration management for Polari SDK."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional, Tuple
6
+
7
+ from .exceptions import ConfigurationError
8
+
9
+
10
+ @dataclass
11
+ class PolariConfig:
12
+ """SDK Configuration."""
13
+
14
+ # Authentication
15
+ api_key: str
16
+ base_url: str = "https://api.polariapi.com"
17
+ layer0_url: str = "https://layer0.api.polariapi.com"
18
+ layer1_url: str = "https://layer1.api.polariapi.com"
19
+ layer2_url: str = "https://layer2.api.polariapi.com"
20
+ layer3_url: str = "https://layer3.api.polariapi.com"
21
+ # Timeouts (seconds)
22
+ timeout: int = 30
23
+ connect_timeout: int = 10
24
+ read_timeout: int = 30
25
+
26
+ # Retry policy
27
+ max_retries: int = 3
28
+ retry_backoff_factor: float = 1.0
29
+ retry_statuses: Tuple[int, ...] = (429, 500, 502, 503, 504)
30
+
31
+ # Batch processing
32
+ default_batch_size: int = 10
33
+ max_batch_size: int = 50
34
+
35
+ # Features
36
+ enable_metrics: bool = True
37
+ enable_cost_tracking: bool = True
38
+ enable_caching: bool = False
39
+
40
+ # Logging
41
+ log_level: str = "INFO"
42
+ log_requests: bool = False
43
+ log_responses: bool = False
44
+
45
+ # Layer-specific thresholds
46
+ layer0_quality_threshold: float = 0.55
47
+ layer2_similarity_threshold: float = 0.7
48
+ layer2_min_cluster_size: int = 2
49
+
50
+ # Internal
51
+ _validated: bool = field(default=False, init=False, repr=False)
52
+
53
+ def __post_init__(self) -> None:
54
+ """Validate configuration after initialization."""
55
+ if not self._validated:
56
+ self._validate()
57
+ self._validated = True
58
+
59
+ def _validate(self) -> None:
60
+ """Validate configuration values."""
61
+ if not self.api_key:
62
+ raise ConfigurationError("API key is required")
63
+
64
+ if self.timeout <= 0:
65
+ raise ConfigurationError("Timeout must be positive")
66
+
67
+ if self.max_retries < 0:
68
+ raise ConfigurationError("Max retries cannot be negative")
69
+
70
+ if not 0.0 <= self.layer0_quality_threshold <= 1.0:
71
+ raise ConfigurationError("Quality threshold must be between 0 and 1")
72
+
73
+ if not 0.0 <= self.layer2_similarity_threshold <= 1.0:
74
+ raise ConfigurationError("Similarity threshold must be between 0 and 1")
75
+
76
+ if self.layer2_min_cluster_size < 2:
77
+ raise ConfigurationError("Min cluster size must be at least 2")
78
+
79
+ if self.default_batch_size > self.max_batch_size:
80
+ raise ConfigurationError("Default batch size cannot exceed max batch size")
81
+
82
+ @classmethod
83
+ def from_env(cls, **overrides: any) -> "PolariConfig":
84
+ """Load configuration from environment variables.
85
+
86
+ Args:
87
+ **overrides: Override specific config values
88
+
89
+ Returns:
90
+ PolariConfig instance
91
+
92
+ Environment Variables:
93
+ POLARI_API_KEY: API key (required)
94
+ POLARI_BASE_URL: Base API URL
95
+ POLARI_TIMEOUT: Request timeout in seconds
96
+ POLARI_MAX_RETRIES: Maximum retry attempts
97
+ POLARI_LOG_LEVEL: Logging level
98
+ """
99
+ api_key = os.getenv("POLARI_API_KEY")
100
+ if not api_key and "api_key" not in overrides:
101
+ raise ConfigurationError("POLARI_API_KEY environment variable not set")
102
+
103
+ return cls(
104
+ api_key=overrides.get("api_key", api_key or ""),
105
+ base_url=overrides.get("base_url", os.getenv("POLARI_BASE_URL", cls.base_url)),
106
+ timeout=int(overrides.get("timeout", os.getenv("POLARI_TIMEOUT", cls.timeout))),
107
+ max_retries=int(
108
+ overrides.get("max_retries", os.getenv("POLARI_MAX_RETRIES", cls.max_retries))
109
+ ),
110
+ log_level=overrides.get("log_level", os.getenv("POLARI_LOG_LEVEL", cls.log_level)),
111
+ **{k: v for k, v in overrides.items() if k not in ["api_key", "base_url", "timeout", "max_retries", "log_level"]},
112
+ )
polari/exceptions.py ADDED
@@ -0,0 +1,90 @@
1
+ """Polari SDK exception hierarchy."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class PolariError(Exception):
7
+ """Base exception for all Polari SDK errors."""
8
+
9
+ pass
10
+
11
+
12
+ class PolariAPIError(PolariError):
13
+ """API returned an error response."""
14
+
15
+ def __init__(self, status_code: int, message: str, response: Optional[Dict[str, Any]] = None):
16
+ self.status_code = status_code
17
+ self.message = message
18
+ self.response = response or {}
19
+ super().__init__(f"API Error {status_code}: {message}")
20
+
21
+
22
+ class RateLimitError(PolariAPIError):
23
+ """Rate limit exceeded (429)."""
24
+
25
+ def __init__(self, message: str = "Rate limit exceeded", response: Optional[Dict[str, Any]] = None):
26
+ super().__init__(429, message, response)
27
+
28
+
29
+ class AuthenticationError(PolariAPIError):
30
+ """Authentication failed (401)."""
31
+
32
+ def __init__(self, message: str = "Authentication failed", response: Optional[Dict[str, Any]] = None):
33
+ super().__init__(401, message, response)
34
+
35
+
36
+ class ValidationError(PolariAPIError):
37
+ """Request validation failed (400/422)."""
38
+
39
+ def __init__(
40
+ self,
41
+ message: str = "Validation failed",
42
+ status_code: int = 400,
43
+ response: Optional[Dict[str, Any]] = None,
44
+ ):
45
+ super().__init__(status_code, message, response)
46
+
47
+
48
+ class ServerError(PolariAPIError):
49
+ """Server error (5xx)."""
50
+
51
+ def __init__(
52
+ self,
53
+ message: str = "Server error",
54
+ status_code: int = 500,
55
+ response: Optional[Dict[str, Any]] = None,
56
+ ):
57
+ super().__init__(status_code, message, response)
58
+
59
+
60
+ class NetworkError(PolariError):
61
+ """Network connectivity issue."""
62
+
63
+ pass
64
+
65
+
66
+ class TimeoutError(PolariError):
67
+ """Request timeout."""
68
+
69
+ pass
70
+
71
+
72
+ class ConfigurationError(PolariError):
73
+ """Invalid configuration."""
74
+
75
+ pass
76
+
77
+
78
+ class ProcessingError(PolariError):
79
+ """Error during article processing."""
80
+
81
+ pass
82
+
83
+
84
+ class RetryExhaustedError(PolariError):
85
+ """Max retry attempts exhausted."""
86
+
87
+ def __init__(self, attempts: int, last_error: Optional[Exception] = None):
88
+ self.attempts = attempts
89
+ self.last_error = last_error
90
+ super().__init__(f"Retry exhausted after {attempts} attempts")
@@ -0,0 +1,5 @@
1
+ """Layer implementations."""
2
+
3
+ from .layer0 import Layer0
4
+
5
+ __all__ = ["Layer0"]
polari/layers/base.py ADDED
@@ -0,0 +1,144 @@
1
+ """Base layer interface for all Polari layers."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import httpx
8
+
9
+ from ..config import PolariConfig
10
+ from ..exceptions import (
11
+ AuthenticationError,
12
+ NetworkError,
13
+ PolariAPIError,
14
+ RateLimitError,
15
+ ServerError,
16
+ TimeoutError,
17
+ ValidationError,
18
+ )
19
+ from ..utils import CostTracker, MetricsCollector
20
+ from ..utils.retry import ExponentialBackoff
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class BaseLayer(ABC):
26
+ """Abstract base class for all layer implementations."""
27
+
28
+ def __init__(
29
+ self,
30
+ http_client: httpx.AsyncClient,
31
+ config: PolariConfig,
32
+ metrics: MetricsCollector,
33
+ cost_tracker: CostTracker,
34
+ ):
35
+ """Initialize base layer.
36
+
37
+ Args:
38
+ http_client: Shared HTTP client
39
+ config: SDK configuration
40
+ metrics: Metrics collector
41
+ cost_tracker: Cost tracker
42
+ """
43
+ self.http_client = http_client
44
+ self.config = config
45
+ self.metrics = metrics
46
+ self.cost_tracker = cost_tracker
47
+ self.layer_name = self.__class__.__name__.lower()
48
+
49
+ @abstractmethod
50
+ def get_endpoint(self) -> str:
51
+ """Get the API endpoint for this layer.
52
+
53
+ Returns:
54
+ Endpoint path (e.g., "/v1/layer0/process")
55
+ """
56
+ pass
57
+
58
+ async def _make_request(
59
+ self,
60
+ method: str,
61
+ endpoint: str,
62
+ data: Optional[Dict[str, Any]] = None,
63
+ params: Optional[Dict[str, Any]] = None,
64
+ ) -> Dict[str, Any]:
65
+ """Make HTTP request with error handling and exponential backoff retry.
66
+
67
+ Args:
68
+ method: HTTP method (GET, POST, etc.)
69
+ endpoint: API endpoint path
70
+ data: Request body data
71
+ params: Query parameters
72
+
73
+ Returns:
74
+ Response JSON
75
+
76
+ Raises:
77
+ PolariAPIError: On API errors
78
+ NetworkError: On network errors
79
+ TimeoutError: On request timeout
80
+ """
81
+ url = f"{getattr(self, 'base_url', self.config.base_url)}{endpoint}"
82
+
83
+ retry = ExponentialBackoff(
84
+ max_retries=self.config.max_retries,
85
+ base_delay=self.config.retry_backoff_factor,
86
+ )
87
+
88
+ async def _do_request():
89
+ try:
90
+ response = await self.http_client.request(
91
+ method=method,
92
+ url=url,
93
+ json=data,
94
+ params=params,
95
+ timeout=self.config.timeout,
96
+ )
97
+
98
+ if response.status_code >= 400:
99
+ # Only retry 429 (rate limit) and 5xx (server errors)
100
+ # Never retry 401/403/400/422 - these won't change on retry
101
+ if response.status_code in (401, 403, 400, 404, 422):
102
+
103
+ self._handle_error_response(response) # Raises, NOT retried
104
+ else:
105
+ self._handle_error_response(response) # 429/5xx - retried
106
+
107
+ return response.json()
108
+
109
+ except httpx.TimeoutException as e:
110
+ logger.error(f"Request timeout: {e}")
111
+ raise TimeoutError(f"Request timeout after {self.config.timeout}s") from e
112
+ except httpx.NetworkError as e:
113
+ logger.error(f"Network error: {e}")
114
+ raise NetworkError(f"Network error: {e}") from e
115
+
116
+ return await retry.execute(_do_request)
117
+
118
+ def _handle_error_response(self, response: httpx.Response) -> None:
119
+ """Handle error HTTP responses.
120
+
121
+ Args:
122
+ response: HTTP response object
123
+
124
+ Raises:
125
+ Appropriate PolariAPIError subclass
126
+ """
127
+ try:
128
+ error_data = response.json()
129
+ message = error_data.get("detail", error_data.get("message", "Unknown error"))
130
+ except Exception:
131
+ message = response.text or "Unknown error"
132
+
133
+ status_code = response.status_code
134
+
135
+ if status_code == 401:
136
+ raise AuthenticationError(message, response=error_data if 'error_data' in locals() else None)
137
+ elif status_code == 429:
138
+ raise RateLimitError(message, response=error_data if 'error_data' in locals() else None)
139
+ elif status_code in (400, 422):
140
+ raise ValidationError(message, status_code, response=error_data if 'error_data' in locals() else None)
141
+ elif status_code >= 500:
142
+ raise ServerError(message, status_code, response=error_data if 'error_data' in locals() else None)
143
+ else:
144
+ raise PolariAPIError(status_code, message, response=error_data if 'error_data' in locals() else None)