Command line programs are powerful because they can access and control most things on your machine. Consequently, they allow users to quickly perform tasks and automate processes with just a few succinct commands.
In this article, I will discuss how I built my Python command-line tool and how it has improved my browsing experience. I will also explain how you can build your own tool to enhance your browsing experience.
The idea was inspired by the article Using ChatGPT to make Bash palatable where bash was used to close tabs and open saved URLs from files. In this article, more features will be added, and Python will be used (although combined with bash subcommands).
The advantage of a CLI
One of the benefits of developing a CLI for a program like this rather than creating a Chrome extension, for instance, is that it can seamlessly be integrated with other commands.
For example, imagine you are running some time-consuming process in the terminal and would like to know when it’s finished. Then you can run the following:
my_long_running_process ; browsertool open_message "It's finished"
The ;
between the commands means that the second command will run when the first command has finished:
https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FM5TrxOrZ25g%3Ffeature%3Doembed&display_name=YouTube&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DM5TrxOrZ25g&key=a19fcc184b9711e1b4764040d3dc5c07&type=text%2Fhtml&schema=youtube
In the video example, I used the sleep command to simulate running a process before the message is opened, but the sleep command can be useful in a real scenario as well.
For instance, let’s say I would like to relax for an hour and watch some YouTube, but when that hour is up I would like to close down all tabs and open my work tabs. This can easily be done with the following chained commands:
sleep 3600 ; browsertool clear ; browsertool open_tabs work
By combining different command line programs, powerful tools can be built.
In the rest of the article, I will explain how I built the tool. I’ve split it into two parts. I start by describing the functionality, and thereafter, I will describe how the program is structured to facilitate a CLI and how to extend it.
Let’s begin!
Functionality
I start with importing the packages and defining some constant variables:
#!/usr/bin/env python3
from argparse import ArgumentParser
import os
import re
import subprocess
from typing import Callable, List
from dataclasses import dataclass
from pathlib import Path
import os
# specify where groups should be stored
SCRIPT_PATH = Path(__file__).parent.resolve()
GROUP_PATH = f"{SCRIPT_PATH}/groups"
The top line is a shebang to make the OS know it’s a Python script when running it as a CLI. The GROUP_PATH
variable specifies where the files containing the groups of URLs that can be saved and opened together will be stored.
Now, I will define a method to run AppleScripts from Python:
# will run the apple script and return the output
def run_apple_script(script: str) -> str:
return subprocess.check_output(f"osascript -e '{script}'", shell=True).decode("utf-8")
The method will run the script in a subcommand and then return the output back into Python as a string. I am using type annotations here, that is:
script: str
means thescript
argument should be of typestr
-> str
means the method will return a string
I will use these throughout the program. They are not necessary and do not change the run-time behavior in Python, but can be useful to document the functions and help your IDE or other tools spot errors.
I knew practically nothing about AppleScripts before I started building this program, but found it would be a suitable tool to interact with the browser tabs on mac. I mostly used ChatGPT to piece together those scripts, as not much was found on Google. For this reason, I won’t go much into detail about them.
Next, let’s define some methods that interact with the browser.
Save tabs
This function will save all currently open tabs into a file with a specified name:
def get_group_filepath(name: str) -> str:
return f"{GROUP_PATH}/{name}.txt"
# remove duplicates while preserving order
def remove_duplicates(arr: List[str]) -> List[str]:
added = set()
new_arr = []
for e in arr:
if e in added:
continue
new_arr.append(e)
added.add(e)
return new_arr
# save the urls of the currently open tabs to a group
def save_tabs(name: str, replace: bool=False) -> None:
# get all open tabs
tabs = get_tabs()
urls = [tab["url"].strip() for tab in tabs]
urls = [u for u in urls if u != ""]
# get filename to store in
filename = get_group_filepath(name)
# create if not exists
Path(filename).touch(exist_ok=True)
with open(filename, "r+") as f:
# if replace=False, concatenate the new urls with the old ones
# but make sure no duplicates are added
if not replace:
old_urls = f.read().strip().split("\n")
urls = old_urls + urls
urls = remove_duplicates(urls)
# replace content
f.seek(0)
f.write("\n".join(urls).strip())
f.truncate()
The function extracts the URLs from all open tabs with get_tabs
(shown later) and then adds these to a file with one URL per line. If the file doesn’t exist or replace is set to False, then the file will only contain the currently open URLs, otherwise, it will be concatenated with the old URLs in the file.
The get_tabs()
method will be used multiple times in the program and is utilizing an AppleScript:
# will returns all open tabs
def get_tabs() -> List[dict]:
# a suffix is added to simplify
# splitting the output of the apple script
suffix = "<$suffix$>"
# escape { and } in f-string using {{ and }}
tabs = run_apple_script(f"""
set tabList to {{}}
tell application "Google Chrome"
repeat with w in windows
repeat with t in tabs of w
set end of tabList to {{id: id of t, URL: (URL of t) & "{suffix}"}}
end repeat
end repeat
end tell
return tabList
""").strip()
# remove the suffix at the last URL
tabs = tabs[:-len(suffix)]
def tab_to_dict(x: str) -> dict:
# x = "id: ..., URL: ..."
tab = {}
id, url = x.replace("id:", "").split(", URL:")
tab["id"] = id.strip()
tab["url"] = url.strip()
return tab
# can now split using the suffix + ","
tabs = [tab_to_dict(t) for t in tabs.split(f"{suffix},")]
return tabs
This function is a bit messy. First, an AppleScript is run to get all the tabs open in Chrome. The tricky part was that in the beginning, the returned string was formatted as follows:
id: ..., URL: ..., id: ..., URL: ..., etc.
This means that if the URL contains a comma it will be problematic if .split(",")
is used.
For this reason, I concatenate a suffix at the end of the URL, enabling me to split with this suffix to get both the id and URL in each split. Thereafter, it’s just a matter of extracting the values and returning them as a list of dictionaries.
Open tabs
Given that we’ve saved the URLs to a file, we can easily read them and then open them in the browser using AppleScript:
# open the urls in the tab group
def open_tabs(name: str) -> None:
filename = get_group_filepath(name)
if Path(filename).exists():
with open(filename, "r") as f:
urls = f.read().split("\n")
to_open = "\n".join([f'open location "{url}"' for url in urls])
run_apple_script(f"""
tell application "Google Chrome"
activate
make new window
{to_open}
end tell
""")
else:
raise ValueError("Group does not exist.")
List saved tabs
Once the tabs have been saved to files, it’s easy to list them from the folder they were added:
# return a list with all tab groups
def get_tab_groups() -> List[str]:
return [f.replace(".txt", "") for f in os.listdir(GROUP_PATH) if ".txt" in f]
def list_tab_groups() -> None:
print("\n- ".join(["Saved tab groups", *get_tab_groups()]))
Delete saved tabs
Just delete the file:
def group_delete(name: str) -> None:
os.remove(get_group_filepath(name))
Close tabs
This method will close tabs if their URLs match a given regex. Thus, you could type something like “stackoverflow|google” to close all tabs with either stackoverflow or google in their URLs.
# will close the tabs with the given ids
def close_ids(ids: List[str]) -> None:
ids = ",".join(ids)
run_apple_script(f"""
set ids to {{{ids}}}
tell application "Google Chrome"
repeat with w in windows
repeat with t in (get tabs of w)
if (get id of t) is in the ids then
close t
end if
end repeat
end repeat
end tell
""")
# will close all tabs that match the given regex
def close_tabs(regex: str) -> None:
tabs = get_tabs()
remove = []
for t in tabs:
if re.search(re.compile(regex), t["url"]):
remove.append(t["id"])
close_ids(remove)
The close_tabs
method returns all open tabs, checks if the regex matches the URLs, and if so adds their ids to a list. Then that list is given to the close_ids
method that closes those tabs.
Open a message
This method will display a message in a new tab:
# open a message in a new tab
def open_message(message: str) -> None:
# format the message to be displayed
html = '<h1 style="font-size: 50px; position: absolute; top: 30%; left: 50%; transform: translate(-50%, -50%); text-align: center;">'
html += message
html += "</h1>"
# escape " and '
html = html.replace('"', '\\"').replace("'", "\'\\'\'")
# show it with AppleScript
run_apple_script(f"""
set theHTML to "{html}"
set theBase64 to do shell script "echo " & quoted form of theHTML & " | base64"
set theURL to "data:text/html;base64," & theBase64
tell application "Google Chrome"
activate
if (count of windows) = 0 then
make new window
open location theURL
else
tell front window
make new tab
set URL of active tab to theURL
end tell
end if
end tell
""")
The message is enclosed in an h1-tag with some styling, escaped and thereafter displayed by setting a new tab to the base64 version of it.
Structure of the program
Now, I will describe how the program is structured so that new functions can easily be added and integrated with the CLI. First, I define a dataclass called action. Each instantiation of an action will define a feature in the program, thus, if you would like to extend the program, an action object is what you will have to add.
# class to represent an action to be taken
@dataclass(frozen=True)
class Action:
name: str
arguments: List[Arg]
# ... = taking any number of arguments
method: Callable[..., None]
description: str
In this program, it’s just used to succinctly define a class, not much else. The class has:
- a name, which will be used to reference it in the command line
- a number of arguments of type
Arg
(shown next), that defines what arguments it needs and how they will be defined on the command line - the method to call
- a description to display when using
--help
The Arg
is defined below:
# class to represent an argument for an action
@dataclass(frozen=True)
class Arg:
name: str
flag: bool = False
It has two attributes:
- name: corresponds to a parameter in the method of the action it’s connected to
- flag: whether it is an optional flag or positional argument
If you are not familiar with the terms “flag” or “option”, I recommend you check out this introductory article about building CLIs in Python. These two attributes do not define every possible way to define command line arguments by any means, but I’ve limited the program this way to simplify. I will show you later how they will be used to construct the CLI.
The actions are then defined in a list and will be used to formalize the functionality I described in the previous part of the article:
actions = [
Action(
name="save_tabs",
arguments=[Arg("name"), Arg("replace", flag=True)],
method=save_tabs,
description="Save tabs to supplied name. If replace is set to true, it will replace the existing group, otherwise append."
),
Action(
name="list_saved_tabs",
arguments=[],
method=list_tab_groups,
description="List all saved tab groups"
),
Action(
name="open_tabs",
arguments=[Arg("name")],
method=open_tabs,
description="Select a tab group to open"
),
Action(
name="clear",
arguments=[],
method=lambda: close_tabs(""),
description="Clear/deletes all tabs"
),
Action(
name="open_message",
arguments=[Arg("message")],
method=open_message,
description="Open message"
),
Action(
name="close_tabs",
arguments=[Arg("regex")],
method=close_tabs,
description="Close tabs that match the supplied regex"
),
Action(
name="delete_saved_tabs",
arguments=[Arg("name")],
method=group_delete,
description="Delete one of the tab groups"
)
]
Now, the actions can be used to construct the CLI. Each action will have a subparser in the argparse
module that enables different arguments for different actions:
if __name__ == "__main__":
parser = ArgumentParser(
prog="Browser controller",
description="Perform actions on the browser"
)
# depending on what action is taken, different arguments will
# be available
subparsers = parser.add_subparsers(title="The action to take", required=True)
# add all actions and their respective arguments to argparse
for action in actions:
# add subcommand of name `action.name`
subparser = subparsers.add_parser(action.name, description=action.description)
for argument in action.arguments:
# if flag, add -- to the argument name
prefix = "--" if argument.flag else ""
subparser.add_argument(
prefix + argument.name,
# store_true means true if specified, false otherwise
# store means return argument value as string
action="store_true" if argument.flag else "store"
)
# set the method value to be the action method
subparser.set_defaults(method=action.method)
# turn args into dict
args = vars(parser.parse_args())
# separate method from arguments
# and then call method with arguments
method = args.pop("method")
method(**args)
Let’s unwrap what happened here, step by step. First an ArgumentParser
from argparse
is instantiated with a title and description. Thereafter, for each action, a subparser is added using:
subparsers.add_parser(action.name, ...)
Each such call will create a subcommand that can be triggered with:
python3 main.py <action.name> ...
Subcommands are useful because they enable different arguments for different situations, or in this case, different actions. The arguments for each subcommand and action are defined using action.arguments
in a loop:
for argument in action.arguments:
# if flag, add -- to the argument name
prefix = "--" if argument.flag else ""
subparser.add_argument(
prefix + argument.name,
# store_true means true if specified, false otherwise
# store means return argument value as string
action="store_true" if argument.flag else "store"
)
For the positional arguments, we have that action="store"
, meaning it will just return the provided value as a string. For the flag, a double dash --
prefix is added to make it optional, and action="store_true"
, meaning presence = True, absence = False, that is:
# myflag is specified => myflag=True
> somecommand --myflag
# no myflag is specified => myflag=False
> somecommand
After the loop, the method is added as a default so that it can be accessed together with the parsed arguments:
# set the method value to be the action method
subparser.set_defaults(method=action.method)
Finally, the method is called with the supplied arguments:
# turn args into dict
args = vars(parser.parse_args())
# separate method from arguments
# and then call method with arguments
method = args.pop("method")
method(**args)
If you call parser.parse_args()
you get a namespace with the arguments, and using vars(...)
turns it into a dictionary. Thereafter the method’s arguments and the method itself are separated using .pop("method")
that returns and removes the method from the dict. Then the rest of the values in args
(i.e. the arguments) can be supplied to the method as kwargs.
And that’s the structure of the program!
If you enjoyed this article:
- 🙏 Follow on twitter, if you would like to read my upcoming articles, new ones every week!
- 📚 If you are looking for more content, check out my reading lists in AI, Python or Data Science
Thanks for reading and have a great day.
Orginally Published By Jacob Ferus