sunholo 0.74.7__py3-none-any.whl → 0.74.9__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.
@@ -4,6 +4,7 @@ import json
4
4
  from datetime import datetime
5
5
  import urllib.parse
6
6
  import time
7
+ from io import BytesIO
7
8
 
8
9
  from ..logging import log
9
10
 
@@ -115,7 +116,14 @@ class BrowseWebWithImagePromptsBot:
115
116
  ```
116
117
  """
117
118
  #class BrowseWebWithImagePromptsBot:
118
- def __init__(self, session_id, website_name, browser_type='chromium', headless=True, max_steps=10):
119
+ def __init__(
120
+ self,
121
+ website_name:str,
122
+ session_id: str=None,
123
+ browser_type:str='chromium',
124
+ headless:bool=True,
125
+ max_steps:int=10
126
+ ):
119
127
  try:
120
128
  from playwright.sync_api import sync_playwright
121
129
  except ImportError as err:
@@ -125,12 +133,19 @@ class BrowseWebWithImagePromptsBot:
125
133
  if not sync_playwright:
126
134
  raise ImportError("playright needed for BrowseWebWithImagePromptsBot class - install via `pip install sunholo[tools]`")
127
135
 
128
- self.session_id = session_id or datetime.now().strftime("%Y%m%d%H%M%S")
136
+ log.info(f"Starting BrowseWebWithImagePromptsBot with {website_name=}, {session_id=}, {browser_type=}, {headless=}, {max_steps=}")
137
+
138
+ # Assign session_id if it is None or 'None'
139
+ if not session_id or session_id == 'None':
140
+ self.session_id = datetime.now().strftime("%Y%m%d%H%M%S")
141
+ else:
142
+ self.session_id = session_id
143
+
129
144
  self.website_name = website_name
130
- self.browser_type = browser_type
131
- self.max_steps = max_steps
145
+ self.browser_type = browser_type or 'chromium'
146
+ self.max_steps = int(max_steps)
132
147
  self.steps = 0
133
- self.screenshot_dir = f"browser_tool/{get_clean_website_name(website_name)}/{session_id}"
148
+ self.screenshot_dir = f"browser_tool/{get_clean_website_name(website_name)}/{self.session_id}"
134
149
  os.makedirs(self.screenshot_dir, exist_ok=True)
135
150
  self.cookie_file = os.path.join(self.screenshot_dir, "cookies.json")
136
151
  self.action_log_file = os.path.join(self.screenshot_dir, "action_log.json")
@@ -199,21 +214,81 @@ class BrowseWebWithImagePromptsBot:
199
214
  log.warning(f"navigate failed with {str(err)}")
200
215
  self.action_log.append(f"Tried to navigate to {url} but got an error")
201
216
 
217
+ def get_locator_via_roles_and_placeholder(self, selector: str):
218
+ interactive_roles = ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"]
219
+
220
+ for role in interactive_roles:
221
+ log.info(f'Trying role {role} for selector {selector}')
222
+ elements = self.page.get_by_role(role).get_by_placeholder(selector).locator("visible=true").all()
223
+ if elements:
224
+ log.info(f"Got {len(elements)} elements for selector {selector} with role {role}")
225
+ for element in elements:
226
+ try:
227
+ log.info(f"Trying {selector} with element.hover locator: {element}")
228
+ try:
229
+ element.hover(timeout=10000, trial=True)
230
+ self.action_log.append(f"Successfully found element via selector: {selector}")
231
+
232
+ return element
233
+
234
+ except Exception as err:
235
+ log.warning(f"Could not hover over element: {element} {str(err)} - trying next element")
236
+ except Exception as e:
237
+ log.error(f"Failed to get locator for selector '{selector}' with role {role}: {str(e)}")
238
+
239
+ time.sleep(0.5) # Wait for a bit before retrying
240
+
241
+ log.info(f"No elements for '{selector}' within role '{role}'")
242
+
243
+ self.action_log.append(f"FAILED: Using page.get_by_role('role').get_by_placeholder('{selector}').locator('visible=true') could not find any valid element. Try something else.")
244
+ return None
245
+
246
+ def get_locator_via_roles_and_text(self, selector: str):
247
+ interactive_roles = ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"]
248
+
249
+ for role in interactive_roles:
250
+ log.info(f'Trying role {role} for selector {selector}')
251
+ elements = self.page.get_by_role(role).get_by_text(selector).locator("visible=true").all()
252
+ if elements:
253
+ log.info(f"Got {len(elements)} elements for selector {selector} with role {role}")
254
+ for element in elements:
255
+ try:
256
+ log.info(f"Trying {selector} with element.hover locator: {element}")
257
+ try:
258
+ element.hover(timeout=10000, trial=True)
259
+ self.action_log.append(f"Successfully found element via selector: {selector}")
260
+
261
+ return element
262
+
263
+ except Exception as err:
264
+ log.warning(f"Could not hover over element: {element} {str(err)} - trying next element")
265
+ except Exception as e:
266
+ log.error(f"Failed to get locator for selector '{selector}' with role {role}: {str(e)}")
267
+
268
+ time.sleep(0.5) # Wait for a bit before retrying
269
+
270
+ log.info(f"No elements for '{selector}' within role '{role}'")
271
+
272
+ self.action_log.append(f"FAILED: Using page.get_by_role('role').get_by_text('{selector}').locator('visible=true') could not find any valid element. Try something else.")
273
+ return None
274
+
202
275
  def get_locator(self, selector, by_text=True):
203
276
  if by_text:
204
- elements = self.page.get_by_text(selector).all()
205
- if elements:
206
- return elements[0]
207
- else:
208
- log.warning(f"No elements found with text: {selector}")
209
- return None
277
+ element = self.get_locator_via_roles_and_text(selector)
210
278
  else:
211
- return self.page.locator(selector)
279
+ element = self.page.locator(selector)
280
+
281
+ if element:
282
+ return element
283
+
284
+ log.error(f"Failed to get locator for selector {selector}")
285
+
286
+ return None
212
287
 
213
- def click(self, selector, by_text=True):
288
+ def click(self, selector):
214
289
  (x,y)=(0,0)
215
290
 
216
- element = self.get_locator(selector, by_text=by_text)
291
+ element = self.get_locator_via_roles_and_text(selector)
217
292
  if element is None:
218
293
  self.action_log.append(f"Tried to click on text {selector} but it was not a valid location to click")
219
294
  return (x,y)
@@ -229,14 +304,14 @@ class BrowseWebWithImagePromptsBot:
229
304
  try:
230
305
  element.click()
231
306
  self.page.wait_for_load_state()
232
- log.info(f"Clicked on element with selector {selector} at {x=},{y=}")
233
- self.action_log.append(f"Clicked on element with selector {selector} at {x=},{y=}")
307
+ log.info(f"Clicked on {element=} with {selector=} at {x=},{y=}")
308
+ self.action_log.append(f"Clicked on {element=} with {selector=} at {x=},{y=}")
234
309
 
235
310
  return (x,y)
236
311
 
237
312
  except Exception as err:
238
313
  log.warning(f"click failed with {str(err)}")
239
- self.action_log.append(f"Tried to click on element with selector {selector} at {x=},{y=} but got an error")
314
+ self.action_log.append(f"Tried to click on {element=} with {selector=} at {x=},{y=} but got an error")
240
315
 
241
316
  return (x,y)
242
317
 
@@ -257,11 +332,11 @@ class BrowseWebWithImagePromptsBot:
257
332
  log.warning(f"Scrolled failed with {str(err)}")
258
333
  self.action_log.append(f"Tried to scroll {direction} by {amount} pixels but got an error")
259
334
 
260
- def type_text(self, selector, text, by_text=True):
335
+ def type_text(self, selector, text):
261
336
  (x,y)=(0,0)
262
- element = self.get_locator(selector, by_text=by_text)
337
+ element = self.get_locator_via_roles_and_placeholder(selector)
263
338
  if element is None:
264
- self.action_log.append(f"Tried to type {text} via website text: {selector} but it was not a valid location to add text")
339
+ self.action_log.append(f"Tried to type {text} via website text: {selector} but it was not a valid element to add text")
265
340
  return (x,y)
266
341
 
267
342
  try:
@@ -275,50 +350,60 @@ class BrowseWebWithImagePromptsBot:
275
350
  try:
276
351
  element.fill(text)
277
352
  self.page.wait_for_load_state()
278
- log.info(f"Typed text '{text}' into element with selector {selector} at {x=},{y=}")
279
- self.action_log.append(f"Typed text '{text}' into element with selector {selector} at {x=},{y=}")
353
+ log.info(f"Typed text '{text}' into {element=} with {selector=} at {x=},{y=}")
354
+ self.action_log.append(f"Typed text '{text}' into {element=} with {selector=} at {x=},{y=}")
280
355
 
281
356
  return (x, y)
282
357
 
283
358
  except Exception as err:
284
359
  log.warning(f"Typed text failed with {str(err)}")
285
- self.action_log.append(f"Tried to type text '{text}' into element with selector {selector} at {x=},{y=} but got an error")
360
+ self.action_log.append(f"Tried to type text '{text}' into {element=} with {selector=} at {x=},{y=} but got an error")
286
361
 
287
362
  return (x, y)
288
363
 
289
- def take_screenshot(self, final=False, full_page=False, mark_action=None):
364
+ def take_screenshot(self, full_page=False, mark_action=None):
365
+
366
+ from PIL import Image
367
+
290
368
  timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
291
369
  parsed_url = urllib.parse.urlparse(self.page.url)
292
370
 
293
371
  url_path = parsed_url.path
294
372
  if url_path == "/":
295
373
  url_path = "index.html"
296
- if final:
297
- screenshot_path = os.path.join(self.screenshot_dir, f"final/{timestamp}_{url_path}.png")
298
374
  else:
299
- screenshot_path = os.path.join(self.screenshot_dir, f"{timestamp}_{url_path}.png")
300
- self.page.screenshot(path=screenshot_path, full_page=full_page)
375
+ url_path = url_path.replace("/","_")
376
+
377
+ if get_clean_website_name(url_path) != self.website_name:
378
+ url_path = f"{get_clean_website_name(url_path)}_{url_path}"
379
+
380
+ screenshot_path = os.path.join(self.screenshot_dir, f"{timestamp}_{url_path}.png")
381
+ screenshot_bytes = self.page.screenshot(full_page=full_page, scale='css')
301
382
 
302
383
  if mark_action:
303
- self.mark_screenshot(screenshot_path, mark_action)
384
+ image = self.mark_screenshot(screenshot_bytes, mark_action)
385
+ else:
386
+ image = Image.open(BytesIO(screenshot_bytes))
387
+
388
+ image.save(screenshot_path, format='png')
304
389
 
305
390
  log.info(f"Screenshot {self.page.url} taken and saved to {screenshot_path}")
306
391
  #self.action_log.append(f"Screenshot {self.page.url} taken and saved to {screenshot_path}")
307
392
  self.session_screenshots.append(screenshot_path)
308
393
 
309
- return screenshot_path
394
+ return screenshot_bytes
310
395
 
311
- def mark_screenshot(self, screenshot_path, mark_action):
396
+ def mark_screenshot(self, screenshot_bytes, mark_action):
312
397
  """
313
398
  Marks the screenshot with the specified action.
314
399
 
315
400
  Parameters:
316
- screenshot_path (str): The path to the screenshot.
401
+ screenshot_bytes (bytes): The bytes of the screenshot.
317
402
  mark_action (dict): Action details for marking the screenshot.
318
403
  """
319
404
  from PIL import Image, ImageDraw
320
-
321
- image = Image.open(screenshot_path)
405
+ time.sleep(1) # maybe pass in bytes to avoid waiting for file to be written
406
+ image = Image.open(BytesIO(screenshot_bytes))
322
407
  draw = ImageDraw.Draw(image)
323
408
 
324
409
  if mark_action['type'] == 'click':
@@ -329,7 +414,7 @@ class BrowseWebWithImagePromptsBot:
329
414
  x, y = mark_action['position']
330
415
  draw.rectangle((x-5, y-5, x+5, y+5), outline='blue', width=3)
331
416
 
332
- image.save(screenshot_path)
417
+ return image
333
418
 
334
419
  def get_latest_screenshot_path(self):
335
420
  screenshots = sorted(
@@ -364,6 +449,10 @@ class BrowseWebWithImagePromptsBot:
364
449
  else:
365
450
  log.warning(f'Unknown response: {response=} {type(response)}')
366
451
  output = None
452
+
453
+ if not output:
454
+ log.error(f'Got no output from response: {response=}')
455
+ return None
367
456
 
368
457
  if 'status' not in output:
369
458
  log.error(f'Response did not contain status')
@@ -377,9 +466,9 @@ class BrowseWebWithImagePromptsBot:
377
466
 
378
467
  return output
379
468
 
380
- def send_screenshot_to_llm(self, screenshot_path, last_message):
381
- with open(screenshot_path, "rb") as image_file:
382
- encoded_image = base64.b64encode(image_file.read()).decode('utf-8')
469
+ def send_screenshot_to_llm(self, screenshot_bytes, last_message):
470
+
471
+ encoded_image = base64.b64encode(screenshot_bytes).decode('utf-8')
383
472
 
384
473
  prompt_vars = self.create_prompt_vars(last_message)
385
474
  response = self.send_prompt_to_llm(prompt_vars, encoded_image) # Sending prompt and image separately
@@ -396,6 +485,8 @@ This method should be implemented by subclasses: `def send_prompt_to_llm(self, p
396
485
  """)
397
486
 
398
487
  def close(self):
488
+ log.info(f"Session {self.session_id} finished")
489
+ self.take_screenshot()
399
490
  self.save_cookies()
400
491
  self.browser.close()
401
492
  self.playwright.stop()
@@ -437,14 +528,14 @@ This method should be implemented by subclasses: `def send_prompt_to_llm(self, p
437
528
  log.warning(f"Reached the maximum number of steps: {self.max_steps}")
438
529
  return
439
530
  time.sleep(2)
440
- screenshot_path = self.take_screenshot(mark_action=mark_action)
531
+ screenshot_bytes = self.take_screenshot(mark_action=mark_action)
441
532
  next_browser_instructions = self.send_screenshot_to_llm(
442
- screenshot_path,
533
+ screenshot_bytes,
443
534
  last_message=last_message)
444
535
 
445
536
  return next_browser_instructions
446
537
 
447
- def create_gif_from_pngs(self, frame_duration=500):
538
+ def create_gif_from_pngs(self, frame_duration=300):
448
539
  """
449
540
  Creates a GIF from a folder of PNG images.
450
541
 
@@ -501,9 +592,17 @@ This method should be implemented by subclasses: `def send_prompt_to_llm(self, p
501
592
  log.error('Browser status: "in-progress" but no new_instructions')
502
593
  last_message = next_instructions['message']
503
594
  self.action_log.append(last_message)
504
- next_instructions = self.execute_instructions(
505
- next_instructions['new_instructions'],
506
- last_message=last_message)
595
+ try:
596
+ next_instructions = self.execute_instructions(
597
+ next_instructions['new_instructions'],
598
+ last_message=last_message)
599
+ except Exception as err:
600
+ log.error(f'session aborted due to: {str(err)}')
601
+ next_instructions = {
602
+ 'status': 'error',
603
+ 'message': f'session errored with: {str(err)}'
604
+ }
605
+ break
507
606
  else:
508
607
  log.info(f'Session finished due to status={next_instructions["status"]}')
509
608
  in_session=False
@@ -513,9 +612,6 @@ This method should be implemented by subclasses: `def send_prompt_to_llm(self, p
513
612
  in_session=False
514
613
  break
515
614
 
516
- log.info("Session finished")
517
- self.take_screenshot()
518
-
519
615
  self.close()
520
616
 
521
617
  answer = None
sunholo/utils/parsers.py CHANGED
@@ -186,6 +186,6 @@ def escape_braces(text):
186
186
  text = re.sub(r'(?<!})}(?!})', '}}', text) # Replace '}' with '}}' if not already double braced
187
187
  return text
188
188
 
189
- def get_clean_website_name(url):
189
+ def get_clean_website_name(url: str):
190
190
  parsed_url = urllib.parse.urlparse(url)
191
191
  return parsed_url.netloc
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sunholo
3
- Version: 0.74.7
3
+ Version: 0.74.9
4
4
  Summary: Large Language Model DevOps - a package to help deploy LLMs to the Cloud.
5
5
  Home-page: https://github.com/sunholo-data/sunholo-py
6
- Download-URL: https://github.com/sunholo-data/sunholo-py/archive/refs/tags/v0.74.7.tar.gz
6
+ Download-URL: https://github.com/sunholo-data/sunholo-py/archive/refs/tags/v0.74.9.tar.gz
7
7
  Author: Holosun ApS
8
8
  Author-email: multivac@sunholo.com
9
9
  License: Apache License, Version 2.0
@@ -99,7 +99,7 @@ sunholo/streaming/streaming.py,sha256=9z6pXINEopuL_Z1RnmgXAoZJum9dzyuOxqYtEYnjf8
99
99
  sunholo/summarise/__init__.py,sha256=MZk3dblUMODcPb1crq4v-Z508NrFIpkSWNf9FIO8BcU,38
100
100
  sunholo/summarise/summarise.py,sha256=C3HhjepTjUhUC8FLk4jMQIBvq1BcORniwuTFHjPVhVo,3784
101
101
  sunholo/tools/__init__.py,sha256=5NuYpwwTX81qGUWvgwfItoSLXteNnp7KjgD7IPZUFjI,53
102
- sunholo/tools/web_browser.py,sha256=SGNSObQ6bxgFGrI6FoKI31F1EuSQ1XonJxauStYWtTg,21780
102
+ sunholo/tools/web_browser.py,sha256=wms9vHndrbD2NaAvNpYG291YiGP8wVHJ6iljsMVZc5w,26292
103
103
  sunholo/utils/__init__.py,sha256=Hv02T5L2zYWvCso5hzzwm8FQogwBq0OgtUbN_7Quzqc,89
104
104
  sunholo/utils/api_key.py,sha256=Ct4bIAQZxzPEw14hP586LpVxBAVi_W9Serpy0BK-7KI,244
105
105
  sunholo/utils/big_context.py,sha256=gJIP7_ZL-YSLhOMq8jmFTMqH1wq8eB1NK7oKPeZAq2s,5578
@@ -108,7 +108,7 @@ sunholo/utils/config_class.py,sha256=4fm2Bwn_zFhVJBiUnMBzfCA5LKhTcBMU3mzhf5seXrw
108
108
  sunholo/utils/config_schema.py,sha256=Wv-ncitzljOhgbDaq9qnFqH5LCuxNv59dTGDWgd1qdk,4189
109
109
  sunholo/utils/gcp.py,sha256=uueODEpA-P6O15-t0hmcGC9dONLO_hLfzSsSoQnkUss,4854
110
110
  sunholo/utils/gcp_project.py,sha256=0ozs6tzI4qEvEeXb8MxLnCdEVoWKxlM6OH05htj7_tc,1325
111
- sunholo/utils/parsers.py,sha256=aCIT08VjVbu8E3BAxepIiqFQa8zwu4bTgEstU_qjyg8,5414
111
+ sunholo/utils/parsers.py,sha256=akLSZLdvHf5T9OKVj5C3bo4g3Y8puATd8rxnbB4tWDs,5419
112
112
  sunholo/utils/timedelta.py,sha256=BbLabEx7_rbErj_YbNM0MBcaFN76DC4PTe4zD2ucezg,493
113
113
  sunholo/utils/user_ids.py,sha256=SQd5_H7FE7vcTZp9AQuQDWBXd4FEEd7TeVMQe1H4Ny8,292
114
114
  sunholo/utils/version.py,sha256=P1QAJQdZfT2cMqdTSmXmcxrD2PssMPEGM-WI6083Fck,237
@@ -117,9 +117,9 @@ sunholo/vertex/extensions_class.py,sha256=4PsUM9dSYrIPpq9bZ3K2rL9MRb_rlqAgnMsW0o
117
117
  sunholo/vertex/init.py,sha256=-w7b9GKsyJnAJpYHYz6_zBUtmeJeLXlEkgOfwoe4DEI,2715
118
118
  sunholo/vertex/memory_tools.py,sha256=pomHrDKqvY8MZxfUqoEwhdlpCvSGP6KmFJMVKOimXjs,6842
119
119
  sunholo/vertex/safety.py,sha256=S9PgQT1O_BQAkcqauWncRJaydiP8Q_Jzmu9gxYfy1VA,2482
120
- sunholo-0.74.7.dist-info/LICENSE.txt,sha256=SdE3QjnD3GEmqqg9EX3TM9f7WmtOzqS1KJve8rhbYmU,11345
121
- sunholo-0.74.7.dist-info/METADATA,sha256=7UcUFn-TT6y-4aTHe3JyuBpoK1iiBVbFsusIAXys94U,7010
122
- sunholo-0.74.7.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
123
- sunholo-0.74.7.dist-info/entry_points.txt,sha256=bZuN5AIHingMPt4Ro1b_T-FnQvZ3teBes-3OyO0asl4,49
124
- sunholo-0.74.7.dist-info/top_level.txt,sha256=wt5tadn5--5JrZsjJz2LceoUvcrIvxjHJe-RxuudxAk,8
125
- sunholo-0.74.7.dist-info/RECORD,,
120
+ sunholo-0.74.9.dist-info/LICENSE.txt,sha256=SdE3QjnD3GEmqqg9EX3TM9f7WmtOzqS1KJve8rhbYmU,11345
121
+ sunholo-0.74.9.dist-info/METADATA,sha256=tf6DzIGnmFeb47QUwocgunQWJD2WEmYI3Kv73pcCNy4,7010
122
+ sunholo-0.74.9.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
123
+ sunholo-0.74.9.dist-info/entry_points.txt,sha256=bZuN5AIHingMPt4Ro1b_T-FnQvZ3teBes-3OyO0asl4,49
124
+ sunholo-0.74.9.dist-info/top_level.txt,sha256=wt5tadn5--5JrZsjJz2LceoUvcrIvxjHJe-RxuudxAk,8
125
+ sunholo-0.74.9.dist-info/RECORD,,