olostep 0.9.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.
olostep/.DS_Store ADDED
Binary file
olostep/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ # Core clients
2
+ from .clients.async_client import OlostepClient
3
+ from .clients.sync_client import SyncOlostepClient
4
+
5
+ # Stateful result objects
6
+ from .frontend.client_state import ScrapeResult, BatchItem, CrawlPage, Crawl, CrawlInfo, Sitemap
7
+
8
+ # Type system
9
+ from .models.request import (
10
+ Format,
11
+ Country,
12
+ RetrieveFormat,
13
+ WaitAction,
14
+ ClickAction,
15
+ FillInputAction,
16
+ ScrollAction,
17
+ Parser,
18
+ LLMExtract,
19
+ LinksOnPage,
20
+ ScreenSize,
21
+ Transformer,
22
+ )
23
+
24
+ # Error hierarchy
25
+ from . import errors
26
+
27
+ __version__ = "0.9.0"
28
+
29
+ # Get all error classes dynamically
30
+ _error_classes = [name for name in dir(errors) if not name.startswith('_') and name.endswith('Error')]
31
+
32
+ __all__ = [
33
+ # Clients
34
+ "OlostepClient",
35
+ "SyncOlostepClient",
36
+ # Result objects
37
+ "ScrapeResult",
38
+ "BatchItem",
39
+ "CrawlPage",
40
+ "Crawl",
41
+ "CrawlInfo",
42
+ "Sitemap",
43
+ # Types
44
+ "Format",
45
+ "Country",
46
+ "RetrieveFormat",
47
+ "WaitAction",
48
+ "ClickAction",
49
+ "FillInputAction",
50
+ "ScrollAction",
51
+ "Parser",
52
+ "LLMExtract",
53
+ "LinksOnPage",
54
+ "ScreenSize",
55
+ "Transformer",
56
+ # Error classes
57
+ *_error_classes,
58
+ # Version
59
+ "__version__",
60
+ ]
olostep/_log.py ADDED
@@ -0,0 +1,238 @@
1
+ """
2
+ Modern logging configuration for Olostep SDK.
3
+
4
+ Provides library-safe logging with secret redaction and structured IO logging.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Iterable
15
+ from .config import IO_LOG_PATH
16
+ # Top-level package logger
17
+ logger = logging.getLogger("olostep")
18
+ logger.addHandler(logging.NullHandler()) # library-safe default
19
+ logger.propagate = True # let app handlers catch logs
20
+
21
+ # Sub-loggers (optional convenience)
22
+ io_logger = logging.getLogger("olostep.backend.io")
23
+ io_logger.propagate = True # it's the default, just to be explicit
24
+
25
+
26
+ class RedactSecretsFilter(logging.Filter):
27
+ """
28
+ Redacts common secrets in log records. Users may attach this to their handlers.
29
+ """
30
+ SECRET_PATTERNS: Iterable[re.Pattern[str]] = (
31
+ re.compile(r"(Authorization:\s*Bearer\s+)[A-Za-z0-9\-\._~\+\/]+=*", re.I),
32
+ re.compile(r"(api[_-]?key=)[A-Za-z0-9]{10,}", re.I),
33
+ re.compile(r"(token=)[A-Za-z0-9\-_\.]{10,}", re.I),
34
+ re.compile(r"(\"authorization\":\s*\"Bearer\s+)[A-Za-z0-9\-\._~\+\/]+=*", re.I),
35
+ re.compile(r"(\"Authorization\":\s*\"Bearer\s+)[A-Za-z0-9\-\._~\+\/]+=*", re.I),
36
+ re.compile(r"(\"x-api-key\":\s*\")[A-Za-z0-9]{10,}", re.I),
37
+ re.compile(r"(\"x-auth-token\":\s*\")[A-Za-z0-9\-_\.]{10,}", re.I),
38
+ )
39
+ REPLACEMENT = r"\1[REDACTED]"
40
+
41
+ def filter(self, record: logging.LogRecord) -> bool:
42
+ msg = record.getMessage()
43
+ for pat in self.SECRET_PATTERNS:
44
+ msg = pat.sub(self.REPLACEMENT, msg)
45
+ # stash sanitized message without mutating args (safe for %-formatting)
46
+ record.msg = msg
47
+ record.args = ()
48
+ return True
49
+
50
+
51
+ class PerMessageIOFilter(logging.Filter):
52
+ """
53
+ Filter that allows per-message control of IO logging.
54
+ Use the 'log_to_file' extra parameter to control whether a message gets logged to file.
55
+ """
56
+
57
+ def filter(self, record: logging.LogRecord) -> bool:
58
+ skip_file_logging = getattr(record, 'skip_file_logging', False)
59
+ return not skip_file_logging
60
+
61
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
62
+ # functions to toggle logging behavior
63
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
64
+
65
+ def _enable_stderr_debug(level: int = logging.DEBUG) -> None:
66
+ """
67
+ Opt-in helper: attach a simple stderr handler to the package logger.
68
+ Safe for quickstarts; in real apps users should configure logging themselves.
69
+ """
70
+ h = logging.StreamHandler()
71
+ fmt = "[%(levelname)s] %(name)s: %(message)s"
72
+ h.setFormatter(logging.Formatter(fmt))
73
+ h.addFilter(RedactSecretsFilter())
74
+ root = logging.getLogger("olostep")
75
+ root.setLevel(level)
76
+ root.addHandler(h)
77
+
78
+ # Setup IO logger with file handler if logging is enabled
79
+ def _setup_io_file_handler() -> None:
80
+ logger.warning("Setting up IO file logging. This is a lot of data.")
81
+ """Setup file handler for IO logger if logging is enabled."""
82
+ # try:
83
+ # from .config import IO_LOG_PATH
84
+ # except ImportError:
85
+ # # Fallback when not imported as part of package
86
+ # IO_LOG_PATH = os.getenv("OLOSTEP_IO_LOG_PATH", "tests/runtime")
87
+
88
+ if not IO_LOG_PATH:
89
+ return
90
+
91
+ # Check if file handler already exists
92
+ if any(isinstance(h, logging.FileHandler) for h in io_logger.handlers):
93
+ return
94
+
95
+ # Create log directory
96
+ log_dir = Path(IO_LOG_PATH)
97
+ logger.info(f"Logging IO to: {log_dir.resolve()}")
98
+ log_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ # Create file handler with JSON formatter
101
+ file_handler = logging.FileHandler(log_dir / "io_logs.json", mode='a')
102
+ file_handler.setLevel(logging.DEBUG)
103
+
104
+ # Custom formatter for structured JSON logging
105
+ class IOJSONFormatter(logging.Formatter):
106
+ def format(self, record: logging.LogRecord) -> str:
107
+ log_entry = {
108
+ "timestamp": datetime.now().isoformat(),
109
+ "level": record.levelname,
110
+ "logger": record.name,
111
+ "message": record.getMessage(),
112
+ }
113
+
114
+ # Add i and o data if present, with redaction (handle both cases)
115
+
116
+ if hasattr(record, 'request_id'):
117
+ log_entry["request_id"] = record.request_id
118
+
119
+ if hasattr(record, 'response_time_ms'):
120
+ log_entry["response_time_ms"] = record.response_time_ms
121
+
122
+ if hasattr(record, 'i'):
123
+ log_entry["I"] = self._redact_data(record.i)
124
+ elif hasattr(record, 'I'):
125
+ log_entry["I"] = self._redact_data(record.I)
126
+
127
+ if hasattr(record, 'o'):
128
+ log_entry["O"] = self._redact_data(record.o)
129
+ elif hasattr(record, 'O'):
130
+ log_entry["O"] = self._redact_data(record.O)
131
+
132
+
133
+ return json.dumps(log_entry, indent=2) + ","
134
+
135
+ def _redact_data(self, data: Any) -> Any:
136
+ """Recursively redact sensitive data from dictionaries."""
137
+ if isinstance(data, dict):
138
+ redacted = {}
139
+ for key, value in data.items():
140
+ if key == "body" and isinstance(value, str):
141
+ # Always try to parse body as JSON first, then handle as text
142
+ try:
143
+ parsed_body = json.loads(value)
144
+ redacted[key] = self._redact_data(parsed_body)
145
+ except (json.JSONDecodeError, TypeError):
146
+ # If not valid JSON, keep as string but redact secrets
147
+ if self._contains_secret(value):
148
+ redacted[key] = self._redact_string(value)
149
+ else:
150
+ redacted[key] = value
151
+ elif isinstance(value, str) and self._contains_secret(value):
152
+ redacted[key] = self._redact_string(value)
153
+ elif isinstance(value, dict):
154
+ redacted[key] = self._redact_data(value)
155
+ else:
156
+ redacted[key] = value
157
+ return redacted
158
+ elif isinstance(data, str) and self._contains_secret(data):
159
+ return self._redact_string(data)
160
+ else:
161
+ return data
162
+
163
+ def _contains_secret(self, text: str) -> bool:
164
+ """Check if text contains secrets."""
165
+ # Check for Bearer tokens in any case
166
+ if re.search(r'bearer\s+[a-za-z0-9\-\._~\+\/]+=*', text, re.I):
167
+ return True
168
+ # Check for API keys
169
+ if re.search(r'api[_-]?key[=:]\s*[a-za-z0-9]{10,}', text, re.I):
170
+ return True
171
+ # Check for tokens
172
+ if re.search(r'token[=:]\s*[a-za-z0-9\-_\.]{10,}', text, re.I):
173
+ return True
174
+ return False
175
+
176
+ def _redact_string(self, text: str) -> str:
177
+ """Redact secrets in a string."""
178
+ # Redact Bearer tokens
179
+ text = re.sub(r'(bearer\s+)[a-za-z0-9\-\._~\+\/]+=*', r'\1[REDACTED]', text, flags=re.I)
180
+ # Redact API keys
181
+ text = re.sub(r'(api[_-]?key[=:]\s*)[a-za-z0-9]{10,}', r'\1[REDACTED]', text, flags=re.I)
182
+ # Redact tokens
183
+ text = re.sub(r'(token[=:]\s*)[a-za-z0-9\-_\.]{10,}', r'\1[REDACTED]', text, flags=re.I)
184
+ return text
185
+
186
+ file_handler.setFormatter(IOJSONFormatter())
187
+ file_handler.addFilter(RedactSecretsFilter())
188
+ file_handler.addFilter(PerMessageIOFilter())
189
+ io_logger.addHandler(file_handler)
190
+
191
+ io_logger.setLevel(logging.DEBUG)
192
+
193
+
194
+
195
+
196
+ def get_logger(name: str | None = None) -> logging.Logger:
197
+ """Library API for internal modules."""
198
+ return logging.getLogger("olostep" + ("" if not name else f".{name}"))
199
+
200
+
201
+ def load_io_logs(log_file_path: str | Path) -> list[dict[str, Any]]:
202
+ """
203
+ Load IO logs from a file as a list of dictionaries.
204
+
205
+ The log file contains JSON objects separated by commas.
206
+ This function wraps the content in square brackets to create a valid JSON array.
207
+
208
+ Args:
209
+ log_file_path: Path to the IO logs JSON file
210
+
211
+ Returns:
212
+ List of log entries as dictionaries
213
+
214
+ Raises:
215
+ FileNotFoundError: If the log file doesn't exist
216
+ json.JSONDecodeError: If the file contains invalid JSON
217
+ """
218
+ log_path = Path(log_file_path)
219
+
220
+ if not log_path.exists():
221
+ raise FileNotFoundError(f"Log file not found: {log_path}")
222
+
223
+ with open(log_path, 'r') as f:
224
+ content = f.read().strip()
225
+
226
+ if not content:
227
+ return []
228
+
229
+ # Remove trailing comma if present
230
+ if content.endswith(','):
231
+ content = content[:-1]
232
+
233
+ # Wrap in square brackets to create a valid JSON array
234
+ json_array_content = f"[{content}]"
235
+
236
+ return json.loads(json_array_content)
237
+
238
+
@@ -0,0 +1,323 @@
1
+ """
2
+ Olostep API Endpoints Documentation.
3
+
4
+ This module serves as comprehensive documentation in code for the Olostep API.
5
+ It contains all endpoint definitions, URL patterns, request/response formats,
6
+ and usage examples for each API endpoint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal
14
+ from ..models.request import (
15
+ # Scrapes
16
+ ScrapeUrlRequest,
17
+ # Batches
18
+ BatchStartRequest,
19
+ BatchItemsRequest,
20
+ # Crawls
21
+ CrawlStartRequest,
22
+ CrawlPagesRequest,
23
+ # Maps
24
+ MapCreateRequest,
25
+ # Retrieve
26
+ RetrieveGetRequest,
27
+ )
28
+ from ..models.response import (
29
+ # Scrapes
30
+ CreateScrapeResponse,
31
+ GetScrapeResponse,
32
+ # Batches
33
+ BatchCreateResponse,
34
+ BatchInfoResponse,
35
+ BatchItemsResponse,
36
+ # Crawls
37
+ CreateCrawlResponse,
38
+ CrawlInfoResponse,
39
+ CrawlPagesResponse,
40
+ # Maps
41
+ MapResponse,
42
+ # Retrieve
43
+ RetrieveResponse,
44
+ )
45
+
46
+ Method = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class EndpointContract:
51
+ """Single source of truth for an API endpoint.
52
+
53
+ Holds HTTP details and the concrete Pydantic v2 request/response models.
54
+ Frontends remain Pydantic-free and use these contracts only through the backend caller.
55
+ """
56
+
57
+ # Stable registry key exposed to frontends as namespaces/methods
58
+ key: tuple[str, str]
59
+
60
+ # Human docs
61
+ name: str
62
+ description: str
63
+
64
+ # HTTP details
65
+ method: Method
66
+ path: str # e.g., "/scrapes" or "/scrapes/{scrape_id}"
67
+
68
+ # IO models (backend validation only)
69
+ request_model: type[Any] | None = None
70
+ response_model: type[Any] | None = None
71
+
72
+ # Optional examples for docs/SDK help
73
+ examples: list[dict[str, Any]] = field(default_factory=list)
74
+
75
+ def formatted_path(
76
+ self,
77
+ path_params: dict[str, Any] | None = None,
78
+ ) -> str:
79
+ path_params = path_params or {}
80
+ formatted_path = self.path.format(**path_params)
81
+ formatted_path = formatted_path.rstrip('/')
82
+ formatted_path = formatted_path.lstrip('/')
83
+ return formatted_path
84
+
85
+ @property
86
+ def path_parameters(self) -> list[str]:
87
+ return re.findall(r"{([^}]+)}", self.path)
88
+
89
+
90
+ # =============================================================================
91
+ # SCRAPES
92
+ # =============================================================================
93
+
94
+ SCRAPE_URL = EndpointContract(
95
+ key=("scrape", "url"),
96
+ name="Create Scrape",
97
+ description="Create a new web scraping job to extract content from a URL",
98
+ method="POST",
99
+ path="/scrapes",
100
+ request_model=ScrapeUrlRequest,
101
+ response_model=CreateScrapeResponse,
102
+ examples=[
103
+ {
104
+ "description": "Basic HTML scrape",
105
+ "request": {"url_to_scrape": "https://example.com", "formats": ["html"]},
106
+ },
107
+ {
108
+ "description": "Multi-format scrape with configuration",
109
+ "request": {
110
+ "url_to_scrape": "https://example.com",
111
+ "formats": ["html", "markdown", "text"],
112
+ "country": "US",
113
+ "remove_images": True,
114
+ "wait_before_scraping": 2000,
115
+ },
116
+ },
117
+ ],
118
+ )
119
+
120
+ SCRAPE_GET = EndpointContract(
121
+ key=("scrape", "get"),
122
+ name="Get Scrape",
123
+ description="Retrieve the results of a completed scraping job",
124
+ method="GET",
125
+ path="/scrapes/{scrape_id}",
126
+ request_model=None,
127
+ response_model=GetScrapeResponse,
128
+ examples=[
129
+ {"description": "Retrieve scrape results", "path_params": {"scrape_id": "scrape_12345"}},
130
+ ],
131
+ )
132
+
133
+
134
+ # =============================================================================
135
+ # BATCHES
136
+ # =============================================================================
137
+
138
+ BATCH_START = EndpointContract(
139
+ key=("batch", "start"),
140
+ name="Create Batch",
141
+ description="Create a batch scraping job to process multiple URLs",
142
+ method="POST",
143
+ path="/batches",
144
+ request_model=BatchStartRequest,
145
+ response_model=BatchCreateResponse,
146
+ examples=[
147
+ {
148
+ "description": "Basic batch creation",
149
+ "request": {
150
+ "items": [
151
+ {"url": "https://example1.com", "custom_id": "item1"},
152
+ {"url": "https://example2.com", "custom_id": "item2"},
153
+ ]
154
+ },
155
+ },
156
+ {
157
+ "description": "Batch with configuration",
158
+ "request": {
159
+ "items": [{"url": "https://example.com", "custom_id": "test"}],
160
+ "country": "US",
161
+ "parser": {"id": "default"},
162
+ },
163
+ },
164
+ ],
165
+ )
166
+
167
+ BATCH_INFO = EndpointContract(
168
+ key=("batch", "info"),
169
+ name="Get Batch Info",
170
+ description="Retrieve information about a batch scraping job",
171
+ method="GET",
172
+ path="/batches/{batch_id}",
173
+ request_model=None,
174
+ response_model=BatchInfoResponse,
175
+ examples=[
176
+ {"description": "Get batch status", "path_params": {"batch_id": "batch_12345"}},
177
+ ],
178
+ )
179
+
180
+ BATCH_ITEMS = EndpointContract(
181
+ key=("batch", "items"),
182
+ name="Get Batch Items",
183
+ description="Retrieve the results of individual items in a batch",
184
+ method="GET",
185
+ path="/batches/{batch_id}/items",
186
+ request_model=BatchItemsRequest,
187
+ response_model=BatchItemsResponse,
188
+ examples=[
189
+ {
190
+ "description": "Get all completed items",
191
+ "path_params": {"batch_id": "batch_12345"},
192
+ "query_params": {"status": "completed"},
193
+ },
194
+ {
195
+ "description": "Get items with pagination",
196
+ "path_params": {"batch_id": "batch_12345"},
197
+ "query_params": {"limit": 10, "cursor": 0},
198
+ },
199
+ ],
200
+ )
201
+
202
+
203
+ # =============================================================================
204
+ # CRAWLS
205
+ # =============================================================================
206
+
207
+ CRAWL_START = EndpointContract(
208
+ key=("crawl", "start"),
209
+ name="Create Crawl",
210
+ description="Create a web crawling job to discover and scrape linked pages",
211
+ method="POST",
212
+ path="/crawls",
213
+ request_model=CrawlStartRequest,
214
+ response_model=CreateCrawlResponse,
215
+ examples=[
216
+ {
217
+ "description": "Basic crawl creation",
218
+ "request": {"start_url": "https://example.com", "max_pages": 10, "follow_links": True},
219
+ }
220
+ ],
221
+ )
222
+
223
+ CRAWL_INFO = EndpointContract(
224
+ key=("crawl", "info"),
225
+ name="Get Crawl Info",
226
+ description="Retrieve information about a web crawling job",
227
+ method="GET",
228
+ path="/crawls/{crawl_id}",
229
+ request_model=None,
230
+ response_model=CrawlInfoResponse,
231
+ examples=[
232
+ {"description": "Get crawl status", "path_params": {"crawl_id": "crawl_12345"}},
233
+ ],
234
+ )
235
+
236
+ CRAWL_PAGES = EndpointContract(
237
+ key=("crawl", "pages"),
238
+ name="Get Crawl Pages",
239
+ description="Retrieve the pages discovered during a crawl",
240
+ method="GET",
241
+ path="/crawls/{crawl_id}/pages",
242
+ request_model=CrawlPagesRequest,
243
+ response_model=CrawlPagesResponse,
244
+ examples=[
245
+ {"description": "Get all crawled pages", "path_params": {"crawl_id": "crawl_12345"}},
246
+ {
247
+ "description": "Get pages with pagination",
248
+ "path_params": {"crawl_id": "crawl_12345"},
249
+ "query_params": {"limit": 20, "cursor": 0},
250
+ },
251
+ ],
252
+ )
253
+
254
+
255
+ # =============================================================================
256
+ # MAPS
257
+ # =============================================================================
258
+
259
+ MAP_CREATE = EndpointContract(
260
+ key=("map", "create"),
261
+ name="Create Map",
262
+ description="Extract links from a website",
263
+ method="POST",
264
+ path="/maps",
265
+ request_model=MapCreateRequest,
266
+ response_model=MapResponse,
267
+ examples=[
268
+ {"description": "Basic link extraction", "request": {"url": "https://example.com"}},
269
+ {
270
+ "description": "Link extraction with filtering",
271
+ "request": {
272
+ "url": "https://example.com",
273
+ "search_query": "blog",
274
+ "top_n": 10,
275
+ "include_urls": ["/blog/**"],
276
+ "exclude_urls": ["/admin/**"],
277
+ },
278
+ },
279
+ ],
280
+ )
281
+
282
+
283
+ # =============================================================================
284
+ # RETRIEVE
285
+ # =============================================================================
286
+
287
+ RETRIEVE_GET = EndpointContract(
288
+ key=("retrieve", "get"),
289
+ name="Retrieve Content",
290
+ description="Retrieve content by its unique identifier with specified formats",
291
+ method="GET",
292
+ path="/retrieve",
293
+ request_model=RetrieveGetRequest,
294
+ response_model=RetrieveResponse,
295
+ examples=[
296
+ {
297
+ "description": "Retrieve content by ID with formats",
298
+ "query_params": {"retrieve_id": "retrieve_12345", "formats": ["html", "markdown", "json"]},
299
+ }
300
+ ],
301
+ )
302
+
303
+
304
+ # =============================================================================
305
+ # REGISTRY
306
+ # =============================================================================
307
+
308
+ CONTRACTS: dict[tuple[str, str], EndpointContract] = {
309
+ c.key: c
310
+ for c in [
311
+ SCRAPE_URL,
312
+ SCRAPE_GET,
313
+ BATCH_START,
314
+ BATCH_INFO,
315
+ BATCH_ITEMS,
316
+ CRAWL_START,
317
+ CRAWL_INFO,
318
+ CRAWL_PAGES,
319
+ MAP_CREATE,
320
+ RETRIEVE_GET,
321
+ ]
322
+ }
323
+