silk-scraper 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.
silk/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ Silk - A flexible browser automation library
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from expression import Ok, Error, Result, Option, Some, Nothing
8
+
9
+ from . import actions
10
+ from . import browsers
11
+ from . import models
12
+ from . import selectors
13
+
14
+ from .actions.decorators import action
@@ -0,0 +1,48 @@
1
+ """
2
+ Actions are the building blocks of Silk.
3
+
4
+ They represent pure operations that can be composed together using functional
5
+ programming patterns. Each action returns a Result type that handles both success
6
+ and error cases.
7
+
8
+ Silk provides several ways to compose actions:
9
+
10
+ - sequence: Executes actions in sequence and returns ALL results as a Block
11
+ - compose: Executes actions in sequence and returns ONLY the LAST result
12
+ - pipe: Creates a pipeline where each action can use the result of the previous action
13
+ - parallel: Executes actions concurrently and collects all results
14
+ - fallback: Tries actions in sequence until one succeeds
15
+
16
+ These can also be used through operators:
17
+ - a >> b: Equivalent to compose(a, b)
18
+ - a | b: Equivalent to fallback(a, b)
19
+ - a & b: Similar to parallel(a, b) but returns a tuple of results
20
+ """
21
+
22
+ from silk.actions.base import Action
23
+ from silk.actions.composition import compose, fallback, parallel, pipe, sequence
24
+ from silk.actions.flow import (
25
+ branch,
26
+ loop_until,
27
+ retry,
28
+ retry_with_backoff,
29
+ tap,
30
+ with_timeout,
31
+ )
32
+
33
+ __all__ = [
34
+ "Action",
35
+ # Control
36
+ "branch",
37
+ "loop_until",
38
+ "retry_with_backoff",
39
+ "with_timeout",
40
+ "tap",
41
+ "retry",
42
+ # Composition
43
+ "sequence",
44
+ "parallel",
45
+ "pipe",
46
+ "fallback",
47
+ "compose",
48
+ ]
silk/actions/base.py ADDED
@@ -0,0 +1,419 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import (
5
+ TYPE_CHECKING,
6
+ Any,
7
+ Awaitable,
8
+ Callable,
9
+ Generic,
10
+ Optional,
11
+ ParamSpec,
12
+ Tuple,
13
+ TypeVar,
14
+ Union,
15
+ cast,
16
+ )
17
+
18
+ from expression.core import Error, Result
19
+
20
+ from silk.models.browser import ActionContext
21
+
22
+ if TYPE_CHECKING:
23
+ from silk.browsers.manager import BrowserManager
24
+
25
+ T = TypeVar("T")
26
+ S = TypeVar("S")
27
+ R = TypeVar("R")
28
+
29
+ P = ParamSpec("P")
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class Action(ABC, Generic[T]):
35
+ """
36
+ Base class for all actions that can be performed in a browser
37
+
38
+ An Action represents a pure operation that can be composed with
39
+ other actions using functional programming patterns.
40
+ """
41
+
42
+ @abstractmethod
43
+ async def execute(self, context: ActionContext) -> Result[T, Exception]:
44
+ """
45
+ Execute the action using the given context
46
+
47
+ Args:
48
+ context: Execution context with references to browser and page
49
+
50
+ Returns:
51
+ Result containing either the action result or an exception
52
+ """
53
+ pass
54
+
55
+ # todo we need a operation that runs a action on a list of items similar to airflow mapped tasks
56
+ # todo improve the doc strings, explaination and examples
57
+ def map(self, f: Callable[[T], S]) -> "Action[S]":
58
+ """
59
+ Create a new action that maps the result of this action
60
+
61
+ Args:
62
+ f: Function to transform the result
63
+
64
+ Returns:
65
+ A new Action with transformed result
66
+ """
67
+ original_action = self
68
+
69
+ class MappedAction(Action[S]):
70
+ async def execute(self, context: ActionContext) -> Result[S, Exception]:
71
+ result = await original_action.execute(context)
72
+ try:
73
+ return result.map(f)
74
+ except Exception as e:
75
+ return Error(e)
76
+
77
+ return MappedAction()
78
+
79
+ # todo we meed to fix this and_then should pass the context to the next action
80
+ # it should be similar to celery task chaining
81
+ # maybe we use a simlar signature modifier like action.s(immutable_value) ?
82
+ # or action("input",forward_value) or action(accept_value)
83
+ def and_then(self, f: Callable[[Optional[T]], "Action[S]"]) -> "Action[S]":
84
+ """
85
+ Chain an action after this one, using the result of this action
86
+
87
+ Args:
88
+ f: Function that takes the result of this action and returns a new action
89
+
90
+ Returns:
91
+ A new Action that chains the two actions
92
+ """
93
+ original_action = self
94
+
95
+ class ChainedAction(Action[S]):
96
+ async def execute(self, context: ActionContext) -> Result[S, Exception]:
97
+ try:
98
+ result = await original_action.execute(context)
99
+
100
+ if result.is_error():
101
+ return cast(Result[S, Exception], result)
102
+
103
+ value = result.default_value(None)
104
+ # Create the next action regardless of whether value is None
105
+ # The function f may or may not use the value
106
+ next_action = f(value)
107
+
108
+ return await next_action.execute(context)
109
+ except Exception as e:
110
+ return Error(e)
111
+
112
+ return ChainedAction()
113
+
114
+ def then(self, next_action: "Action[S]") -> "Action[S]":
115
+ """
116
+ Chain an action after this one, ignoring the result of this action
117
+
118
+ Args:
119
+ next_action: Action to execute after this one completes
120
+
121
+ Returns:
122
+ A new Action that chains the two actions sequentially
123
+ """
124
+ original_action = self
125
+
126
+ class SequentialAction(Action[S]):
127
+ async def execute(self, context: ActionContext) -> Result[S, Exception]:
128
+ try:
129
+ result = await original_action.execute(context)
130
+
131
+ if result.is_error():
132
+ return cast(Result[S, Exception], result)
133
+
134
+ # Ignore the value and execute the next action
135
+ return await next_action.execute(context)
136
+ except Exception as e:
137
+ return Error(e)
138
+
139
+ return SequentialAction()
140
+
141
+ def retry(self, max_attempts: int = 3, delay_ms: int = 1000) -> "Action[T]":
142
+ """
143
+ Create a new action that retries this action multiple times until it succeeds
144
+
145
+ Args:
146
+ max_attempts: Maximum number of retry attempts
147
+ delay_ms: Delay between retries in milliseconds
148
+
149
+ Returns:
150
+ A new Action with retry logic
151
+ """
152
+ original_action = self
153
+
154
+ class RetryAction(Action[T]):
155
+ async def execute(self, context: ActionContext) -> Result[T, Exception]:
156
+ retry_context = context.derive(
157
+ max_retries=max_attempts, retry_delay_ms=delay_ms
158
+ )
159
+
160
+ last_error = None
161
+
162
+ for attempt in range(max_attempts):
163
+ attempt_context = retry_context.derive(retry_count=attempt)
164
+
165
+ try:
166
+ result = await original_action.execute(attempt_context)
167
+ if result.is_ok():
168
+ return result
169
+ last_error = result.error if hasattr(result, "error") else None
170
+ except Exception as e:
171
+ last_error = e
172
+
173
+ if attempt < max_attempts - 1:
174
+ await asyncio.sleep(delay_ms / 1000)
175
+
176
+ return Error(
177
+ last_error or Exception(f"All {max_attempts} attempts failed")
178
+ )
179
+
180
+ return RetryAction()
181
+
182
+ def with_timeout(self, timeout_ms: int) -> "Action[T]":
183
+ """
184
+ Create a new action that times out after the specified duration
185
+
186
+ Args:
187
+ timeout_ms: Timeout in milliseconds
188
+
189
+ Returns:
190
+ A new Action with timeout logic
191
+ """
192
+ original_action = self
193
+
194
+ class TimeoutAction(Action[T]):
195
+ async def execute(self, context: ActionContext) -> Result[T, Exception]:
196
+ timeout_context = context.derive(timeout_ms=timeout_ms)
197
+
198
+ try:
199
+ result = await asyncio.wait_for(
200
+ # todo this might block the event loop and prevent
201
+ # the browser from doing anything else in some driver implementations
202
+ original_action.execute(timeout_context),
203
+ timeout=timeout_ms / 1000,
204
+ )
205
+
206
+ return result
207
+ except asyncio.TimeoutError:
208
+ return Error(Exception(f"Operation timed out after {timeout_ms}ms"))
209
+ except Exception as e:
210
+ return Error(e)
211
+
212
+ return TimeoutAction()
213
+
214
+ def __rshift__(
215
+ self, other: Union[Callable[[T], S], "Action[Any]"]
216
+ ) -> "Action[Any]":
217
+ """
218
+ Overload the >> operator for pipe-like sequencing
219
+
220
+ a >> b is equivalent to:
221
+ - If b is a function: a.map(b)
222
+ - If b is an Action: a.and_then(lambda _: b)
223
+ """
224
+ if isinstance(other, Action):
225
+ return self.then(other)
226
+ else:
227
+ return self.map(other)
228
+
229
+ def __or__(self, other: "Action[S]") -> "Action[Union[T, S]]":
230
+ """
231
+ Overload the | operator for fallback behavior
232
+
233
+ a | b means "try action a, if it fails, try action b"
234
+ """
235
+ first_action = self
236
+ second_action = other
237
+
238
+ class FallbackAction(Action[Union[T, S]]):
239
+ async def execute(
240
+ self, context: ActionContext
241
+ ) -> Result[Union[T, S], Exception]:
242
+ try:
243
+ fallback_context = context.derive(
244
+ metadata={"fallback_operation": "trying_first"}
245
+ )
246
+
247
+ result = await first_action.execute(fallback_context)
248
+ if result.is_ok():
249
+ return result
250
+ except Exception:
251
+ pass
252
+
253
+ fallback_context = context.derive(
254
+ metadata={"fallback_operation": "trying_second"}
255
+ )
256
+ return await second_action.execute(fallback_context)
257
+
258
+ return FallbackAction()
259
+
260
+ # todo what should we retyurn
261
+ # todo max number of parallel actions config
262
+ def __and__(self, other: "Action[S]") -> "Action[Tuple[Optional[T], Optional[S]]]":
263
+ """
264
+ Overload the & operator for parallel execution
265
+
266
+ a & b means "execute actions a and b in parallel with separate contexts"
267
+ """
268
+ from expression.core import Ok # Make sure Ok is imported
269
+
270
+ first_action = self
271
+ second_action = other
272
+
273
+ class ParallelAction(Action[Tuple[Optional[T], Optional[S]]]):
274
+ async def execute(
275
+ self, context: ActionContext
276
+ ) -> Result[Tuple[Optional[T], Optional[S]], Exception]:
277
+ if not context.browser_manager:
278
+ return Error(
279
+ Exception(
280
+ "Cannot execute parallel actions without a browser manager"
281
+ )
282
+ )
283
+
284
+ first_context_id, second_context_id = None, None
285
+
286
+ try:
287
+ context_result1 = await context.browser_manager.create_context()
288
+ if context_result1.is_error():
289
+ return Error(context_result1.error)
290
+ browser_context1 = context_result1.default_value(None)
291
+ if browser_context1 is None:
292
+ return Error(Exception("Failed to create first context"))
293
+ first_context_id = browser_context1.id
294
+
295
+ page_result1 = browser_context1.get_page()
296
+ if page_result1.is_error():
297
+ return Error(page_result1.error)
298
+ page1 = page_result1.default_value(None)
299
+ if page1 is None:
300
+ return Error(Exception("Failed to get first page"))
301
+
302
+ action_context1 = context.derive(
303
+ context_id=browser_context1.id,
304
+ page_id=page1.id,
305
+ metadata={"parallel_execution": "first_action"},
306
+ )
307
+
308
+ context_result2 = await context.browser_manager.create_context()
309
+ if context_result2.is_error():
310
+ return Error(context_result2.error)
311
+ browser_context2 = context_result2.default_value(None)
312
+ if browser_context2 is None:
313
+ return Error(Exception("Failed to create second context"))
314
+ second_context_id = browser_context2.id
315
+
316
+ page_result2 = browser_context2.get_page()
317
+ if page_result2.is_error():
318
+ return Error(page_result2.error)
319
+ page2 = page_result2.default_value(None)
320
+ if page2 is None:
321
+ return Error(Exception("Failed to get second page"))
322
+
323
+ action_context2 = context.derive(
324
+ context_id=browser_context2.id,
325
+ page_id=page2.id,
326
+ metadata={"parallel_execution": "second_action"},
327
+ )
328
+
329
+ # Execute both actions in parallel
330
+ results = await asyncio.gather(
331
+ first_action.execute(action_context1),
332
+ second_action.execute(action_context2),
333
+ return_exceptions=False,
334
+ )
335
+
336
+ result1, result2 = results[0], results[1]
337
+
338
+ if result1.is_error():
339
+ return Error(result1.error)
340
+ if result2.is_error():
341
+ return Error(result2.error)
342
+
343
+ # Extract values from successful results and return a tuple
344
+ # successful tasks might return None
345
+ value1 = result1.default_value(None)
346
+ value2 = result2.default_value(None)
347
+ return Ok((value1, value2))
348
+ except Exception as e:
349
+ return Error(e)
350
+ finally:
351
+ if first_context_id and context.browser_manager:
352
+ await context.browser_manager.close_context(first_context_id)
353
+ if second_context_id and context.browser_manager:
354
+ await context.browser_manager.close_context(second_context_id)
355
+
356
+ # Make sure to return an instance of the ParallelAction class
357
+ return ParallelAction()
358
+
359
+ def __call__(
360
+ self, browser_manager: "BrowserManager"
361
+ ) -> Awaitable[Result[T, Exception]]:
362
+ """
363
+ Make Action instances callable directly with a browser manager
364
+
365
+ This allows using actions like: result = await action(browser_manager)
366
+ """
367
+ return self.execute_with_manager(browser_manager)
368
+
369
+ async def execute_with_manager(
370
+ self, browser_manager: "BrowserManager"
371
+ ) -> Result[T, Exception]:
372
+ """Execute this action using a BrowserManager to create a new context"""
373
+ context_result = await browser_manager.create_context()
374
+ if context_result.is_error():
375
+ return Error(context_result.error)
376
+ browser_context = context_result.default_value(None)
377
+ if browser_context is None:
378
+ return Error(Exception("Failed to create context"))
379
+
380
+ try:
381
+ page_result = browser_context.get_page()
382
+ if page_result.is_error():
383
+ return Error(page_result.error)
384
+ page = page_result.default_value(None)
385
+ if page is None:
386
+ return Error(Exception("Failed to get page"))
387
+
388
+ action_context = ActionContext(
389
+ browser_manager=browser_manager,
390
+ context_id=browser_context.id,
391
+ page_id=page.id,
392
+ )
393
+
394
+ return await self.execute(action_context)
395
+ finally:
396
+ await browser_manager.close_context(browser_context.id)
397
+
398
+
399
+ def create_action(
400
+ func: Callable[[ActionContext], Awaitable[Result[T, Exception]]],
401
+ ) -> Action[T]:
402
+ """
403
+ Create an action from a function
404
+
405
+ Args:
406
+ func: Function that takes a context and returns a Result
407
+
408
+ Returns:
409
+ An Action that wraps the function
410
+ """
411
+
412
+ class FunctionalAction(Action[T]):
413
+ async def execute(self, context: ActionContext) -> Result[T, Exception]:
414
+ try:
415
+ return await func(context)
416
+ except Exception as e:
417
+ return Error(e)
418
+
419
+ return FunctionalAction()