Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,10 @@ benchmark/

# node modules
node_modules/

tmp/

# Key files
*.pem
*.key
*.gpg
10 changes: 6 additions & 4 deletions patchwork/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,22 @@ def sigint_handler(signum, frame):
@click.option("patched_api_key", "--patched_api_key", help="API key to use with the patched.codes service.")
@click.option("disable_telemetry", "--disable_telemetry", is_flag=True, help="Disable telemetry.", default=False)
@click.option("debug", "--debug", is_flag=True, help="Enable debug mode.", default=False)
@click.option("plain", "--plain", is_flag=True, help="Enable plain mode (no panel).", default=False)
def cli(
log: str,
patchflow: str,
opts: list[str],
config: str | None,
output: str | None,
plain: bool,
data_format: str,
patched_api_key: str | None,
disable_telemetry: bool,
debug: bool,
):
setup_cli()

init_cli_logger(log)
is_plain_console = plain or debug
init_cli_logger(log, is_plain_console)

if "::" in patchflow:
module_path, _, patchflow_name = patchflow.partition("::")
Expand All @@ -167,7 +169,7 @@ def cli(

possbile_module_paths = deque((module_path,))

panel = logger.panel("Initializing Patchwork CLI") if debug else nullcontext()
panel = nullcontext() if is_plain_console else logger.panel("Initializing Patchwork CLI")

with panel:
inputs = {}
Expand Down Expand Up @@ -227,7 +229,7 @@ def cli(
# treat --key=value as a key-value pair
inputs[key] = value

patchflow_panel = nullcontext() if debug else logger.panel(f"Patchflow {patchflow} inputs")
patchflow_panel = nullcontext() if is_plain_console else logger.panel(f"Patchflow {patchflow} inputs")

with patchflow_panel as _:
if debug is True:
Expand Down
167 changes: 167 additions & 0 deletions patchwork/common/utils/browser_initializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import logging
import os
from typing import List, Optional

from browser_use import Browser, BrowserConfig, BrowserContextConfig, Controller
from browser_use.agent.views import ActionResult
from browser_use.browser.context import BrowserContext

logger = logging.getLogger(__name__)


async def set_file_input(index: int, paths: str | List[str], browser: BrowserContext):
"""
Set the file input value to the given path or list of paths.

Args:
index: The DOM element index to target
paths: Local file path or list of local file paths to upload
browser: Browser context for interaction

Returns:
ActionResult: Result of the upload operation
"""
if isinstance(paths, str):
paths = [paths]

for path in paths:
if not os.path.exists(path):
return ActionResult(error=f"File {path} does not exist")

dom_el = await browser.get_dom_element_by_index(index)
file_upload_dom_el = dom_el.get_file_upload_element()

if file_upload_dom_el is None:
msg = f"No file upload element found at index {index}. The element may be hidden or not an input type file"
logger.info(msg)
return ActionResult(error=msg)

file_upload_el = await browser.get_locate_element(file_upload_dom_el)

if file_upload_el is None:
msg = f"No file upload element found at index {index}. The element may be hidden or not an input type file"
logger.info(msg)
return ActionResult(error=msg)

try:
await file_upload_el.set_input_files(paths)
msg = f"Successfully set file input value to {paths}"
logger.info(msg)
return ActionResult(extracted_content=msg, include_in_memory=True)
except Exception as e:
msg = f"Failed to upload file to index {index}: {str(e)}"
logger.info(msg)
return ActionResult(error=msg)


async def close_current_tab(browser: BrowserContext):
await browser.close_current_tab()
msg = "🔄 Closed current tab"
logger.info(msg)
return ActionResult(extracted_content=msg, include_in_memory=True)


class BrowserInitializer:
"""
Initialize and cache browser and controller instances.

This class uses a singleton pattern to ensure we only create one browser
instance throughout the application lifecycle, which saves resources.
"""

_browser = None
_controller = None
_browser_context = None

@classmethod
def init_browser(cls, config=BrowserConfig()):
"""
Initialize and cache the Browser instance.

Returns:
Browser: Browser instance for web automation
"""
if cls._browser is not None:
return cls._browser

cls._browser = Browser(config=config)
return cls._browser

@classmethod
def init_browser_context(cls, config: Optional[BrowserConfig], downloads_path: Optional[str] = None):
"""
Initialize and cache the BrowserContext instance.

Returns:
BrowserContext: BrowserContext instance for managing browser context
"""
if cls._browser_context is not None:
return cls._browser_context

if downloads_path and not os.path.exists(downloads_path):
os.makedirs(downloads_path)

context_config = BrowserContextConfig(
# cookies_file=cookies_file,
browser_window_size={"width": 1920, "height": 1080},
)
browser = cls.init_browser(config=config)

class BrowserContextWithDownloadHandling(BrowserContext):
async def handle_download(self, download):
suggested_filename = download.suggested_filename
unique_filename = await self._get_unique_filename(downloads_path, suggested_filename)
download_path = os.path.join(downloads_path, unique_filename)
await download.save_as(download_path)
logger.info(f"Downloaded file saved to {download_path}")

async def _initialize_session(self):
async def _download_listener(download):
logger.info("[BUD] Download event triggered")
await self.handle_download(download)
return download

def _new_page_listener(page):
logger.info("[BUD] Adding download event listener to page")
page.on("download", _download_listener)
return page

await super()._initialize_session()

logger.info("[BUD] Adding page event listener to context")
self.session.context.on("page", _new_page_listener)

logger.info(f"[BUD] Adding download event listener to {len(self.session.context.pages)} existing pages")
for page in self.session.context.pages:
page.on("download", _download_listener)

cls._browser_context = (
BrowserContextWithDownloadHandling(browser=browser, config=context_config)
if downloads_path
else BrowserContext(browser=browser, config=context_config)
)
return cls._browser_context

@classmethod
def init_controller(cls):
"""
Initialize and cache the Controller instance.

Returns:
Controller: Controller instance for managing browser actions
"""
if cls._controller is not None:
return cls._controller

controller = Controller()

controller.action(
"Set the value of a file input to the given path or list of paths",
)(set_file_input)

controller.action(
description="Close the tab that is currently active",
)(close_current_tab)

cls._controller = controller
return cls._controller
47 changes: 26 additions & 21 deletions patchwork/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from patchwork.managed_files import HOME_FOLDER, LOG_FILE

# Create a global console object
console = Console()
console = Console(force_terminal=True, no_color=False)

# Add TRACE level to logging
logging.TRACE = logging.DEBUG - 1
Expand All @@ -39,10 +39,11 @@ def evict_null_handler():


class TerminalHandler(RichHandler):
def __init__(self, log_level: str):
def __init__(self, log_level: str, plain: bool):
self.plain = plain
super().__init__(
console=console,
rich_tracebacks=True,
rich_tracebacks=not plain,
tracebacks_suppress=[click],
show_time=False,
show_path=False,
Expand Down Expand Up @@ -95,22 +96,25 @@ def deregister_progress_bar(self):
@contextlib.contextmanager
def panel(self, title: str):
global console
self.__panel_lines = []
self.__panel_title = title
self.__panel = Panel("", title=title)
renderables = [self.__panel]
if self.__progress_bar is not None:
renderables.append(self.__progress_bar)

self.__live = Live(Group(*renderables), console=console, vertical_overflow="visible")
try:
self.__live.start()
if self.plain:
yield
except Exception as e:
raise e
finally:
self.__reset_live()
self.console.print("\n")
else:
self.__panel_lines = []
self.__panel_title = title
self.__panel = Panel("", title=title)
renderables = [self.__panel]
if self.__progress_bar is not None:
renderables.append(self.__progress_bar)

self.__live = Live(Group(*renderables), console=console, vertical_overflow="visible")
try:
self.__live.start()
yield
except Exception as e:
raise e
finally:
self.__reset_live()
self.console.print("\n")

def emit(self, record: logging.LogRecord) -> None:
markup = getattr(record, "markup", None)
Expand All @@ -126,7 +130,8 @@ def emit(self, record: logging.LogRecord) -> None:
if self.__panel is not None:
self.__emit_panel(record)
else:
setattr(record, "markup", True)
if not self.plain:
setattr(record, "markup", True)
super().emit(record)

def __emit_panel(self, record: logging.LogRecord) -> None:
Expand All @@ -143,7 +148,7 @@ def inner(record: logging.LogRecord) -> bool:
return inner


def init_cli_logger(log_level: str) -> logging.Logger:
def init_cli_logger(log_level: str, plain: bool) -> logging.Logger:
global logger

evict_null_handler()
Expand All @@ -158,7 +163,7 @@ def init_cli_logger(log_level: str) -> logging.Logger:
except FileNotFoundError:
logger.error(f"Unable to create log file: {LOG_FILE}")

th = TerminalHandler(log_level.upper())
th = TerminalHandler(log_level.upper(), plain)
logger.addHandler(th)
setattr(logger, "panel", th.panel)
setattr(logger, "register_progress_bar", th.register_progress_bar)
Expand Down
Loading