diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000000..75aaa2f091 --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,51 @@ +import subprocess +from unittest.mock import patch + +import pytest +import typer + +url = "http://example.com" + + +@pytest.mark.parametrize( + "system, command", + [ + ("Darwin", "open"), + ("Linux", "xdg-open"), + ("FreeBSD", "xdg-open"), + ], +) +def test_launch_url_unix(system: str, command: str): + with patch("platform.system", return_value=system), patch( + "shutil.which", return_value=True + ), patch("subprocess.Popen") as mock_popen: + typer.launch(url) + + mock_popen.assert_called_once_with( + [command, url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ) + + +def test_launch_url_windows(): + with patch("platform.system", return_value="Windows"), patch( + "webbrowser.open" + ) as mock_webbrowser_open: + typer.launch(url) + + mock_webbrowser_open.assert_called_once_with(url) + + +def test_launch_url_no_xdg_open(): + with patch("platform.system", return_value="Linux"), patch( + "shutil.which", return_value=None + ), patch("webbrowser.open") as mock_webbrowser_open: + typer.launch(url) + + mock_webbrowser_open.assert_called_once_with(url) + + +def test_calls_original_launch_when_not_passing_urls(): + with patch("typer.main.click.launch", return_value=0) as launch_mock: + typer.launch("not a url") + + launch_mock.assert_called_once_with("not a url") diff --git a/typer/__init__.py b/typer/__init__.py index b422dd00d3..3d84062a76 100644 --- a/typer/__init__.py +++ b/typer/__init__.py @@ -12,7 +12,6 @@ from click.termui import echo_via_pager as echo_via_pager from click.termui import edit as edit from click.termui import getchar as getchar -from click.termui import launch as launch from click.termui import pause as pause from click.termui import progressbar as progressbar from click.termui import prompt as prompt @@ -28,6 +27,7 @@ from . import colors as colors from .main import Typer as Typer +from .main import launch as launch from .main import run as run from .models import CallbackParam as CallbackParam from .models import Context as Context diff --git a/typer/main.py b/typer/main.py index a621bda6ad..410dfb9257 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1,5 +1,8 @@ import inspect import os +import platform +import shutil +import subprocess import sys import traceback from datetime import datetime @@ -1079,3 +1082,68 @@ def run(function: Callable[..., Any]) -> None: app = Typer(add_completion=False) app.command()(function) app() + + +def _is_macos() -> bool: + return platform.system() == "Darwin" + + +def _is_linux_or_bsd() -> bool: + if platform.system() == "Linux": + return True + + return "BSD" in platform.system() + + +def launch(url: str, wait: bool = False, locate: bool = False) -> int: + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + + This function handles url in different operating systems separately: + - On macOS (Darwin), it uses the 'open' command. + - On Linux and BSD, it uses 'xdg-open' if available. + - On Windows (and other OSes), it uses the standard webbrowser module. + + The function avoids, when possible, using the webbrowser module on Linux and macOS + to prevent spammy terminal messages from some browsers (e.g., Chrome). + + Examples:: + + typer.launch("https://typer.tiangolo.com/") + typer.launch("/my/downloaded/file", locate=True) + + :param url: URL or filename of the thing to launch. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. + :param locate: if this is set to `True` then instead of launching the + application associated with the URL it will attempt to + launch a file manager with the file located. This + might have weird effects if the URL does not point to + the filesystem. + """ + + if url.startswith("http://") or url.startswith("https://"): + if _is_macos(): + return subprocess.Popen( + ["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ).wait() + + has_xdg_open = _is_linux_or_bsd() and shutil.which("xdg-open") is not None + + if has_xdg_open: + return subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ).wait() + + import webbrowser + + webbrowser.open(url) + + return 0 + + else: + return click.launch(url)