lumivor 0.1.7__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,38 @@
1
+ import base64
2
+
3
+ import pytest
4
+
5
+ from lumivor.browser.browser import Browser, BrowserConfig
6
+
7
+
8
+ @pytest.fixture
9
+ async def browser():
10
+ browser_service = Browser(config=BrowserConfig(headless=True))
11
+ yield browser_service
12
+
13
+ await browser_service.close()
14
+
15
+
16
+ # @pytest.mark.skip(reason='takes too long')
17
+ def test_take_full_page_screenshot(browser):
18
+ # Go to a test page
19
+ browser.go_to_url('https://example.com')
20
+
21
+ # Take full page screenshot
22
+ screenshot_b64 = browser.take_screenshot(full_page=True)
23
+
24
+ # Verify screenshot is not empty and is valid base64
25
+ assert screenshot_b64 is not None
26
+ assert isinstance(screenshot_b64, str)
27
+ assert len(screenshot_b64) > 0
28
+
29
+ # Test we can decode the base64 string
30
+ try:
31
+ base64.b64decode(screenshot_b64)
32
+ except Exception as e:
33
+ pytest.fail(f'Failed to decode base64 screenshot: {str(e)}')
34
+
35
+
36
+ if __name__ == '__main__':
37
+ test_take_full_page_screenshot(
38
+ Browser(config=BrowserConfig(headless=False)))
@@ -0,0 +1,77 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from lumivor.browser.browser import Browser, BrowserConfig
7
+ from lumivor.dom.views import ElementTreeSerializer
8
+ from lumivor.utils import time_execution_sync
9
+
10
+
11
+ # run with: pytest lumivor/browser/tests/test_clicks.py
12
+ @pytest.mark.asyncio
13
+ async def test_highlight_elements():
14
+ browser = Browser(config=BrowserConfig(
15
+ headless=False, disable_security=True))
16
+
17
+ async with await browser.new_context() as context:
18
+ page = await context.get_current_page()
19
+ # await page.goto('https://immobilienscout24.de')
20
+ # await page.goto('https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/service-plans')
21
+ # await page.goto('https://google.com/search?q=elon+musk')
22
+ # await page.goto('https://kayak.com')
23
+ # await page.goto('https://www.w3schools.com/tags/tryit.asp?filename=tryhtml_iframe')
24
+ # await page.goto('https://dictionary.cambridge.org')
25
+ # await page.goto('https://github.com')
26
+ await page.goto('https://huggingface.co/')
27
+
28
+ await asyncio.sleep(1)
29
+
30
+ while True:
31
+ try:
32
+ # await asyncio.sleep(10)
33
+ state = await context.get_state()
34
+
35
+ with open('./tmp/page.json', 'w') as f:
36
+ json.dump(
37
+ ElementTreeSerializer.dom_element_node_to_json(
38
+ state.element_tree),
39
+ f,
40
+ indent=1,
41
+ )
42
+
43
+ # await time_execution_sync('highlight_selector_map_elements')(
44
+ # browser.highlight_selector_map_elements
45
+ # )(state.selector_map)
46
+
47
+ # Find and print duplicate XPaths
48
+ xpath_counts = {}
49
+ if not state.selector_map:
50
+ continue
51
+ for selector in state.selector_map.values():
52
+ xpath = selector.xpath
53
+ if xpath in xpath_counts:
54
+ xpath_counts[xpath] += 1
55
+ else:
56
+ xpath_counts[xpath] = 1
57
+
58
+ print('\nDuplicate XPaths found:')
59
+ for xpath, count in xpath_counts.items():
60
+ if count > 1:
61
+ print(f'XPath: {xpath}')
62
+ print(f'Count: {count}\n')
63
+
64
+ print(list(state.selector_map.keys()), 'Selector map keys')
65
+ print(state.element_tree.clickable_elements_to_string())
66
+ action = input('Select next action: ')
67
+
68
+ await time_execution_sync('remove_highlight_elements')(context.remove_highlights)()
69
+
70
+ node_element = state.selector_map[int(action)]
71
+
72
+ # check if index of selector map are the same as index of items in dom_items
73
+
74
+ await context._click_element_node(node_element)
75
+
76
+ except Exception as e:
77
+ print(e)
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from lumivor.dom.history_tree_processor.service import DOMHistoryElement
7
+ from lumivor.dom.views import DOMState
8
+
9
+
10
+ # Pydantic
11
+ class TabInfo(BaseModel):
12
+ """Represents information about a browser tab"""
13
+
14
+ page_id: int
15
+ url: str
16
+ title: str
17
+
18
+
19
+ @dataclass
20
+ class BrowserState(DOMState):
21
+ url: str
22
+ title: str
23
+ tabs: list[TabInfo]
24
+ screenshot: Optional[str] = None
25
+
26
+
27
+ @dataclass
28
+ class BrowserStateHistory:
29
+ url: str
30
+ title: str
31
+ tabs: list[TabInfo]
32
+ interacted_element: list[DOMHistoryElement | None] | list[None]
33
+ screenshot: Optional[str] = None
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ data = {}
37
+ data['tabs'] = [tab.model_dump() for tab in self.tabs]
38
+ data['screenshot'] = self.screenshot
39
+ data['interacted_element'] = [
40
+ el.to_dict() if el else None for el in self.interacted_element
41
+ ]
42
+ data['url'] = self.url
43
+ data['title'] = self.title
44
+ return data
45
+
46
+
47
+ class BrowserError(Exception):
48
+ """Base class for all browser errors"""
@@ -0,0 +1,140 @@
1
+ import asyncio
2
+ from inspect import iscoroutinefunction, signature
3
+ from typing import Any, Callable, Optional, Type
4
+
5
+ from pydantic import BaseModel, create_model
6
+
7
+ from lumivor.browser.context import BrowserContext
8
+ from lumivor.controller.registry.views import (
9
+ ActionModel,
10
+ ActionRegistry,
11
+ RegisteredAction,
12
+ )
13
+ from lumivor.telemetry.service import ProductTelemetry
14
+ from lumivor.telemetry.views import (
15
+ ControllerRegisteredFunctionsTelemetryEvent,
16
+ RegisteredFunction,
17
+ )
18
+
19
+
20
+ class Registry:
21
+ """Service for registering and managing actions"""
22
+
23
+ def __init__(self):
24
+ self.registry = ActionRegistry()
25
+ self.telemetry = ProductTelemetry()
26
+
27
+ def _create_param_model(self, function: Callable) -> Type[BaseModel]:
28
+ """Creates a Pydantic model from function signature"""
29
+ sig = signature(function)
30
+ params = {
31
+ name: (param.annotation, ... if param.default ==
32
+ param.empty else param.default)
33
+ for name, param in sig.parameters.items()
34
+ if name != 'browser'
35
+ }
36
+ # TODO: make the types here work
37
+ return create_model(
38
+ f'{function.__name__}Params',
39
+ __base__=ActionModel,
40
+ **params, # type: ignore
41
+ )
42
+
43
+ def action(
44
+ self,
45
+ description: str,
46
+ param_model: Optional[Type[BaseModel]] = None,
47
+ requires_browser: bool = False,
48
+ ):
49
+ """Decorator for registering actions"""
50
+
51
+ def decorator(func: Callable):
52
+ # Create param model from function if not provided
53
+ actual_param_model = param_model or self._create_param_model(func)
54
+
55
+ # Wrap sync functions to make them async
56
+ if not iscoroutinefunction(func):
57
+
58
+ async def async_wrapper(*args, **kwargs):
59
+ return await asyncio.to_thread(func, *args, **kwargs)
60
+
61
+ # Copy the signature and other metadata from the original function
62
+ async_wrapper.__signature__ = signature(func)
63
+ async_wrapper.__name__ = func.__name__
64
+ async_wrapper.__annotations__ = func.__annotations__
65
+ wrapped_func = async_wrapper
66
+ else:
67
+ wrapped_func = func
68
+
69
+ action = RegisteredAction(
70
+ name=func.__name__,
71
+ description=description,
72
+ function=wrapped_func,
73
+ param_model=actual_param_model,
74
+ requires_browser=requires_browser,
75
+ )
76
+ self.registry.actions[func.__name__] = action
77
+ return func
78
+
79
+ return decorator
80
+
81
+ async def execute_action(
82
+ self, action_name: str, params: dict, browser: Optional[BrowserContext] = None
83
+ ) -> Any:
84
+ """Execute a registered action"""
85
+ if action_name not in self.registry.actions:
86
+ raise ValueError(f'Action {action_name} not found')
87
+
88
+ action = self.registry.actions[action_name]
89
+ try:
90
+ # Create the validated Pydantic model
91
+ validated_params = action.param_model(**params)
92
+
93
+ # Check if the first parameter is a Pydantic model
94
+ sig = signature(action.function)
95
+ parameters = list(sig.parameters.values())
96
+ is_pydantic = parameters and issubclass(
97
+ parameters[0].annotation, BaseModel)
98
+
99
+ # Prepare arguments based on parameter type
100
+ if action.requires_browser:
101
+ if not browser:
102
+ raise ValueError(
103
+ f'Action {
104
+ action_name} requires browser but none provided. This has to be used in combination of `requires_browser=True` when registering the action.'
105
+ )
106
+ if is_pydantic:
107
+ return await action.function(validated_params, browser=browser)
108
+ return await action.function(**validated_params.model_dump(), browser=browser)
109
+
110
+ if is_pydantic:
111
+ return await action.function(validated_params)
112
+ return await action.function(**validated_params.model_dump())
113
+
114
+ except Exception as e:
115
+ raise RuntimeError(f'Error executing action {
116
+ action_name}: {str(e)}') from e
117
+
118
+ def create_action_model(self) -> Type[ActionModel]:
119
+ """Creates a Pydantic model from registered actions"""
120
+ fields = {
121
+ name: (Optional[action.param_model], None)
122
+ for name, action in self.registry.actions.items()
123
+ }
124
+
125
+ self.telemetry.capture(
126
+ ControllerRegisteredFunctionsTelemetryEvent(
127
+ registered_functions=[
128
+ RegisteredFunction(
129
+ name=name, params=action.param_model.model_json_schema())
130
+ for name, action in self.registry.actions.items()
131
+ ]
132
+ )
133
+ )
134
+
135
+ # type:ignore
136
+ return create_model('ActionModel', __base__=ActionModel, **fields)
137
+
138
+ def get_prompt_description(self) -> str:
139
+ """Get a description of all actions for the prompt"""
140
+ return self.registry.get_prompt_description()
@@ -0,0 +1,71 @@
1
+ from typing import Callable, Dict, Type
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class RegisteredAction(BaseModel):
7
+ """Model for a registered action"""
8
+
9
+ name: str
10
+ description: str
11
+ function: Callable
12
+ param_model: Type[BaseModel]
13
+ requires_browser: bool = False
14
+
15
+ model_config = ConfigDict(arbitrary_types_allowed=True)
16
+
17
+ def prompt_description(self) -> str:
18
+ """Get a description of the action for the prompt"""
19
+ skip_keys = ['title']
20
+ s = f'{self.description}: \n'
21
+ s += '{' + str(self.name) + ': '
22
+ s += str(
23
+ {
24
+ k: {sub_k: sub_v for sub_k, sub_v in v.items() if sub_k not in skip_keys}
25
+ for k, v in self.param_model.schema()['properties'].items()
26
+ }
27
+ )
28
+ s += '}'
29
+ return s
30
+
31
+
32
+ class ActionModel(BaseModel):
33
+ """Base model for dynamically created action models"""
34
+
35
+ # this will have all the registered actions, e.g.
36
+ # click_element = param_model = ClickElementParams
37
+ # done = param_model = None
38
+ #
39
+ model_config = ConfigDict(arbitrary_types_allowed=True)
40
+
41
+ def get_index(self) -> int | None:
42
+ """Get the index of the action"""
43
+ # {'clicked_element': {'index':5}}
44
+ params = self.model_dump(exclude_unset=True).values()
45
+ if not params:
46
+ return None
47
+ for param in params:
48
+ if param is not None and 'index' in param:
49
+ return param['index']
50
+ return None
51
+
52
+ def set_index(self, index: int):
53
+ """Overwrite the index of the action"""
54
+ # Get the action name and params
55
+ action_data = self.model_dump(exclude_unset=True)
56
+ action_name = next(iter(action_data.keys()))
57
+ action_params = getattr(self, action_name)
58
+
59
+ # Update the index directly on the model
60
+ if hasattr(action_params, 'index'):
61
+ action_params.index = index
62
+
63
+
64
+ class ActionRegistry(BaseModel):
65
+ """Model representing the action registry"""
66
+
67
+ actions: Dict[str, RegisteredAction] = {}
68
+
69
+ def get_prompt_description(self) -> str:
70
+ """Get a description of all actions for the prompt"""
71
+ return '\n'.join([action.prompt_description() for action in self.actions.values()])