Skip to content

Commit

Permalink
✨ Get browser by version with wildcards (#15)
Browse files Browse the repository at this point in the history
* ✨ Get browser by version with wildcards

* Use typing_extensions on 3.7

* Fix return type of _get_file_version()

* Remove unused import

* Ignore except from coverage
  • Loading branch information
roniemartinez committed Apr 26, 2022
1 parent 04fa25e commit 943a4a0
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 34 deletions.
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@

Python library for detecting and launching browsers

> I recently wrote a snippet for detecting installed browsers in an OSX machine in
> https://github.com/mitmproxy/mitmproxy/issues/5247#issuecomment-1095337723 based on https://github.com/httptoolkit/browser-launcher
> and I thought this could be useful to other devs since I cannot seem to find an equivalent library in Python

## Why?

I recently wrote a snippet for detecting installed browsers in an OSX machine in
https://github.com/mitmproxy/mitmproxy/issues/5247#issuecomment-1095337723 based on https://github.com/httptoolkit/browser-launcher
and I thought this could be useful to other devs since I cannot find an equivalent library of `httptoolkit/browser-launcher` in Python
and the known `webbrowser` standard library does not support arguments.

## Installation

Expand All @@ -53,7 +57,7 @@ import browsers
import browsers

print(list(browsers.browsers()))
# [('chrome', {'path': '/Applications/Google Chrome.app', 'display_name': 'Google Chrome', 'version': '100.0.4896.127'}), ('firefox', {'path': '/Applications/Firefox.app', 'display_name': 'Firefox', 'version': '99.0.1'}), ('safari', {'path': '/Applications/Safari.app', 'display_name': 'Safari', 'version': '15.4'}), ('opera', {'path': '/Applications/Opera.app', 'display_name': 'Opera', 'version': '85.0.4341.60'}), ('msedge', {'path': '/Applications/Microsoft Edge.app', 'display_name': 'Microsoft Edge', 'version': '100.1185.22041544'})]
# [{'browser_type': 'chrome', 'path': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'display_name': 'Google Chrome', 'version': '100.0.4896.127'}, {'browser_type': 'firefox', 'path': '/Applications/Firefox.app/Contents/MacOS/firefox', 'display_name': 'Firefox', 'version': '99.0.1'}, {'browser_type': 'safari', 'path': '/Applications/Safari.app/Contents/MacOS/Safari', 'display_name': 'Safari', 'version': '15.4'}, {'browser_type': 'opera', 'path': '/Applications/Opera.app/Contents/MacOS/Opera', 'display_name': 'Opera', 'version': '85.0.4341.60'}, {'browser_type': 'msedge', 'path': '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 'display_name': 'Microsoft Edge', 'version': '100.1185.22042050'}]
```

### Get browser information
Expand All @@ -62,7 +66,7 @@ print(list(browsers.browsers()))
import browsers

print(browsers.get("chrome"))
# {'path': '/Applications/Google Chrome.app', 'display_name': 'Google Chrome', 'version': '100.0.4896.88'}
# {'browser_type': 'chrome', 'path': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'display_name': 'Google Chrome', 'version': '100.0.4896.127'}
```

### Launch browser
Expand All @@ -89,13 +93,31 @@ import browsers
browsers.launch("chrome", args=["--incognito"])
```

### Specifying version

The `get()` and `launch()` functions support specifying version in case multiple versions are installed.
Wildcard pattern is also supported.

```python
import browsers

print(browsers.get("chrome", version="100.0.4896.127")) # complete version
# {'browser_type': 'chrome', 'path': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'display_name': 'Google Chrome', 'version': '100.0.4896.127'}

print(browsers.get("chrome", version="100.*")) # wildcard
# {'browser_type': 'chrome', 'path': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'display_name': 'Google Chrome', 'version': '100.0.4896.127'}

browsers.launch("chrome", version="100.0.4896.127") # complete version
browsers.launch("chrome", version="100.*") # wildcard
```

## TODO:

- [x] Detect browser on OSX
- [x] Detect browser on Linux
- [X] Detect browser on Windows
- [x] Launch browser with arguments
- [ ] Get browser by version (support wildcards)
- [x] Get browser by version (support wildcards)

## References

Expand Down
22 changes: 14 additions & 8 deletions browsers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import fnmatch
import logging
import shlex
import subprocess
import sys
from typing import Dict, Iterator, Optional, Sequence, Tuple
from typing import Iterator, Optional, Sequence

from . import linux, osx, windows
from .common import Browser

__all__ = ["browsers", "get", "launch"]

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)


def browsers() -> Iterator[Tuple[str, Dict]]:
def browsers() -> Iterator[Browser]:
"""
Iterates over installed browsers.
Expand All @@ -32,32 +34,36 @@ def browsers() -> Iterator[Tuple[str, Dict]]:
)


def get(browser: str) -> Optional[Dict]:
def get(browser: str, version: str = "*") -> Optional[Browser]:
"""
Returns the information for the provided browser key.
:param browser: Any of "chrome", "chrome-canary", "firefox", "firefox-developer", "firefox-nightly", "opera",
"opera-beta", "opera-developer", "msedge", "msedge-beta", "msedge-dev", "msedge-canary", "msie",
"brave", "brave-beta", "brave-dev", "brave-nightly", and "safari".
:param version: Version string (supports wildcard, e.g. 100.*)
:return: Dictionary containing "path", "display_name" and "version".
"""
for key, value in browsers():
if key == browser:
return value
for b in browsers():
if b["browser_type"] == browser and fnmatch.fnmatch(b["version"], version):
return b
return None


def launch(browser: str, url: str = None, args: Optional[Sequence[str]] = None) -> Optional[subprocess.Popen]:
def launch(
browser: str, version: str = "*", url: str = None, args: Optional[Sequence[str]] = None
) -> Optional[subprocess.Popen]:
"""
Launches a web browser.
:param browser: Browser key.
:param version: Version string (supports wildcard, e.g. 100.*)
:param url: URL.
:param args: Arguments to be passed to the browser.
"""
if args is None:
args = []
b = get(browser)
b = get(browser, version)
if not b:
logger.info("Cannot find browser '%s'", browser)
return None
Expand Down
13 changes: 13 additions & 0 deletions browsers/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import sys

if sys.version_info >= (3, 8):
from typing import TypedDict
else:
from typing_extensions import TypedDict


class Browser(TypedDict):
browser_type: str
path: str
display_name: str
version: str
20 changes: 14 additions & 6 deletions browsers/linux.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import os
import re
import subprocess
import sys
from typing import Dict, Iterator, Tuple
from typing import Iterator

from .common import Browser

LINUX_DESKTOP_ENTRY_LIST = (
# desktop entry name can be "firefox.desktop" or "firefox_firefox.desktop"
Expand All @@ -24,8 +27,10 @@
"/var/lib/snapd/desktop/applications",
)

VERSION_PATTERN = re.compile(r"\b(\S+\.\S+)\b") # simple pattern assuming all version strings have a dot on them


def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
def browsers() -> Iterator[Browser]: # type: ignore[return]
if sys.platform == "linux":
from xdg.DesktopEntry import DesktopEntry

Expand All @@ -39,7 +44,10 @@ def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
executable_path = entry.getExec()
if executable_path.lower().endswith(" %u"):
executable_path = executable_path[:-3].strip()
# FIXME: --version includes the name for most browsers
version = subprocess.getoutput(f"{executable_path} --version")
info = dict(path=executable_path, display_name=entry.getName(), version=version)
yield browser, info
version = subprocess.getoutput(f"{executable_path} --version 2>&1").strip()
match = VERSION_PATTERN.search(version)
if match:
version = match[0]
yield Browser(
browser_type=browser, path=executable_path, display_name=entry.getName(), version=version
)
13 changes: 9 additions & 4 deletions browsers/osx.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import plistlib
import subprocess
import sys
from typing import Dict, Iterator, Tuple
from typing import Iterator

from .common import Browser

OSX_BROWSER_BUNDLE_LIST = (
# browser name, bundle ID, version string
Expand All @@ -27,7 +29,7 @@
)


def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
def browsers() -> Iterator[Browser]: # type: ignore[return]
if sys.platform == "darwin":
for browser, bundle_id, version_string in OSX_BROWSER_BUNDLE_LIST:
paths = subprocess.getoutput(f'mdfind "kMDItemCFBundleIdentifier == {bundle_id}"').splitlines()
Expand All @@ -38,6 +40,9 @@ def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
executable = os.path.join(path, "Contents/MacOS", executable_name)
display_name = plist.get("CFBundleDisplayName") or plist.get("CFBundleName", browser)
version = plist[version_string]
yield browser, dict(
path=executable if browser != "safari" else path, display_name=display_name, version=version
yield Browser(
browser_type=browser,
path=executable if browser != "safari" else path,
display_name=display_name,
version=version,
)
20 changes: 13 additions & 7 deletions browsers/windows.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import os
import sys
from typing import Dict, Iterator, Optional, Tuple
from typing import Iterator

from .common import Browser

WINDOWS_REGISTRY_BROWSER_NAMES = {
"Google Chrome": "chrome",
Expand All @@ -22,7 +24,7 @@
}


def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
def browsers() -> Iterator[Browser]: # type: ignore[return]
if sys.platform == "win32":
import winreg

Expand All @@ -31,7 +33,7 @@ def browsers() -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
yield from _win32_browsers_from_registry(winreg.HKEY_LOCAL_MACHINE, winreg.KEY_READ | winreg.KEY_WOW64_32KEY)


def _win32_browsers_from_registry(tree: int, access: int) -> Iterator[Tuple[str, Dict]]: # type: ignore[return]
def _win32_browsers_from_registry(tree: int, access: int) -> Iterator[Browser]: # type: ignore[return]
if sys.platform == "win32":
import winreg

Expand All @@ -54,15 +56,19 @@ def _win32_browsers_from_registry(tree: int, access: int) -> Iterator[Tuple[str,
cmd = winreg.QueryValue(hkey, rf"{subkey}\shell\open\command")
cmd = cmd.strip('"')
os.stat(cmd)
except (OSError, AttributeError, TypeError, ValueError):
except (OSError, AttributeError, TypeError, ValueError): # pragma: no cover
continue
info = dict(path=cmd, display_name=display_name, version=_get_file_version(cmd))
yield WINDOWS_REGISTRY_BROWSER_NAMES.get(display_name, "unknown"), info
yield Browser(
browser_type=WINDOWS_REGISTRY_BROWSER_NAMES.get(display_name, "unknown"),
path=cmd,
display_name=display_name,
version=_get_file_version(cmd),
)
except FileNotFoundError:
pass


def _get_file_version(path: str) -> Optional[str]:
def _get_file_version(path: str) -> str:
import win32api

info = win32api.GetFileVersionInfo(path, "\\")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pybrowsers"
version = "0.3.1"
version = "0.4.0"
repository = "https://github.com/roniemartinez/browsers"
description = "Python library for detecting and launching browsers"
authors = ["Ronie Martinez <ronmarti18@gmail.com>"]
Expand Down
14 changes: 12 additions & 2 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
pytest.param("msie", id="msie", marks=pytest.mark.skipif(sys.platform != "win32", reason="windows-only")),
),
)
def test_get_available_browsers(browser: str) -> None:
available_browsers = dict(browsers.browsers())
def test_browsers(browser: str) -> None:
available_browsers = [b["browser_type"] for b in browsers.browsers()]
assert browser in available_browsers


Expand All @@ -34,6 +34,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"chrome",
{
"browser_type": "chrome",
"display_name": "Google Chrome",
"path": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"version": ANY,
Expand All @@ -44,6 +45,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"firefox",
{
"browser_type": "firefox",
"display_name": "Firefox",
"path": "/Applications/Firefox.app/Contents/MacOS/firefox",
"version": ANY,
Expand All @@ -54,6 +56,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"safari",
{
"browser_type": "safari",
"display_name": "Safari",
"path": "/Applications/Safari.app",
"version": ANY,
Expand All @@ -64,6 +67,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"msedge",
{
"browser_type": "msedge",
"display_name": "Microsoft Edge",
"path": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"version": ANY,
Expand All @@ -74,6 +78,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"chrome",
{
"browser_type": "chrome",
"display_name": "Google Chrome",
"path": "/usr/bin/google-chrome-stable",
"version": ANY,
Expand All @@ -84,6 +89,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"firefox",
{
"browser_type": "firefox",
"display_name": "Firefox Web Browser",
"path": "firefox",
"version": ANY,
Expand All @@ -94,6 +100,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"chrome",
{
"browser_type": "chrome",
"display_name": "Google Chrome",
"path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
"version": ANY,
Expand All @@ -104,6 +111,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"firefox",
{
"browser_type": "firefox",
"display_name": "Mozilla Firefox",
"path": r"C:\Program Files\Mozilla Firefox\firefox.exe",
"version": ANY,
Expand All @@ -114,6 +122,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"msedge",
{
"browser_type": "msedge",
"display_name": "Microsoft Edge",
"path": r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
"version": ANY,
Expand All @@ -124,6 +133,7 @@ def test_get_available_browsers(browser: str) -> None:
pytest.param(
"msie",
{
"browser_type": "msie",
"display_name": "Internet Explorer",
"path": r"C:\Program Files\Internet Explorer\iexplore.exe",
"version": ANY,
Expand Down

0 comments on commit 943a4a0

Please sign in to comment.