qutebrowser profile manager

Compare changes

Choose any two refs to compare.

-19
.build.yml
··· 1 - image: nixos/unstable 2 - repositories: 3 - nixpkgs: https://nixos.org/channels/nixos-unstable 4 - sources: 5 - - https://git.sr.ht/~pvsr/qbpm 6 - tasks: 7 - - deps: | 8 - cd qbpm 9 - nix-shell --quiet --run exit 10 - - format: | 11 - cd qbpm 12 - nix-shell --run 'black --diff --check qbpm tests' 13 - - mypy: | 14 - cd qbpm 15 - nix-shell --run 'python setup.py --version' 16 - nix-shell --run 'mypy qbpm tests' 17 - - pytest: | 18 - cd qbpm 19 - nix-shell --run pytest
+27
.builds/arch.yml
··· 1 + image: archlinux 2 + sources: 3 + - https://git.sr.ht/~pvsr/qbpm 4 + - https://aur.archlinux.org/python-xdg-base-dirs.git 5 + - https://aur.archlinux.org/python-dacite.git 6 + packages: 7 + - ruff 8 + - mypy 9 + - python-pytest 10 + tasks: 11 + - format: ruff format --check qbpm 12 + - lint: ruff check qbpm 13 + - deps: | 14 + makepkg -si --noconfirm --dir python-xdg-base-dirs 15 + makepkg -si --noconfirm --dir python-dacite 16 + mkdir build 17 + cp qbpm/contrib/PKGBUILD build 18 + sed -i 's|^source.*|source=("git+file:///home/build/qbpm")|' build/PKGBUILD 19 + makepkg -s --noconfirm --dir build 20 + - types: mypy qbpm 21 + - tests: pytest qbpm/tests 22 + - install: makepkg -i --noconfirm --noextract --dir build 23 + - run: | 24 + mkdir -p ~/.config/qutebrowser 25 + touch ~/.config/qutebrowser/config.py 26 + qbpm new profile 27 + qbpm list | grep profile
+13
.builds/aur.yml
··· 1 + image: archlinux 2 + sources: 3 + - https://git.sr.ht/~pvsr/qbpm 4 + - https://aur.archlinux.org/qbpm-git.git 5 + tasks: 6 + - install: | 7 + sed -i 's|^source.*|source=("git+file:///home/build/qbpm")|' qbpm-git/PKGBUILD 8 + yay -Bi --noconfirm qbpm-git 9 + - run: | 10 + mkdir -p ~/.config/qutebrowser 11 + touch ~/.config/qutebrowser/config.py 12 + qbpm new profile 13 + qbpm list | grep profile
+13
.builds/nix.yml
··· 1 + image: nixos/unstable 2 + sources: 3 + - https://git.sr.ht/~pvsr/qbpm 4 + environment: 5 + NIX_CONFIG: "experimental-features = nix-command flakes" 6 + tasks: 7 + - build: nix build ./qbpm 8 + - install: nix profile install ./qbpm 9 + - run: | 10 + mkdir -p ~/.config/qutebrowser 11 + touch ~/.config/qutebrowser/config.py 12 + qbpm new profile 13 + qbpm list | grep profile
+44
.github/workflows/publish.yml
··· 1 + name: Build and push release 2 + on: push 3 + 4 + jobs: 5 + build: 6 + name: Build qbpm 7 + runs-on: ubuntu-latest 8 + steps: 9 + - uses: actions/checkout@v4 10 + with: 11 + persist-credentials: false 12 + - name: Set up Python 13 + uses: actions/setup-python@v5 14 + with: 15 + python-version: "3.13" 16 + - name: Install pypa/build 17 + run: python3 -m pip install build --user 18 + - name: Build wheel and source tarball 19 + run: python3 -m build 20 + - name: Upload package distribution 21 + uses: actions/upload-artifact@v4 22 + with: 23 + name: package-dist 24 + path: dist/ 25 + 26 + publish: 27 + name: Publish qbpm release to PyPI 28 + runs-on: ubuntu-latest 29 + if: startsWith(github.ref, 'refs/tags') 30 + needs: 31 + - build 32 + environment: 33 + name: pypi 34 + url: https://pypi.org/p/qbpm 35 + permissions: 36 + id-token: write 37 + steps: 38 + - name: Download package distribution 39 + uses: actions/download-artifact@v4 40 + with: 41 + name: package-dist 42 + path: dist/ 43 + - name: Publish package to PyPI 44 + uses: pypa/gh-action-pypi-publish@release/v1
+5
.gitignore
··· 11 11 .tox/ 12 12 qbpm/version.py 13 13 qbpm.1 14 + .envrc 15 + .direnv/ 16 + .pre-commit-config.yaml 17 + profiles/ 18 + .coverage
+69
CHANGELOG.md
··· 1 + # next 2 + - add `--help` flag to `qbpm config` 3 + 4 + # ~2.1~ 2.2 5 + - `config.toml` supports `application_name` for generated XDG desktop files 6 + - defaults to `{profile_name} (qutebrowser profile)`, you may want just `{profile_name}` 7 + - `qbpm desktop` can be used to replace existing desktop files 8 + - bumped to 2.2 because I pushed a 2.1 tag prematurely 9 + 10 + # 2.0 11 + ## config 12 + qbpm now reads configuration options from `$XDG_CONFIG_HOME/qbpm/config.toml`! 13 + - to install the default config file: 14 + - run `qbpm config path` and confirm that it prints out a path 15 + - run `qbpm config default > "$(qbpm config path)"` 16 + - supported configuration options: 17 + - `config_py_template`: control the contents of `config.py` in new profiles 18 + - `symlink_autoconfig`: symlink qutebrowser's `autoconfig.yml` in new profiles 19 + - `profile_directory` and `qutebrowser_config_directory` 20 + - equivalent to `--profile-dir` and `--qutebrowser-config-dir` 21 + - `generate_desktop_file` and `desktop_file_directory` 22 + - whether to generate XDG desktop entries for new profiles and where to put them 23 + - `menu`: equivalent to `--menu` for `qbpm choose` 24 + - `menu_prompt`: prompt shown in most menus 25 + - see default config file for more detailed documentation 26 + 27 + ## other 28 + - support for symlinking `autoconfig.yml` in addition to or instead of sourcing `config.py` 29 + - `qbpm new --overwrite`: back up existing config files by moving to e.g. `config.py.bak` 30 + - `contrib/qbpm.desktop`: add `MimeType` and `Keywords`, fix incorrect formatting of `Categories` 31 + - allow help text to be slightly wider to avoid awkward line breaks 32 + - macOS: fix detection of qutebrowser binary in `/Applications` 33 + 34 + # 1.0rc4 35 + - `choose`: support `walker`, `tofi`, and `wmenu` 36 + - better detection of invalid/nonexistent profiles 37 + 38 + # 1.0rc3 39 + - breaking: stop sourcing files from `~/.config/qutebrowser/conf.d/` 40 + - this was undocumented, nonstandard, and didn't work as well as it could 41 + - switch to `pyproject.toml` 42 + - hopefully use the right qutebrowser dirs on Windows 43 + - `choose`: add `qutebrowser` menu item for the main qutebrowser profile 44 + - added `-C` argument to support referencing qutebrowser configs other than the one in ~/.config 45 + - added `click`-generated completions for bash and zsh 46 + - added option support to fish completions 47 + - removed `--create` from `qbpm launch` 48 + - make generated `.desktop` files match qutebrowser's more closely 49 + 50 + # 1.0rc2: 51 + - `choose`: support `fzf` and `fuzzel` 52 + - use `click `for CLI parsing 53 + - `qbpm launch`'s `-n`/`--new` renamed to `-c`/`--create` 54 + - expand fish shell completions 55 + 56 + # 1.0rc1: 57 + - add a man page 58 + 59 + # 0.6 60 + - better error handling 61 + 62 + # 0.5 63 + - `choose`: support custom menu command 64 + - `choose`: support `dmenu-wl` and `wofi` 65 + 66 + # 0.4 67 + - `choose` subcommand (thanks, @mtoohey31!) 68 + - load autoconfig.yml by default 69 + - shell completions for fish
+47 -53
README.md
··· 1 1 # qutebrowser profile manager 2 2 3 - [![builds.sr.ht status](https://builds.sr.ht/~pvsr/qbpm.svg)](https://builds.sr.ht/~pvsr/qbpm?) 3 + [![builds.sr.ht status](https://builds.sr.ht/~pvsr/qbpm/commits/main.svg)](https://builds.sr.ht/~pvsr/qbpm/commits/main?) 4 + [![PyPI](http://img.shields.io/pypi/v/qbpm.svg)](https://pypi.python.org/pypi/qbpm) 4 5 5 - qutebrowser profile manager (qbpm) is a tool for creating and managing 6 - [qutebrowser](https://github.com/qutebrowser/qutebrowser) profiles. There isn't 7 - any built in concept of profiles in qutebrowser, but there is a `--basedir` flag 6 + qbpm (qutebrowser profile manager) is a tool for creating, managing, and running 7 + [qutebrowser](https://github.com/qutebrowser/qutebrowser) profiles. Profile support 8 + isn't built in to qutebrowser, at least not directly, but it does have a `--basedir` flag 8 9 which allows qutebrowser to use any directory as the location of its config and 9 10 data and effectively act as a profile. qbpm creates profiles that source your 10 11 main qutebrowser `config.py`, but have their own separate `autoconfig.yml`, bookmarks, cookies, 11 - history, and other data. It also acts as a wrapper around qutebrowser that sets 12 - up `--basedir` for you, so you can treat `qbpm launch` as an alias for 13 - `qutebrowser`, such as to open a url: `qbpm launch my-profile example.org`. 12 + history, and other data. Profiles can be run by starting qutebrowser with the 13 + appropriate `--basedir`, or more conveniently using the `qbpm launch` and `qbpm choose` commands. 14 14 15 15 qutebrowser shares session depending on the basedir, so launching the same 16 16 profile twice will result in two windows sharing a session, which means running ··· 19 19 instances of qutebrowser which can be opened and closed independently. 20 20 21 21 ## Usage 22 - Create a new profile called "python", edit its `config.py`, then launch it: 22 + To create a new profile called "python" and launch it with the python docs open: 23 23 ``` 24 24 $ qbpm new python 25 - $ qbpm edit python 26 25 $ qbpm launch python docs.python.org 27 - $ qbpm choose # run dmenu or another launcher to pick a profile 28 26 ``` 29 27 30 - `qbpm from-session` can copy the tabs of a [saved qutebrowser 31 - session](https://qutebrowser.org/doc/help/commands.html#session-save) to a new 32 - profile. If you have a window full of tabs related to planning a vacation, you 33 - could save it to a session called "vacation" using `:session-save -o vacation` 34 - in qutebrowser, then create a new profile with those tabs: 35 - ``` 36 - $ qbpm from-session vacation 37 - ``` 28 + Note that all arguments after `qbpm launch PROFILE` are passed to qutebrowser, 29 + so options can be passed too: `qbpm launch python --target window pypi.org`. 30 + 31 + If you have multiple profiles you can use `qbpm choose` to bring up a list of 32 + profiles and select one to launch. Depending on what your system has available 33 + the menu may be `dmenu`, `fuzzel`, `fzf`, an applescript dialog, or one of many 34 + other menu programs qbpm can detect. Any dmenu-compatible menu can be used with 35 + `--menu`, e.g. `qbpm choose --menu 'fuzzel --dmenu'`. As with `qbpm launch`, 36 + extra arguments are passed to qutebrowser. 38 37 39 - The default profile directory is `$XDG_DATA_HOME/qutebrowser-profiles`, where 40 - `$XDG_DATA_HOME` is usually `$HOME/.local/share`, but you can create and launch 41 - profiles from anywhere using `--profile-dir`/`-P`: 42 - ``` 43 - $ qbpm --profile-dir ~/dev/my-project new qb-profile 44 - $ cd ~/dev/my-project 45 - $ qbpm -P . launch qb-profile 46 - # or 47 - $ qutebrowser --basedir qb-profile 48 - ``` 38 + Run `qbpm --help` to see other available commands. 39 + 40 + By default when you create a new profile a `.desktop` file is created that 41 + launches the profile. This launcher does not depend on qbpm at all, so if you 42 + want you can run `qbpm new` once and keep using the profile without needing 43 + qbpm installed on your system. 49 44 50 45 ## Installation 51 - - Pip: `pip install git+https://github.com/pvsr/qbpm.git#egg=qbpm` 52 - - Arch: [qbpm-git](https://aur.archlinux.org/packages/qbpm-git) in the AUR 53 - - Nix: clone the repository and run `nix-env -if default.nix` 54 - - MacOS: For command-line only usage, the pip command above is sufficient, but 55 - if you would like to set qbpm as the default browser app, first clone this 56 - repository, then install platypus by running `brew install playtpus`, and 57 - finally install the app by running `platypus -P contrib/qbpm.platypus 58 - /Applications/qbpm.app` inside the cloned repository. You should then be 59 - able to select qbpm as your default browser under: System Preferences 60 - \> General > Default web browser. Note that there is currently [an 61 - issue](https://github.com/qutebrowser/qutebrowser/issues/3719) with 62 - qutebrowser itself that results in unnecessary `file:///*` tabs being 63 - opened. 64 - - If you're on linux, you can copy `contrib/qbpm.desktop` to `~/.local/share/applications`. 65 - That desktop entry will run `qbpm choose`, which shows an application 66 - launcher (dmenu or rofi) with your qutebrowser profiles as the options. 46 + If you use Nix, you can install or run qbpm as a [Nix flake](https://nixos.wiki/wiki/Flakes). 47 + For example, to run qbpm without installing it you can use `nix run github:pvsr/qbpm -- new my-profile`. 67 48 68 - ## Future ideas that may or may not happen 69 - - Release through github 70 - - More shared or copied config and data 71 - - Use any profile as a base for new profiles (currently only the main config in 72 - `$XDG_CONFIG_HOME` is supported) 73 - - Source `autoconfig.yml` instead of `config.py` 74 - - Bundled config file optimized for single-site browsing 75 - - `qbpm.conf` to configure the features above 76 - - Someday: qutebrowser plugin 49 + On Arch and derivatives, you can install the AUR package: [qbpm-git](https://aur.archlinux.org/packages/qbpm-git). 50 + 51 + Otherwise you can install directly from PyPI using [uv](https://docs.astral.sh/uv/guides/tools/), 52 + pip, or your preferred client. With uv it's `uv tool run qbpm` to run qbpm 53 + without installing and `uv tool install qbpm` to install to `~/.local/bin`. 54 + The downside of going through PyPI is that the [man page](https://github.com/pvsr/qbpm/blob/main/qbpm.1.scd) 55 + and shell completions will not be installed automatically. 56 + 57 + On Linux you can copy [`contrib/qbpm.desktop`](https://raw.githubusercontent.com/pvsr/qbpm/main/contrib/qbpm.desktop) 58 + to `~/.local/share/applications` to create a qbpm desktop application that runs 59 + `qbpm choose`. 60 + 61 + ### MacOS 62 + 63 + Nix and uv will install qbpm as a command-line application, but if you want a 64 + native Mac application you can download [`contrib/qbpm.platypus`](https://raw.githubusercontent.com/pvsr/qbpm/main/contrib/qbpm.platypus), 65 + install [platypus](https://sveinbjorn.org/platypus), and create a qbpm app with 66 + `platypus -P qbpm.platypus /Applications/qbpm.app`. That will also make qbpm 67 + available as a default browser in `System Preferences > General > Default web browser`. 68 + 69 + Note that there is currently [a qutebrowser bug](https://github.com/qutebrowser/qutebrowser/issues/3719) 70 + that results in unnecessary `file:///*` tabs being opened.
+28 -12
completions/qbpm.fish
··· 1 1 function __fish_qbpm 2 - set -l saved_args $argv 3 - set -l global_args 4 - set -l cmd (commandline -opc) 5 - set -e cmd[1] 6 - argparse -si P/profile-dir= -- $cmd 2>/dev/null 7 - set -q _flag_P 8 - and set global_args "-P $_flag_P" 9 - eval qbpm $global_args $saved_args 2 + set -l saved_args $argv 3 + set -l global_args 4 + set -l cmd (commandline -opc) 5 + set -e cmd[1] 6 + argparse -si P/profile-dir= -- $cmd 2>/dev/null 7 + set -q _flag_P 8 + and set global_args "-P $_flag_P" 9 + eval qbpm $global_args $saved_args 10 10 end 11 11 12 - set -l commands new from-session desktop launch run list edit 12 + set -l commands new from-session desktop launch list edit choose 13 + set -l data_home (set -q XDG_DATA_HOME; and echo $XDG_DATA_HOME; or echo ~/.local/share) 13 14 14 15 complete -c qbpm -f 15 - complete -c qbpm -n "not __fish_seen_subcommand_from $commands" -a "launch new from-session edit list" 16 - complete -c qbpm -n "__fish_seen_subcommand_from launch edit" -a "(__fish_qbpm list)" 17 - set -l data_home (set -q XDG_DATA_HOME; and echo $XDG_DATA_HOME; or echo ~/.local/share) 16 + complete -c qbpm -s h -l help 17 + complete -c qbpm -s l -l log-level -a "debug info error" 18 + complete -c qbpm -s C -l config-dir -r 19 + complete -c qbpm -s P -l profile-dir -r 20 + 21 + complete -c qbpm -n "not __fish_seen_subcommand_from $commands" -a "$commands" 22 + 23 + complete -c qbpm -n "__fish_seen_subcommand_from new from_session" -s l -l launch 24 + complete -c qbpm -n "__fish_seen_subcommand_from new from_session" -l desktop-file 25 + complete -c qbpm -n "__fish_seen_subcommand_from new from_session" -l no-desktop-file 26 + complete -c qbpm -n "__fish_seen_subcommand_from new from_session" -l overwrite 27 + complete -c qbpm -n "__fish_seen_subcommand_from new from_session launch choose" -s f -l foreground 28 + 29 + complete -c qbpm -n "__fish_seen_subcommand_from launch" -s c -l create 30 + complete -c qbpm -n "__fish_seen_subcommand_from choose" -s m -l menu -r 31 + complete -c qbpm -n "__fish_seen_subcommand_from launch choose" -w qutebrowser 32 + 33 + complete -c qbpm -n "__fish_seen_subcommand_from launch edit desktop" -a "(__fish_qbpm list)" 18 34 complete -c qbpm -n "__fish_seen_subcommand_from from-session" -a "(ls $data_home/qutebrowser/sessions | xargs basename -a -s .yml)"
+20 -6
contrib/PKGBUILD
··· 1 1 # Maintainer: Peter Rice <{first name}@peterrice.xyz> 2 2 3 3 pkgname=qbpm-git 4 - pkgver=0.3.r16.g7899e67 4 + pkgver=2.0.r5 5 5 pkgrel=1 6 6 pkgdesc="A profile manager for qutebrowser" 7 7 url="https://github.com/pvsr/qbpm" 8 - license=('GPL') 8 + license=('GPL-3.0-or-later') 9 9 sha512sums=('SKIP') 10 10 arch=('any') 11 - depends=('python' 'python-pyxdg') 12 - makedepends=('git' 'python-setuptools' 'python-setuptools-scm' 'scdoc') 11 + depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-dacite') 12 + makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc') 13 13 provides=('qbpm') 14 - source=("git://github.com/pvsr/qbpm") 14 + source=("git+https://github.com/pvsr/qbpm") 15 15 16 16 pkgver() { 17 17 cd qbpm 18 18 git describe --long --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g' 19 19 } 20 20 21 + prepare() { 22 + git -C "${srcdir}/qbpm" clean -dfx 23 + } 24 + 25 + build() { 26 + cd qbpm 27 + python -m build --wheel --no-isolation 28 + } 29 + 21 30 package() { 22 31 cd qbpm 23 32 install -D -m644 completions/qbpm.fish ${pkgdir}/usr/share/fish/vendor_completions.d/qbpm.fish ··· 26 35 scdoc < qbpm.1.scd > qbpm.1 27 36 install -D -m644 qbpm.1 ${pkgdir}/usr/share/man/man1/qbpm.1 28 37 29 - python setup.py install --root="$pkgdir" --optimize=1 38 + python -m installer --destdir="$pkgdir" dist/*.whl 39 + 40 + install -d "$pkgdir/usr/share/"{bash-completion/completions,zsh/site-functions} 41 + local site_packages=$(python -c "import site; print(site.getsitepackages()[0])") 42 + PYTHONPATH=${pkgdir}/${site_packages} _QBPM_COMPLETE=bash_source ${pkgdir}/usr/bin/qbpm > ${pkgdir}/usr/share/bash-completion/completions/qbpm 43 + PYTHONPATH=${pkgdir}/${site_packages} _QBPM_COMPLETE=zsh_source ${pkgdir}/usr/bin/qbpm > ${pkgdir}/usr/share/zsh/site-functions/_qbpm 30 44 }
+5 -3
contrib/qbpm.desktop
··· 1 1 [Desktop Entry] 2 - Type=Application 3 2 Name=qbpm 4 3 Icon=qutebrowser 5 - Exec=qbpm choose %u 6 - Categories=['Network'] 4 + Type=Application 5 + Categories=Network;WebBrowser; 6 + Exec=qbpm choose --untrusted-args %u 7 7 Terminal=False 8 8 StartupNotify=True 9 + MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/webp;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute; 10 + Keywords=Browser
-23
default.nix
··· 1 - { pkgs ? import <nixpkgs> { } 2 - , python ? "python3" 3 - , pythonPackages ? builtins.getAttr (python + "Packages") pkgs 4 - }: 5 - 6 - with pythonPackages; 7 - buildPythonPackage rec { 8 - pname = "qbpm"; 9 - version = "0.6"; 10 - src = ./.; 11 - doCheck = true; 12 - SETUPTOOLS_SCM_PRETEND_VERSION = version; 13 - nativeBuildInputs = [ pkgs.scdoc setuptools-scm ]; 14 - propagatedBuildInputs = [ pyxdg ]; 15 - checkInputs = [ pytest ]; 16 - postInstall = '' 17 - mkdir -p $out/share/fish/vendor_completions.d 18 - cp completions/qbpm.fish $out/share/fish/vendor_completions.d/ 19 - 20 - mkdir -p $out/share/man/man1 21 - scdoc < qbpm.1.scd > $out/share/man/man1/qbpm.1 22 - ''; 23 - }
+48
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1752950548, 6 + "narHash": "sha256-NS6BLD0lxOrnCiEOcvQCDVPXafX1/ek1dfJHX1nUIzc=", 7 + "owner": "nixos", 8 + "repo": "nixpkgs", 9 + "rev": "c87b95e25065c028d31a94f06a62927d18763fdf", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "nixos", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "pyproject-nix": { 20 + "inputs": { 21 + "nixpkgs": [ 22 + "nixpkgs" 23 + ] 24 + }, 25 + "locked": { 26 + "lastModified": 1753063596, 27 + "narHash": "sha256-el1vFxDk6DR2hKGYnMfQHR7+K4aMiJDKQRMP3gdh+ZI=", 28 + "owner": "nix-community", 29 + "repo": "pyproject.nix", 30 + "rev": "cac90713492f23be5f1072bae88406890b9c68f6", 31 + "type": "github" 32 + }, 33 + "original": { 34 + "owner": "nix-community", 35 + "repo": "pyproject.nix", 36 + "type": "github" 37 + } 38 + }, 39 + "root": { 40 + "inputs": { 41 + "nixpkgs": "nixpkgs", 42 + "pyproject-nix": "pyproject-nix" 43 + } 44 + } 45 + }, 46 + "root": "root", 47 + "version": 7 48 + }
+93 -14
flake.nix
··· 1 1 { 2 - description = "A tool for creating and managing qutebrowser profiles"; 2 + description = "A profile manager for qutebrowser"; 3 + 4 + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 5 + inputs.pyproject-nix.url = "github:nix-community/pyproject.nix"; 6 + inputs.pyproject-nix.inputs.nixpkgs.follows = "nixpkgs"; 7 + 8 + outputs = 9 + { 10 + self, 11 + nixpkgs, 12 + pyproject-nix, 13 + }: 14 + let 15 + pyproject = pyproject-nix.lib.project.loadPyproject { projectRoot = ./.; }; 16 + pyprojectPackage = 17 + python: args: 18 + python.pkgs.buildPythonApplication ( 19 + args // pyproject.renderers.buildPythonPackage { inherit python; } 20 + ); 21 + pyprojectEnv = 22 + python: extraPackages: 23 + python.withPackages (pyproject.renderers.withPackages { inherit python extraPackages; }); 24 + forAllSystems = 25 + mkOutputs: 26 + nixpkgs.lib.genAttrs [ 27 + "aarch64-linux" 28 + "aarch64-darwin" 29 + "x86_64-darwin" 30 + "x86_64-linux" 31 + ] (system: mkOutputs nixpkgs.legacyPackages.${system}); 32 + in 33 + { 34 + packages = forAllSystems (pkgs: { 35 + qbpm = pyprojectPackage pkgs.python3 { 36 + nativeBuildInputs = [ 37 + pkgs.scdoc 38 + pkgs.installShellFiles 39 + ]; 40 + nativeCheckInputs = [ pkgs.python3.pkgs.pytestCheckHook ]; 41 + postInstallCheck = "$out/bin/qbpm --help"; 42 + postInstall = '' 43 + _QBPM_COMPLETE=bash_source $out/bin/qbpm > completions/qbpm.bash 44 + _QBPM_COMPLETE=zsh_source $out/bin/qbpm > completions/qbpm.zsh 45 + installShellCompletion completions/qbpm.{bash,zsh,fish} 46 + scdoc < qbpm.1.scd > qbpm.1 47 + installManPage qbpm.1 48 + ''; 49 + 50 + meta = { 51 + homepage = "https://github.com/pvsr/qbpm"; 52 + changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md"; 53 + description = "A profile manager for qutebrowser"; 54 + license = pkgs.lib.licenses.gpl3Plus; 55 + }; 56 + }; 57 + default = self.packages.${pkgs.system}.qbpm; 58 + }); 3 59 4 - inputs.flake-utils.url = "github:numtide/flake-utils"; 60 + apps = forAllSystems (pkgs: { 61 + qbpm = { 62 + type = "app"; 63 + program = pkgs.lib.getExe self.packages.${pkgs.system}.qbpm; 64 + }; 65 + default = self.apps.${pkgs.system}.qbpm; 66 + }); 5 67 6 - outputs = { self, nixpkgs, flake-utils }: 7 - flake-utils.lib.eachDefaultSystem (system: 8 - let pkgs = nixpkgs.legacyPackages.${system}; in 9 - rec { 10 - packages = flake-utils.lib.flattenTree { 11 - qbpm = import ./. { inherit pkgs; }; 68 + devShells = forAllSystems (pkgs: { 69 + default = pkgs.mkShell { 70 + packages = [ 71 + pkgs.ruff 72 + (pyprojectEnv pkgs.python3 (ps: [ 73 + ps.flit 74 + ps.pytest 75 + ps.pytest-cov 76 + ps.mypy 77 + ps.pylsp-mypy 78 + ])) 79 + ]; 12 80 }; 13 - defaultPackage = packages.qbpm; 14 - apps.qbpm = flake-utils.lib.mkApp { drv = packages.qbpm; }; 15 - defaultApp = apps.qbpm; 16 - devShell = import ./shell.nix { inherit pkgs; }; 17 - } 18 - ); 81 + }); 82 + 83 + formatter = forAllSystems ( 84 + pkgs: 85 + pkgs.nixfmt-tree.override { 86 + runtimeInputs = [ pkgs.ruff ]; 87 + settings = { 88 + tree-root-file = "flake.nix"; 89 + formatter.ruff = { 90 + command = "ruff"; 91 + options = [ "format" ]; 92 + includes = [ "*.py" ]; 93 + }; 94 + }; 95 + } 96 + ); 97 + }; 19 98 }
+75
pyproject.toml
··· 1 + [project] 2 + name = "qbpm" 3 + version = "2.2" 4 + description = "qutebrowser profile manager" 5 + license = "GPL-3.0-or-later" 6 + license-files = ["LICENSE"] 7 + readme = "README.md" 8 + authors = [{ name = "Peter Rice", email = "peter@peterrice.xyz" }] 9 + classifiers = [ 10 + "Environment :: Console", 11 + "Intended Audience :: End Users/Desktop", 12 + "Operating System :: MacOS", 13 + "Operating System :: POSIX :: Linux", 14 + "Programming Language :: Python :: 3", 15 + "Typing :: Typed", 16 + ] 17 + requires-python = ">= 3.11" 18 + dependencies = [ 19 + "click", 20 + "xdg-base-dirs", 21 + "dacite", 22 + ] 23 + 24 + [project.urls] 25 + homepage = "https://github.com/pvsr/qbpm" 26 + repository = "https://github.com/pvsr/qbpm" 27 + issues = "https://github.com/pvsr/qbpm/issues" 28 + changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md" 29 + 30 + [project.scripts] 31 + qbpm = "qbpm.main:main" 32 + 33 + [build-system] 34 + requires = ["flit_core >=3.2,<4"] 35 + build-backend = "flit_core.buildapi" 36 + 37 + [tool.pytest.ini_options] 38 + pythonpath = "src" 39 + 40 + [tool.mypy] 41 + disallow_untyped_defs = true 42 + disallow_any_unimported = true 43 + no_implicit_optional = true 44 + check_untyped_defs = true 45 + warn_return_any = true 46 + warn_unused_ignores = true 47 + 48 + [[tool.mypy.overrides]] 49 + module = "tests.*" 50 + disallow_untyped_defs = false 51 + 52 + [tool.ruff.lint] 53 + select = [ 54 + "E", 55 + "F", 56 + "W", 57 + "I", 58 + "UP", 59 + "N", 60 + "ANN", 61 + "B", 62 + "A", 63 + "C4", 64 + "PT", 65 + "SIM", 66 + "ARG", 67 + "PTH", 68 + "PL", 69 + "RUF", 70 + ] 71 + # long lines 72 + ignore = [ "E501" ] 73 + 74 + [tool.ruff.lint.per-file-ignores] 75 + "tests/test_*.py" = [ "S101", "ANN201"]
-4
qbpm/__init__.py
··· 1 - try: 2 - from qbpm.version import version as __version__ 3 - except ImportError: 4 - __version__ = "unknown"
-204
qbpm/main.py
··· 1 - import argparse 2 - from os import environ 3 - from pathlib import Path 4 - from typing import Any, Callable, Optional 5 - 6 - from xdg import BaseDirectory # type: ignore 7 - 8 - from qbpm import __version__, operations, profiles 9 - from qbpm.profiles import Profile 10 - from qbpm.utils import SUPPORTED_MENUS, error 11 - 12 - DEFAULT_PROFILE_DIR = Path(BaseDirectory.xdg_data_home) / "qutebrowser-profiles" 13 - 14 - 15 - def main(mock_args=None) -> None: 16 - parser = argparse.ArgumentParser(description="qutebrowser profile manager") 17 - parser.set_defaults(operation=lambda args: parser.print_help(), passthrough=False) 18 - parser.add_argument( 19 - "-P", 20 - "--profile-dir", 21 - metavar="directory", 22 - type=Path, 23 - help="directory in which profiles are stored", 24 - ) 25 - parser.add_argument( 26 - "--version", 27 - action="version", 28 - version=__version__, 29 - ) 30 - 31 - subparsers = parser.add_subparsers() 32 - new = subparsers.add_parser("new", help="create a new profile") 33 - new.add_argument("profile_name", metavar="profile", help="name of the new profile") 34 - new.add_argument("home_page", metavar="url", nargs="?", help="profile's home page") 35 - new.set_defaults( 36 - operation=lambda args: profiles.new_profile( 37 - build_profile(args), 38 - args.home_page, 39 - args.desktop_file, 40 - args.overwrite, 41 - ) 42 - ) 43 - creator_args(new) 44 - 45 - session = subparsers.add_parser( 46 - "from-session", help="create a new profile from a qutebrowser session" 47 - ) 48 - session.add_argument( 49 - "session", 50 - help="path to session file or name of session. " 51 - "e.g. ~/.local/share/qutebrowser/sessions/example.yml or example", 52 - ) 53 - session.add_argument( 54 - "profile_name", 55 - metavar="profile", 56 - nargs="?", 57 - help="name of the new profile. if unset the session name will be used", 58 - ) 59 - session.set_defaults( 60 - operation=lambda args: operations.from_session( 61 - args.session, 62 - args.profile_name, 63 - args.profile_dir, 64 - args.desktop_file, 65 - args.overwrite, 66 - ) 67 - ) 68 - creator_args(session) 69 - 70 - desktop = subparsers.add_parser( 71 - "desktop", help="create a desktop file for an existing profile" 72 - ) 73 - desktop.add_argument( 74 - "profile_name", metavar="profile", help="profile to create a desktop file for" 75 - ) 76 - desktop.set_defaults(operation=lambda args: operations.desktop(build_profile(args))) 77 - 78 - launch = subparsers.add_parser( 79 - "launch", aliases=["run"], help="launch qutebrowser with the given profile" 80 - ) 81 - launch.add_argument( 82 - "profile_name", 83 - metavar="profile", 84 - help="profile to launch. it will be created if it does not exist, unless -s is set", 85 - ) 86 - launch.add_argument( 87 - "-n", 88 - "--new", 89 - action="store_false", 90 - dest="strict", 91 - help="create the profile if it doesn't exist", 92 - ) 93 - launch.add_argument( 94 - "-f", 95 - "--foreground", 96 - action="store_true", 97 - help="launch qutebrowser in the foreground and print its stdout and stderr to the console", 98 - ) 99 - launch.set_defaults( 100 - operation=lambda args: operations.launch( 101 - build_profile(args), args.strict, args.foreground, args.qb_args 102 - ), 103 - passthrough=True, 104 - ) 105 - 106 - list_ = subparsers.add_parser("list", help="list existing profiles") 107 - list_.set_defaults(operation=operations.list_) 108 - 109 - choose = subparsers.add_parser( 110 - "choose", 111 - help="choose profile using a dmenu-compatible launcher or an applescript dialog", 112 - ) 113 - menus = sorted(SUPPORTED_MENUS) 114 - choose.add_argument( 115 - "-m", 116 - "--menu", 117 - help=f'menu application to use. this may be any dmenu-compatible command (e.g. "dmenu -i -p qbpm" or "/path/to/rofi -d") or one of the following menus with built-in support: {menus}', 118 - ) 119 - choose.add_argument( 120 - "-f", 121 - "--foreground", 122 - action="store_true", 123 - help="launch qutebrowser in the foreground and print its stdout and stderr to the console", 124 - ) 125 - choose.set_defaults(operation=operations.choose, passthrough=True) 126 - 127 - edit = subparsers.add_parser( 128 - "edit", help="edit a profile's config.py using $EDITOR" 129 - ) 130 - edit.add_argument("profile_name", metavar="profile", help="profile to edit") 131 - edit.set_defaults(operation=lambda args: operations.edit(build_profile(args))) 132 - 133 - raw_args = parser.parse_known_args(mock_args) 134 - args = raw_args[0] 135 - if args.passthrough: 136 - args.qb_args = raw_args[1] 137 - elif len(raw_args[1]) > 0: 138 - error(f"unrecognized arguments: {' '.join(raw_args[1])}") 139 - exit(1) 140 - 141 - if not args.profile_dir: 142 - args.profile_dir = Path(environ.get("QBPM_PROFILE_DIR") or DEFAULT_PROFILE_DIR) 143 - if not args.operation(args): 144 - exit(1) 145 - 146 - 147 - def creator_args(parser: argparse.ArgumentParser) -> None: 148 - parser.add_argument( 149 - "-l", 150 - "--launch", 151 - action=ThenLaunchAction, 152 - dest="operation", 153 - help="launch the profile after creating", 154 - ) 155 - parser.add_argument( 156 - "-f", 157 - "--foreground", 158 - action="store_true", 159 - help="if --launch is set, launch qutebrowser in the foreground", 160 - ) 161 - parser.add_argument( 162 - "--no-desktop-file", 163 - dest="desktop_file", 164 - action="store_false", 165 - help="do not generate a desktop file for the profile", 166 - ) 167 - parser.add_argument( 168 - "--overwrite", 169 - action="store_true", 170 - help="replace existing profile config", 171 - ) 172 - parser.set_defaults(strict=True) 173 - 174 - 175 - class ThenLaunchAction(argparse.Action): 176 - def __init__(self, option_strings, dest, nargs=0, **kwargs): 177 - super(ThenLaunchAction, self).__init__( 178 - option_strings, dest, nargs=nargs, **kwargs 179 - ) 180 - 181 - def __call__(self, parser, namespace, values, option_string=None): 182 - if operation := getattr(namespace, self.dest): 183 - setattr(namespace, self.dest, lambda args: then_launch(args, operation)) 184 - 185 - 186 - def then_launch( 187 - args: argparse.Namespace, 188 - operation: Callable[[argparse.Namespace], Optional[Any]], 189 - ) -> bool: 190 - if result := operation(args): 191 - if isinstance(result, Profile): 192 - profile = result 193 - else: 194 - profile = build_profile(args) 195 - return operations.launch(profile, False, args.foreground, args.qb_args) 196 - return False 197 - 198 - 199 - def build_profile(args: argparse.Namespace) -> Profile: 200 - return Profile(args.profile_name, args.profile_dir) 201 - 202 - 203 - if __name__ == "__main__": 204 - main()
-158
qbpm/operations.py
··· 1 - import argparse 2 - import os 3 - import shutil 4 - import subprocess 5 - from pathlib import Path 6 - from sys import platform, stderr 7 - from typing import List, Optional 8 - 9 - from xdg import BaseDirectory # type: ignore 10 - from xdg.DesktopEntry import DesktopEntry # type: ignore 11 - 12 - from qbpm import profiles 13 - from qbpm.profiles import Profile 14 - from qbpm.utils import SUPPORTED_MENUS, error, get_default_menu, user_data_dir 15 - 16 - 17 - def from_session( 18 - session: str, 19 - profile_name: Optional[str] = None, 20 - profile_dir: Optional[Path] = None, 21 - desktop_file: bool = True, 22 - overwrite: bool = False, 23 - ) -> Optional[Profile]: 24 - if session.endswith(".yml"): 25 - session_file = Path(session).expanduser() 26 - session_name = session_file.stem 27 - else: 28 - session_name = session 29 - session_file = user_data_dir() / "sessions" / (session_name + ".yml") 30 - if not session_file.is_file(): 31 - error(f"{session_file} is not a file") 32 - return None 33 - 34 - profile = Profile(profile_name or session_name, profile_dir) 35 - if not profiles.new_profile(profile, None, desktop_file, overwrite): 36 - return None 37 - 38 - session_dir = profile.root / "data" / "sessions" 39 - session_dir.mkdir(parents=True, exist_ok=overwrite) 40 - shutil.copy(session_file, session_dir / "_autosave.yml") 41 - 42 - return profile 43 - 44 - 45 - def launch( 46 - profile: Profile, strict: bool, foreground: bool, qb_args: List[str] 47 - ) -> bool: 48 - if not profiles.ensure_profile_exists(profile, not strict): 49 - return False 50 - 51 - args = profile.cmdline() + qb_args 52 - if not shutil.which(args[0]): 53 - error("qutebrowser is not installed") 54 - return False 55 - 56 - if foreground: 57 - return subprocess.run(args).returncode == 0 58 - else: 59 - p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) 60 - try: 61 - # give qb a chance to validate input before returning to shell 62 - stdout, stderr = p.communicate(timeout=0.1) 63 - print(stderr.decode(errors="ignore"), end="") 64 - except subprocess.TimeoutExpired: 65 - pass 66 - 67 - return True 68 - 69 - 70 - application_dir = Path(BaseDirectory.xdg_data_home) / "applications" / "qbpm" 71 - 72 - 73 - def desktop(profile: Profile) -> bool: 74 - exists = profile.exists() 75 - if exists: 76 - profiles.create_desktop_file(profile) 77 - else: 78 - error(f"profile {profile.name} not found at {profile.root}") 79 - return exists 80 - 81 - 82 - def list_(args: argparse.Namespace) -> bool: 83 - for profile in sorted(args.profile_dir.iterdir()): 84 - print(profile.name) 85 - return True 86 - 87 - 88 - def choose(args: argparse.Namespace) -> bool: 89 - menu = args.menu or get_default_menu() 90 - if not menu: 91 - error(f"No menu program found, please install one of: {SUPPORTED_MENUS}") 92 - return False 93 - if menu == "applescript" and platform != "darwin": 94 - error(f"Menu applescript cannot be used on a {platform} host") 95 - return False 96 - profiles = [profile.name for profile in sorted(args.profile_dir.iterdir())] 97 - if len(profiles) == 0: 98 - error("No profiles") 99 - return False 100 - 101 - command = menu_command(menu, profiles, args) 102 - program = command.split(" ")[0] 103 - if not shutil.which(program): 104 - error(f"'{program}' not found on path") 105 - return False 106 - 107 - selection_cmd = subprocess.Popen( 108 - command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE 109 - ) 110 - out = selection_cmd.stdout 111 - if not out: 112 - error(f"Could not read stdout from {command}") 113 - return False 114 - selection = out.read().decode(errors="ignore").rstrip("\n") 115 - 116 - if selection: 117 - profile = Profile(selection, args.profile_dir) 118 - launch(profile, True, args.foreground, args.qb_args) 119 - else: 120 - error("No profile selected") 121 - if err := selection_cmd.stderr: 122 - msg = err.read().decode(errors="ignore").rstrip("\n") 123 - if msg: 124 - for line in msg.split("\n"): 125 - print(f"stderr: {line}", file=stderr) 126 - return False 127 - return True 128 - 129 - 130 - def menu_command(menu: str, profiles, args: argparse.Namespace) -> str: 131 - arg_string = " ".join(args.qb_args) 132 - if menu == "applescript": 133 - profile_list = '", "'.join(profiles) 134 - return f"""osascript -e \'set profiles to {{"{profile_list}"}} 135 - set profile to choose from list profiles with prompt "qutebrowser: {arg_string}" default items {{item 1 of profiles}} 136 - item 1 of profile\'""" 137 - 138 - prompt = "-p qutebrowser" 139 - command = menu 140 - if len(menu.split(" ")) == 1: 141 - program = Path(menu).name 142 - if program == "rofi": 143 - command = f"{menu} -dmenu -no-custom {prompt} -mesg {arg_string}" 144 - elif program == "wofi": 145 - command = f"{menu} --dmenu {prompt}" 146 - elif program in ["dmenu", "dmenu-wl"]: 147 - command = f"{menu} {prompt}" 148 - profile_list = "\n".join(profiles) 149 - return f'echo "{profile_list}" | {command}' 150 - 151 - 152 - def edit(profile: Profile) -> bool: 153 - if not profile.exists(): 154 - error(f"profile {profile.name} not found at {profile.root}") 155 - return False 156 - editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vim" 157 - os.execlp(editor, editor, str(profile.root / "config" / "config.py")) 158 - return True
-123
qbpm/profiles.py
··· 1 - from functools import partial 2 - from pathlib import Path 3 - from sys import platform 4 - from typing import List, Optional 5 - 6 - from xdg import BaseDirectory # type: ignore 7 - from xdg.DesktopEntry import DesktopEntry # type: ignore 8 - 9 - from qbpm.utils import error, user_config_dir 10 - 11 - 12 - class Profile: 13 - name: str 14 - profile_dir: Path 15 - root: Path 16 - 17 - def __init__(self, name: str, profile_dir: Optional[Path]) -> None: 18 - self.name = name 19 - self.profile_dir = profile_dir or Path( 20 - BaseDirectory.save_data_path("qutebrowser-profiles") 21 - ) 22 - self.root = self.profile_dir / name 23 - 24 - def check(self) -> Optional["Profile"]: 25 - if "/" in self.name: 26 - error("profile name cannot contain slashes") 27 - return None 28 - if not self.profile_dir.resolve().is_dir(): 29 - error(f"{self.profile_dir} is not a directory") 30 - return None 31 - return self 32 - 33 - def exists(self) -> bool: 34 - return self.root.exists() and self.root.is_dir() 35 - 36 - def cmdline(self) -> List[str]: 37 - macos_app = "/Applications/qutebrowser.app/Contents/MacOS/qutebrowser" 38 - if platform == "darwin" and Path(macos_app).exists(): 39 - qb = macos_app 40 - else: 41 - qb = "qutebrowser" 42 - return [ 43 - qb, 44 - "-B", 45 - str(self.root), 46 - "--qt-arg", 47 - "name", 48 - self.name, 49 - "--desktop-file-name", 50 - self.name, 51 - ] 52 - 53 - 54 - def create_profile(profile: Profile, overwrite: bool = False) -> bool: 55 - if not profile.check(): 56 - return False 57 - 58 - if not overwrite and profile.root.exists(): 59 - error(f"{profile.root} already exists") 60 - return False 61 - 62 - config_dir = profile.root / "config" 63 - config_dir.mkdir(parents=True, exist_ok=overwrite) 64 - print(profile.root) 65 - return True 66 - 67 - 68 - def create_config( 69 - profile: Profile, home_page: Optional[str] = None, overwrite: bool = False 70 - ) -> None: 71 - user_config = profile.root / "config" / "config.py" 72 - with user_config.open(mode="w" if overwrite else "x") as dest_config: 73 - out = partial(print, file=dest_config) 74 - out("config.load_autoconfig()") 75 - title_prefix = "{perc}{current_title}{title_sep}" 76 - out(f"c.window.title_format = '{title_prefix} qutebrowser ({profile.name})'") 77 - if home_page: 78 - out(f"c.url.start_pages = ['{home_page}']") 79 - main_config_dir = user_config_dir() 80 - out(f"config.source('{main_config_dir / 'config.py'}')") 81 - for conf in main_config_dir.glob("conf.d/*.py"): 82 - out(f"config.source('{conf}')") 83 - 84 - 85 - application_dir = Path(BaseDirectory.xdg_data_home) / "applications" / "qbpm" 86 - 87 - 88 - def create_desktop_file(profile: Profile): 89 - desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop")) 90 - desktop.set("Name", f"{profile.name} (qutebrowser profile)") 91 - # TODO allow passing in an icon value 92 - desktop.set("Icon", "qutebrowser") 93 - desktop.set("Exec", " ".join(profile.cmdline()) + " %u") 94 - desktop.set("Categories", ["Network"]) 95 - desktop.set("Terminal", False) 96 - desktop.set("StartupNotify", True) 97 - desktop.write() 98 - 99 - 100 - def ensure_profile_exists(profile: Profile, create: bool = True) -> bool: 101 - if profile.root.exists() and not profile.root.is_dir(): 102 - error(f"{profile.root} is not a directory") 103 - return False 104 - if not profile.root.exists() and create: 105 - return new_profile(profile) 106 - if not profile.root.exists(): 107 - error(f"{profile.root} does not exist") 108 - return False 109 - return True 110 - 111 - 112 - def new_profile( 113 - profile: Profile, 114 - home_page: Optional[str] = None, 115 - desktop_file: bool = True, 116 - overwrite: bool = False, 117 - ) -> bool: 118 - if create_profile(profile, overwrite): 119 - create_config(profile, home_page, overwrite) 120 - if desktop_file: 121 - create_desktop_file(profile) 122 - return True 123 - return False
-45
qbpm/utils.py
··· 1 - import platform 2 - import subprocess 3 - import sys 4 - from pathlib import Path 5 - from shutil import which 6 - from sys import exit, stderr 7 - from typing import Optional 8 - 9 - from xdg import BaseDirectory # type: ignore 10 - 11 - SUPPORTED_MENUS = ["wofi", "rofi", "dmenu", "dmenu-wl", "applescript"] 12 - 13 - 14 - def error(msg: str) -> None: 15 - print(f"error: {msg}", file=stderr) 16 - 17 - 18 - def user_data_dir() -> Path: 19 - if platform.system() == "Linux": 20 - return Path(BaseDirectory.xdg_data_home) / "qutebrowser" 21 - if platform.system() == "Darwin": 22 - return Path.home() / "Library" / "Application Support" / "qutebrowser" 23 - error("This operation is only implemented for linux and macOS.") 24 - print( 25 - "If you're interested in adding support for another OS, send a PR " 26 - "to github.com/pvsr/qbpm adding the location of qutebrowser data such " 27 - "as history.sqlite on your OS to user_data_dir() in qbpm/utils.py.", 28 - file=stderr, 29 - ) 30 - exit(1) 31 - 32 - 33 - def user_config_dir() -> Path: 34 - return Path(BaseDirectory.xdg_config_home) / "qutebrowser" 35 - 36 - 37 - def get_default_menu() -> Optional[str]: 38 - if sys.platform == "darwin": 39 - return "applescript" 40 - for menu_cmd in SUPPORTED_MENUS: 41 - if menu_cmd == "applescript": 42 - continue 43 - if which(menu_cmd) is not None: 44 - return menu_cmd 45 - return None
+44 -27
qbpm.1.scd
··· 6 6 7 7 # SYNOPSIS 8 8 9 - *qbpm* [--profile-dir=<path>|-P <path>] <command> [<args>] 9 + *qbpm* [--profile-dir=<path>|-P <path>] [--config-file|-c <path>] <command> [<args>] 10 10 11 11 # DESCRIPTION 12 12 13 - qbpm is a tool for creating and running qutebrowser profiles. qutebrowser 14 - doesn't have a native concept of profiles, but it does have a --basedir flag 15 - that allows qutebrowser's config, cache, and data to be stored in any directory. 16 - So a profile is simply a directory meant to be set as qutebrowser's basedir. 17 - qbpm creates profiles that source the config.py from your qutebrowser config dir 18 - in $XDG_CONFIG_HOME (typically $HOME/.config/qutebrowser). By default profiles 19 - are stored in a qutebrowser-profiles/ dir in $XDG_DATA_HOME (typically 20 - $HOME/.local/share), but this can be set to another directory by passing 21 - \--profile-dir to qbpm or setting the $QBPM_PROFILE_DIR environment variable. 13 + qbpm is a tool for creating, managing, and running qutebrowser profiles. Profile support 14 + isn't built in to qutebrowser, at least not directly, but it does have a \--basedir flag 15 + which allows qutebrowser to use any directory as the location of its config and 16 + data and effectively act as a profile. qbpm creates profiles that source your 17 + main qutebrowser config.py, but have their own separate autoconfig.yml, bookmarks, cookies, 18 + history, and other data. Profiles can be run by starting qutebrowser with the 19 + appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choose commands. 22 20 23 21 # OPTIONS 24 22 ··· 32 30 Use _path_ as the profile directory instead of the default location. Takes 33 31 precedence over the QBPM_PROFILE_DIR environment variable. 34 32 33 + *-c, --config-file* <path> 34 + Read configuration for qbpm from _path_. Defaults to ~/.config/qbpm/config.toml. 35 + 35 36 # COMMANDS 36 37 37 38 *new* [options] <profile> [<url>] 38 39 Create a new qutebrowser profile named _profile_. If _url_ is present it will 39 - be used as the profile's home page. By default, a .desktop file will be 40 - created for the profile in $XDG_DATA_HOME/applications/qbpm/. 40 + be used as the profile's home page. 41 41 42 42 Options: 43 43 ··· 47 47 *-f, --foreground* 48 48 If --launch is set, run qutebrowser in the foreground. 49 49 50 - *--no-desktop-file* 51 - Do not generate a .desktop file for the profile. 50 + *-C, --qutebrowser-config-dir* <path> 51 + Source config files from the provided directory instead of the global 52 + qutebrowser config location. 53 + 54 + *--desktop-file/--no-desktop-file* 55 + Whether to generate an XDG desktop entry for the profile. Only relevant 56 + on linux systems. See https://wiki.archlinux.org/title/Desktop_entries 57 + for information on desktop entries. 52 58 53 59 *--overwrite* 54 60 By default qbpm will refuse to create a profile if one with the same name 55 - already exists. --overwrite disables this check and rewrites the existing 56 - profile's configuration files from scratch. Profile data is left untouched. 61 + already exists. --overwrite disables this check and replaces the existing 62 + profile's configuration files. Profile data is left untouched. 57 63 58 - *launch* [options] <profile> [argument...] 64 + *launch* [options] <profile> [arguments...] 59 65 Start qutebrowser with --basedir set to the location of _profile_. All 60 66 arguments following _profile_ will be passed on to qutebrowser. 61 67 ··· 64 70 *-f, --foreground* 65 71 Run qutebrowser in the foreground instead of forking off a new process. 66 72 67 - *-n, --new* 73 + *-c, --create* 68 74 Create the profile if it does not exist. 69 75 70 76 Examples: ··· 73 79 \# launch my profile called work and open internal.mycompany.com 74 80 qbpm launch work internal.mycompany.com 75 81 76 - \# launch a new profile called qb-dev, passing the flags to qutebrowser 82 + \# launch a new profile called qb-dev, passing the debugging flags to qutebrowser 77 83 qbpm launch -n qb-dev --debug --json-logging 78 84 ``` 79 85 80 - *choose* [options] 86 + *choose* [options] [arguments...] 81 87 Open a menu to choose a qutebrowser profile to launch. On linux this defaults 82 88 to dmenu or another compatible menu program such as rofi, and on macOS this 83 - will be an applescript dialog. 89 + will be an applescript dialog. All arguments are passed to qutebrowser. 84 90 85 91 *-m, --menu* <menu> 86 92 Use _menu_ instead of the default menu program. This may be the name of a 87 93 program on $PATH or a path to a program, in which case it will be run in 88 - dmenu mode if qbpm knows about the program, or a full command line. 94 + dmenu mode if qbpm knows about the program, or a full command line. On 95 + MacOS the special value "applescript" is accepted. Run `qbpm choose --help` 96 + for a list of known menu programs for your environment. 89 97 90 98 Examples: 91 99 92 100 ``` 93 - \# runs "echo {profiles} | /path/to/rofi -dmenu -no-custom -p qutebrowser" 94 - qbpm choose -m /path/to/rofi 101 + qbpm choose --menu fzf 102 + 103 + qbpm choose --menu "./build/my-cool-menu --dmenu-mode --prompt qutebrowser" 104 + 105 + \# qbpm knows about fuzzel so it can automatically invoke it as "~/.local/bin/fuzzel --dmenu" 106 + qbpm choose --menu ~/.local/bin/fuzzel 95 107 96 - qbpm choose -m "my-cool-menu --dmenu-mode --prompt qutebrowser" 108 + \# if more than one word is provided it will be invoked as is, so `--dmenu` must be included 109 + qbpm choose --menu 'fuzzel --dmenu --width 100' 97 110 ``` 98 111 99 112 *from-session* [options] <session> [<name>] ··· 104 117 *new*. 105 118 106 119 *desktop* <profile> 107 - Generate a .desktop file for _profile_. 120 + Generate an XDG desktop entry for _profile_. 108 121 109 122 *edit* <profile> 110 123 Open _profile_'s config.py in your default editor. ··· 116 129 117 130 Peter Rice 118 131 119 - Contribute at https://github.com/pvsr/qbpm 132 + # CONTRIBUTE 133 + 134 + _https://github.com/pvsr/qbpm_ 135 + 136 + _https://codeberg.org/pvsr/qbpm_
-2
requirements.txt
··· 1 - pyxdg ~= 0.26 2 - setuptools_scm ~= 3.4
-15
setup.py
··· 1 - from setuptools import setup, find_packages 2 - 3 - setup( 4 - name="qbpm", 5 - version="0.6", 6 - url="https://github.com/pvsr/qbpm", 7 - packages=find_packages(), 8 - entry_points={"console_scripts": ["qbpm = qbpm.main:main"]}, 9 - install_requires=["pyxdg"], 10 - author="Peter Rice", 11 - author_email="peter@peterrice.xyz", 12 - description="qutebrowser profile manager", 13 - use_scm_version={"write_to": "qbpm/version.py"}, 14 - setup_requires=["setuptools_scm"], 15 - )
-14
shell.nix
··· 1 - { pkgs ? import <nixpkgs> {}, ... }: 2 - with pkgs; 3 - mkShell { 4 - buildInputs = [ 5 - (python3.withPackages (ps: with ps; [ 6 - pyxdg 7 - setuptools-scm 8 - pytest 9 - pylint 10 - mypy 11 - black 12 - ])) 13 - ]; 14 - }
+38
src/qbpm/__init__.py
··· 1 + from pathlib import Path 2 + 3 + from .log import error 4 + from .paths import qutebrowser_exe 5 + 6 + try: 7 + from qbpm.version import version as __version__ # type: ignore 8 + except ImportError: 9 + __version__ = "unknown" 10 + 11 + 12 + class Profile: 13 + name: str 14 + profile_dir: Path 15 + root: Path 16 + 17 + def __init__(self, name: str, profile_dir: Path) -> None: 18 + self.name = name 19 + self.profile_dir = profile_dir 20 + self.root = self.profile_dir / name 21 + 22 + def check_name(self) -> bool: 23 + if "/" in self.name or self.name in [".", ".."]: 24 + error("profile name cannot be a path") 25 + return False 26 + return True 27 + 28 + def cmdline(self) -> list[str]: 29 + return [ 30 + qutebrowser_exe(), 31 + "-B", 32 + str(self.root), 33 + "--qt-arg", 34 + "name", 35 + self.name, 36 + "--desktop-file-name", 37 + self.name, 38 + ]
+4
src/qbpm/__main__.py
··· 1 + from .main import main 2 + 3 + if __name__ == "__main__": 4 + main()
+45
src/qbpm/choose.py
··· 1 + import subprocess 2 + from pathlib import Path 3 + 4 + from . import Profile 5 + from .launch import launch_qutebrowser 6 + from .log import error 7 + from .menus import find_menu 8 + 9 + 10 + def choose_profile( 11 + profile_dir: Path, 12 + menu: str | list[str], 13 + prompt: str, 14 + foreground: bool, 15 + qb_args: tuple[str, ...], 16 + ) -> bool: 17 + dmenu = find_menu(menu) 18 + if not dmenu: 19 + return False 20 + 21 + real_profiles = {profile.name for profile in profile_dir.iterdir()} 22 + if len(real_profiles) == 0: 23 + error("no profiles") 24 + return False 25 + profiles = [*real_profiles, "qutebrowser"] 26 + command = dmenu.command(sorted(profiles), prompt, " ".join(qb_args)) 27 + selection_cmd = subprocess.run( 28 + command, 29 + text=True, 30 + input="\n".join(sorted(profiles)), 31 + stdout=subprocess.PIPE, 32 + stderr=None, 33 + check=False, 34 + ) 35 + out = selection_cmd.stdout 36 + selection = out.rstrip("\n") 37 + 38 + if selection == "qutebrowser" and "qutebrowser" not in real_profiles: 39 + return launch_qutebrowser(None, foreground, qb_args) 40 + elif selection: 41 + profile = Profile(selection, profile_dir) 42 + return launch_qutebrowser(profile, foreground, qb_args) 43 + else: 44 + error("no profile selected") 45 + return False
+79
src/qbpm/config.py
··· 1 + import os 2 + import platform 3 + import sys 4 + import tomllib 5 + from dataclasses import dataclass, field, fields 6 + from pathlib import Path 7 + 8 + import dacite 9 + 10 + from . import paths 11 + from .log import error, or_phrase 12 + 13 + DEFAULT_CONFIG_FILE = Path(__file__).parent / "config.toml" 14 + 15 + 16 + @dataclass(kw_only=True) 17 + class Config: 18 + config_py_template: str | None = None 19 + symlink_autoconfig: bool = False 20 + qutebrowser_config_directory: Path | None = None 21 + profile_directory: Path = field(default_factory=paths.default_profile_dir) 22 + generate_desktop_file: bool = platform.system() == "Linux" 23 + application_name: str = "{profile_name} (qutebrowser profile)" 24 + desktop_file_directory: Path = field( 25 + default_factory=paths.default_qbpm_application_dir 26 + ) 27 + menu: str | list[str] = field(default_factory=list) 28 + menu_prompt: str = "qutebrowser" 29 + 30 + @classmethod 31 + def load(cls, config_file: Path | None) -> "Config": 32 + config_file = config_file or DEFAULT_CONFIG_FILE 33 + try: 34 + data = tomllib.loads(config_file.read_text(encoding="utf-8")) 35 + if extra := data.keys() - {field.name for field in fields(Config)}: 36 + raise RuntimeError(f'unknown config value: "{next(iter(extra))}"') 37 + return dacite.from_dict( 38 + data_class=Config, 39 + data=data, 40 + config=dacite.Config( 41 + type_hooks={Path: lambda val: Path(val).expanduser()} 42 + ), 43 + ) 44 + except Exception as e: 45 + error(f"loading {config_file} failed with error '{e}'") 46 + sys.exit(1) 47 + 48 + 49 + def find_config(config_path: Path | None) -> Config: 50 + if not config_path: 51 + default = paths.default_qbpm_config_dir() / "config.toml" 52 + if default.is_file(): 53 + config_path = default 54 + elif config_path == Path(os.devnull): 55 + config_path = None 56 + elif not config_path.is_file(): 57 + error(f"{config_path} is not a file") 58 + sys.exit(1) 59 + return Config.load(config_path) 60 + 61 + 62 + def find_qutebrowser_config_dir( 63 + qb_config_dir: Path | None, autoconfig: bool = False 64 + ) -> Path | None: 65 + dirs = ( 66 + [qb_config_dir, qb_config_dir / "config"] 67 + if qb_config_dir 68 + else list(paths.qutebrowser_config_dirs()) 69 + ) 70 + for config_dir in dirs: 71 + if (config_dir / "config.py").exists() or ( 72 + autoconfig and (config_dir / "autoconfig.yml").exists() 73 + ): 74 + return config_dir.absolute() 75 + if autoconfig: 76 + error(f"couldn't find config.py or autoconfig.yml in {or_phrase(dirs)}") 77 + else: 78 + error(f"couldn't find config.py in {or_phrase(dirs)}") 79 + return None
+46
src/qbpm/config.toml
··· 1 + # template that new config.py files are generated from 2 + # supported placeholders: {profile_name}, {source_config_py} 3 + config_py_template = """ 4 + config.source(r'{source_config_py}') 5 + 6 + c.window.title_format += ' ({profile_name})' 7 + 8 + config.load_autoconfig() 9 + """ 10 + 11 + # symlink autoconfig.yml in new profiles if the os supports it 12 + # symlink_autoconfig = false 13 + 14 + # location to store qutebrowser profiles 15 + # profile_directory = "~/.local/share/qutebrowser-profiles" 16 + 17 + # location of the qutebrowser config to inherit from 18 + # qutebrowser_config_directory = "~/.config/qutebrowser" 19 + 20 + # when creating a profile also generate an XDG desktop file that launches the profile 21 + # defaults to true on linux 22 + # generate_desktop_file = false 23 + # desktop_file_directory = "~/.local/share/applications/qbpm" 24 + 25 + # application name in XDG desktop file (replace existing with `qbpm desktop PROFILE_NAME`) 26 + # supported placeholders: {profile_name} 27 + # application_name = "{profile_name} (qutebrowser profile)" 28 + 29 + # profile selection menu for `qbpm choose` 30 + # when not set, qbpm will try to find a menu program on your $PATH 31 + # run `qbpm choose --help` for a list of known menu programs 32 + # if menu is a known menu, dmenu-mode flags are set automatically 33 + # menu = "fuzzel" # gets turned into "fuzzel --dmenu", /path/to/fuzzel also works 34 + # otherwise menu must be a dmenu-compatible commandline 35 + # supported placeholders: {prompt}, {qb_args} 36 + # menu = "~/bin/my-dmenu" 37 + # menu = "fuzzel --dmenu --prompt '{prompt}> ' --lines 20 --width 50" 38 + # optionally menu can be written as a list to simplify quoting 39 + # menu = ["fuzzel", "--dmenu", "--prompt", "{prompt}> ", "--lines", "20", "--width", "50"] 40 + 41 + # value of {prompt} in menu commands 42 + # supported placeholders: {qb_args} 43 + # defaults to "qutebrowser" 44 + # menu_prompt = "qbpm" 45 + # menu_prompt = "profiles" 46 + # menu_prompt = "qutebrowser {qb_args}"
+49
src/qbpm/desktop.py
··· 1 + import textwrap 2 + from pathlib import Path 3 + 4 + from . import Profile 5 + 6 + MIME_TYPES = [ 7 + "text/html", 8 + "text/xml", 9 + "application/xhtml+xml", 10 + "application/xml", 11 + "application/rdf+xml", 12 + "image/gif", 13 + "image/jpeg", 14 + "image/png", 15 + "x-scheme-handler/http", 16 + "x-scheme-handler/https", 17 + "x-scheme-handler/qute", 18 + ] 19 + 20 + 21 + def create_desktop_file( 22 + profile: Profile, application_dir: Path, application_name: str 23 + ) -> None: 24 + application_name = application_name.format(profile_name=profile.name) 25 + text = textwrap.dedent(f"""\ 26 + [Desktop Entry] 27 + Name={application_name} 28 + StartupWMClass=qutebrowser 29 + GenericName={profile.name} 30 + Icon=qutebrowser 31 + Type=Application 32 + Categories=Network;WebBrowser; 33 + Exec={" ".join([*profile.cmdline(), "--untrusted-args", "%u"])} 34 + Terminal=false 35 + StartupNotify=true 36 + MimeType={";".join(MIME_TYPES)}; 37 + Keywords=Browser 38 + Actions=new-window;preferences; 39 + 40 + [Desktop Action new-window] 41 + Name=New Window 42 + Exec={" ".join(profile.cmdline())} 43 + 44 + [Desktop Action preferences] 45 + Name=Preferences 46 + Exec={" ".join([*profile.cmdline(), '"qute://settings"'])} 47 + """) 48 + application_dir.mkdir(parents=True, exist_ok=True) 49 + (application_dir / f"{profile.name}.desktop").write_text(text)
+32
src/qbpm/launch.py
··· 1 + import shutil 2 + import subprocess 3 + 4 + from . import Profile 5 + from .log import error 6 + from .paths import qutebrowser_exe 7 + 8 + 9 + def launch_qutebrowser( 10 + profile: Profile | None, foreground: bool, qb_args: tuple[str, ...] = () 11 + ) -> bool: 12 + qb = profile.cmdline() if profile else [qutebrowser_exe()] 13 + return launch(foreground, [*qb, *qb_args]) 14 + 15 + 16 + def launch(foreground: bool, args: list[str]) -> bool: 17 + if not shutil.which(args[0]): 18 + error("qutebrowser is not installed") 19 + return False 20 + 21 + if foreground: 22 + return subprocess.run(args, check=False).returncode == 0 23 + else: 24 + p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) 25 + try: 26 + # give qb a chance to validate input before returning to shell 27 + _stdout, stderr = p.communicate(timeout=0.1) 28 + print(stderr.decode(errors="ignore"), end="") 29 + except subprocess.TimeoutExpired: 30 + pass 31 + 32 + return True
+22
src/qbpm/log.py
··· 1 + import logging 2 + 3 + 4 + def info(msg: str) -> None: 5 + logging.info(msg) 6 + 7 + 8 + def error(msg: str) -> None: 9 + logging.error(msg) 10 + 11 + 12 + def or_phrase(items: list) -> str: 13 + strings = list(map(str, items)) 14 + size = len(strings) 15 + if size == 0: 16 + return "[]" 17 + elif size == 1: 18 + return strings[0] 19 + elif size == 2: # noqa: PLR2004 20 + return " or ".join(strings) 21 + else: 22 + return ", or ".join([", ".join(strings[0:-1]), strings[-1]])
+321
src/qbpm/main.py
··· 1 + import logging 2 + import sys 3 + from collections.abc import Callable 4 + from dataclasses import dataclass 5 + from functools import wraps 6 + from pathlib import Path 7 + from typing import Any, NoReturn, TypeVar 8 + 9 + import click 10 + 11 + from . import Profile, profiles 12 + from .choose import choose_profile 13 + from .config import DEFAULT_CONFIG_FILE, Config, find_config 14 + from .desktop import create_desktop_file 15 + from .launch import launch_qutebrowser 16 + from .menus import supported_menus 17 + from .paths import default_qbpm_config_dir 18 + from .session import profile_from_session 19 + 20 + CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"], "max_content_width": 91} 21 + 22 + 23 + @dataclass 24 + class Context: 25 + cli_profile_dir: Path | None 26 + cli_config_file: Path | None 27 + 28 + def load_config(self) -> Config: 29 + config = find_config(self.cli_config_file) 30 + if self.cli_profile_dir: 31 + config.profile_directory = self.cli_profile_dir 32 + return config 33 + 34 + 35 + @dataclass 36 + class CreatorOptions: 37 + qb_config_dir: Path | None 38 + launch: bool 39 + foreground: bool 40 + desktop_file: bool | None 41 + overwrite: bool 42 + 43 + 44 + T = TypeVar("T") 45 + 46 + 47 + def creator_options(orig: Callable[..., T]) -> Callable[..., T]: 48 + @wraps(orig) 49 + def command( 50 + qb_config_dir: Path | None, 51 + launch: bool, 52 + foreground: bool, 53 + desktop_file: bool | None, 54 + overwrite: bool, 55 + *args: Any, # noqa: ANN401 56 + **kwargs: Any, # noqa: ANN401 57 + ) -> T: 58 + return orig( 59 + *args, 60 + c_opts=CreatorOptions( 61 + qb_config_dir, launch, foreground, desktop_file, overwrite 62 + ), 63 + **kwargs, 64 + ) 65 + 66 + for opt in reversed( 67 + [ 68 + click.option( 69 + "-C", 70 + "--qutebrowser-config-dir", 71 + "qb_config_dir", 72 + type=click.Path(file_okay=False, readable=True, path_type=Path), 73 + help="Location of the qutebrowser config to source.", 74 + ), 75 + click.option("-l", "--launch", is_flag=True, help="Launch the profile."), 76 + click.option( 77 + "-f", 78 + "--foreground", 79 + is_flag=True, 80 + help="If --launch is set, run qutebrowser in the foreground.", 81 + ), 82 + click.option( 83 + "--desktop-file/--no-desktop-file", 84 + default=None, 85 + help="Generate an XDG desktop entry for the profile.", 86 + ), 87 + click.option( 88 + "--overwrite", 89 + is_flag=True, 90 + help="Replace the current profile configuration if it exists.", 91 + ), 92 + ] 93 + ): 94 + command = opt(command) 95 + return command 96 + 97 + 98 + class LowerCaseFormatter(logging.Formatter): 99 + def format(self, record: logging.LogRecord) -> str: 100 + record.levelname = record.levelname.lower() 101 + return super().format(record) 102 + 103 + 104 + @click.group(context_settings=CONTEXT_SETTINGS) 105 + @click.version_option() 106 + @click.option( 107 + "-P", 108 + "--profile-dir", 109 + type=click.Path(file_okay=False, writable=True, path_type=Path), 110 + envvar="QBPM_PROFILE_DIR", 111 + show_envvar=False, 112 + default=None, 113 + help="Location to store qutebrowser profiles.", 114 + ) 115 + @click.option( 116 + "-c", 117 + "--config-file", 118 + type=click.Path(dir_okay=False, writable=True, path_type=Path), 119 + help="Location of qbpm config file.", 120 + ) 121 + @click.option( 122 + "-l", 123 + "--log-level", 124 + default="error", 125 + type=click.Choice(["debug", "info", "error"], case_sensitive=False), 126 + ) 127 + @click.pass_context 128 + def main( 129 + ctx: click.Context, 130 + profile_dir: Path | None, 131 + config_file: Path | None, 132 + log_level: str, 133 + ) -> None: 134 + root_logger = logging.getLogger() 135 + root_logger.setLevel(log_level.upper()) 136 + handler = logging.StreamHandler() 137 + handler.setFormatter(LowerCaseFormatter("{levelname}: {message}", style="{")) 138 + root_logger.addHandler(handler) 139 + ctx.obj = Context(profile_dir, config_file) 140 + 141 + 142 + @main.command() 143 + @click.argument("profile_name") 144 + @click.argument("home_page", required=False) 145 + @creator_options 146 + @click.pass_obj 147 + def new( 148 + context: Context, 149 + profile_name: str, 150 + home_page: str | None, 151 + c_opts: CreatorOptions, 152 + ) -> None: 153 + """Create a new profile.""" 154 + config = context.load_config() 155 + profile = Profile(profile_name, config.profile_directory) 156 + if c_opts.qb_config_dir: 157 + config.qutebrowser_config_directory = c_opts.qb_config_dir.absolute() 158 + if c_opts.desktop_file is not None: 159 + config.generate_desktop_file = c_opts.desktop_file 160 + exit_with( 161 + profiles.new_profile( 162 + profile, 163 + config, 164 + home_page, 165 + c_opts.overwrite, 166 + ) 167 + and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground)) 168 + ) 169 + 170 + 171 + @main.command() 172 + @click.argument("session") 173 + @click.argument("profile_name", required=False) 174 + @creator_options 175 + @click.pass_obj 176 + def from_session( 177 + context: Context, 178 + session: str, 179 + profile_name: str | None, 180 + c_opts: CreatorOptions, 181 + ) -> None: 182 + """Create a new profile from a saved qutebrowser session. 183 + 184 + SESSION may be the name of a session in the global qutebrowser profile 185 + or a path to a session yaml file. 186 + """ 187 + config = context.load_config() 188 + if c_opts.qb_config_dir: 189 + config.qutebrowser_config_directory = c_opts.qb_config_dir.absolute() 190 + if c_opts.desktop_file is not None: 191 + config.generate_desktop_file = c_opts.desktop_file 192 + profile = profile_from_session( 193 + session, 194 + profile_name, 195 + config, 196 + c_opts.overwrite, 197 + ) 198 + exit_with( 199 + profile is not None 200 + and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground)) 201 + ) 202 + 203 + 204 + @main.command("launch", context_settings={"ignore_unknown_options": True}) 205 + @click.argument("profile_name") 206 + @click.argument("qb_args", nargs=-1, type=click.UNPROCESSED) 207 + @click.option( 208 + "-f", "--foreground", is_flag=True, help="Run qutebrowser in the foreground." 209 + ) 210 + @click.pass_obj 211 + def launch_profile( 212 + context: Context, profile_name: str, foreground: bool, qb_args: tuple[str, ...] 213 + ) -> None: 214 + """Launch qutebrowser with a specific profile. 215 + 216 + All QB_ARGS are passed on to qutebrowser.""" 217 + profile = Profile(profile_name, context.load_config().profile_directory) 218 + if not profiles.check(profile): 219 + sys.exit(1) 220 + exit_with(launch_qutebrowser(profile, foreground, qb_args)) 221 + 222 + 223 + @main.command(context_settings={"ignore_unknown_options": True}) 224 + @click.argument("qb_args", nargs=-1, type=click.UNPROCESSED) 225 + @click.option( 226 + "-m", 227 + "--menu", 228 + metavar="COMMAND", 229 + help="A dmenu-compatible command or one of the following supported menus: " 230 + + ", ".join([menu.name() for menu in supported_menus()]), 231 + ) 232 + @click.option( 233 + "-f", "--foreground", is_flag=True, help="Run qutebrowser in the foreground." 234 + ) 235 + @click.pass_obj 236 + def choose( 237 + context: Context, menu: str | None, foreground: bool, qb_args: tuple[str, ...] 238 + ) -> None: 239 + """Choose a profile to launch. 240 + 241 + Support is built in for many X and Wayland launchers, as well as applescript dialogs. 242 + All QB_ARGS are passed on to qutebrowser. 243 + """ 244 + config = context.load_config() 245 + exit_with( 246 + choose_profile( 247 + config.profile_directory, 248 + menu or config.menu, 249 + config.menu_prompt, 250 + foreground, 251 + qb_args, 252 + ) 253 + ) 254 + 255 + 256 + @main.command() 257 + @click.argument("profile_name") 258 + @click.pass_obj 259 + def edit(context: Context, profile_name: str) -> None: 260 + """Edit a profile's config.py.""" 261 + profile = Profile(profile_name, context.load_config().profile_directory) 262 + if not profiles.check(profile): 263 + sys.exit(1) 264 + click.edit(filename=str(profile.root / "config" / "config.py")) 265 + 266 + 267 + @main.command(name="list") 268 + @click.pass_obj 269 + def list_(context: Context) -> None: 270 + """List existing profiles.""" 271 + for profile in sorted(context.load_config().profile_directory.iterdir()): 272 + print(profile.name) 273 + 274 + 275 + @main.command() 276 + @click.argument("profile_name") 277 + @click.pass_obj 278 + def desktop( 279 + context: Context, 280 + profile_name: str, 281 + ) -> None: 282 + """Create an XDG desktop entry for an existing profile.""" 283 + config = context.load_config() 284 + profile = Profile(profile_name, config.profile_directory) 285 + exists = profiles.check(profile) 286 + if exists: 287 + create_desktop_file( 288 + profile, config.desktop_file_directory, config.application_name 289 + ) 290 + exit_with(exists) 291 + 292 + 293 + @main.group() 294 + def config() -> None: 295 + """Commands to create a qbpm config file. 296 + 297 + qbpm config default > "$(qbpm config path)" 298 + """ 299 + pass 300 + 301 + 302 + @config.command() 303 + @click.pass_obj 304 + def path(context: Context) -> None: 305 + """Print the location where qbpm will look for a config file.""" 306 + if context.cli_config_file: 307 + print(context.cli_config_file.absolute()) 308 + else: 309 + config_dir = default_qbpm_config_dir() 310 + config_dir.mkdir(parents=True, exist_ok=True) 311 + print(config_dir / "config.toml") 312 + 313 + 314 + @config.command 315 + def default() -> None: 316 + """Print the default qbpm config file.""" 317 + print(DEFAULT_CONFIG_FILE.read_text(), end="") 318 + 319 + 320 + def exit_with(result: bool) -> NoReturn: 321 + sys.exit(0 if result else 1)
+118
src/qbpm/menus.py
··· 1 + import platform 2 + import shlex 3 + import sys 4 + from collections.abc import Iterator 5 + from dataclasses import dataclass, replace 6 + from os import environ 7 + from pathlib import Path 8 + from shutil import which 9 + 10 + from .log import error, or_phrase 11 + 12 + 13 + @dataclass(frozen=True) 14 + class Dmenu: 15 + menu_command: list[str] 16 + 17 + def name(self) -> str: 18 + return self.menu_command[0] 19 + 20 + def installed(self) -> bool: 21 + return which(self.name()) is not None 22 + 23 + def command(self, _profiles: list[str], prompt: str, qb_args: str) -> list[str]: 24 + prompt = prompt.format(qb_args=qb_args) 25 + return [arg.format(prompt=prompt, qb_args=qb_args) for arg in self.menu_command] 26 + 27 + 28 + class ApplescriptMenu: 29 + @classmethod 30 + def name(cls) -> str: 31 + return "applescript" 32 + 33 + @classmethod 34 + def installed(cls) -> bool: 35 + return platform.system() == "Darwin" 36 + 37 + @classmethod 38 + def command(cls, profiles: list[str], _prompt: str, qb_args: str) -> list[str]: 39 + profile_list = '", "'.join(profiles) 40 + return [ 41 + "osascript", 42 + "-e", 43 + f"""set profiles to {{"{profile_list}"}} 44 + set profile to choose from list profiles with prompt "qutebrowser: {qb_args}" default items {{item 1 of profiles}} 45 + item 1 of profile""", 46 + ] 47 + 48 + 49 + def find_menu(menu: str | list[str] | None) -> Dmenu | ApplescriptMenu | None: 50 + if menu: 51 + dmenu = custom_dmenu(menu) 52 + if not dmenu.installed(): 53 + error(f"{dmenu.name()} not found") 54 + return None 55 + return dmenu 56 + menus = list(supported_menus()) 57 + found = next(filter(lambda m: m.installed(), menus), None) 58 + if not found: 59 + error( 60 + "no menu program found, use --menu to provide a dmenu-compatible menu or install one of " 61 + + or_phrase([m.name() for m in menus if isinstance(m, Dmenu)]) 62 + ) 63 + return found 64 + 65 + 66 + def custom_dmenu(command: str | list[str]) -> Dmenu: 67 + split = shlex.split(command) if isinstance(command, str) else command 68 + if len(split) == 1 or not split[1]: 69 + command_path = Path(split[0]) 70 + name = command_path.name 71 + for menu in supported_menus(): 72 + if isinstance(menu, Dmenu) and menu.name() == name: 73 + return ( 74 + menu 75 + if name == split[0] 76 + else replace( 77 + menu, 78 + menu_command=[ 79 + str(command_path.expanduser()), 80 + *menu.menu_command[1::], 81 + ], 82 + ) 83 + ) 84 + return Dmenu(split) 85 + 86 + 87 + def supported_menus() -> Iterator[Dmenu | ApplescriptMenu]: 88 + if ApplescriptMenu.installed(): 89 + yield ApplescriptMenu() 90 + if environ.get("WAYLAND_DISPLAY"): 91 + yield from [ 92 + # default window is too narrow for a long prompt 93 + Dmenu(["fuzzel", "--dmenu"]), 94 + Dmenu(["walker", "--dmenu", "--placeholder", "{prompt}"]), 95 + Dmenu(["wofi", "--dmenu", "--prompt", "{prompt}"]), 96 + Dmenu(["tofi", "--prompt-text", "{prompt}> "]), 97 + Dmenu(["wmenu", "-p", "{prompt}"]), 98 + Dmenu(["dmenu-wl", "--prompt", "{prompt}"]), 99 + ] 100 + if environ.get("DISPLAY"): 101 + yield from [ 102 + Dmenu( 103 + [ 104 + "rofi", 105 + "-dmenu", 106 + "-no-custom", 107 + "-p", 108 + "{prompt}", 109 + "-mesg", 110 + "{qb_args}", 111 + ] 112 + ), 113 + Dmenu(["dmenu", "-p", "{prompt}"]), 114 + ] 115 + if sys.stdin.isatty(): 116 + if environ.get("TMUX"): 117 + yield Dmenu(["fzf-tmux", "--prompt", "{prompt}> "]) 118 + yield Dmenu(["fzf", "--prompt", "{prompt}> "])
+42
src/qbpm/paths.py
··· 1 + import platform 2 + from collections.abc import Iterator 3 + from pathlib import Path 4 + 5 + from click import get_app_dir 6 + from xdg_base_dirs import xdg_config_home, xdg_data_home 7 + 8 + 9 + def qutebrowser_exe() -> str: 10 + macos_app = "/Applications/qutebrowser.app/Contents/MacOS/qutebrowser" 11 + if platform.system() == "Darwin" and Path(macos_app).exists(): 12 + return macos_app 13 + else: 14 + return "qutebrowser" 15 + 16 + 17 + def default_qbpm_config_dir() -> Path: 18 + return xdg_config_home() / "qbpm" 19 + 20 + 21 + def default_qbpm_application_dir() -> Path: 22 + return xdg_data_home() / "applications" / "qbpm" 23 + 24 + 25 + def default_profile_dir() -> Path: 26 + return xdg_data_home() / "qutebrowser-profiles" 27 + 28 + 29 + def qutebrowser_data_dir() -> Path: 30 + if platform.system() == "Linux": 31 + return xdg_data_home() / "qutebrowser" 32 + # TODO confirm this works on windows 33 + return Path(get_app_dir("qutebrowser", roaming=True)) 34 + 35 + 36 + def qutebrowser_config_dirs() -> Iterator[Path]: 37 + app_dir = Path(get_app_dir("qutebrowser", roaming=True)) 38 + yield app_dir 39 + xdg_dir = xdg_config_home() / "qutebrowser" 40 + if xdg_dir != app_dir: 41 + yield xdg_dir 42 + yield Path.home() / ".qutebrowser"
+131
src/qbpm/profiles.py
··· 1 + from functools import partial 2 + from pathlib import Path 3 + 4 + from . import Profile 5 + from .config import Config, find_qutebrowser_config_dir 6 + from .desktop import create_desktop_file 7 + from .log import error, info 8 + 9 + MIME_TYPES = [ 10 + "text/html", 11 + "text/xml", 12 + "application/xhtml+xml", 13 + "application/xml", 14 + "application/rdf+xml", 15 + "image/gif", 16 + "image/jpeg", 17 + "image/png", 18 + "x-scheme-handler/http", 19 + "x-scheme-handler/https", 20 + "x-scheme-handler/qute", 21 + ] 22 + 23 + 24 + def create_profile(profile: Profile, overwrite: bool = False) -> bool: 25 + if not profile.check_name(): 26 + return False 27 + 28 + if not overwrite and profile.root.exists(): 29 + error(f"{profile.root} already exists") 30 + return False 31 + 32 + config_dir = profile.root / "config" 33 + config_dir.mkdir(parents=True, exist_ok=overwrite) 34 + return True 35 + 36 + 37 + def create_config( 38 + profile: Profile, 39 + qb_config_dir: Path, 40 + config_py_template: str, 41 + home_page: str | None = None, 42 + overwrite: bool = False, 43 + ) -> None: 44 + source = qb_config_dir / "config.py" 45 + if not source.is_file(): 46 + return 47 + user_config = profile.root / "config" / "config.py" 48 + if overwrite and user_config.exists(): 49 + back_up(user_config) 50 + with user_config.open(mode="w" if overwrite else "x") as dest_config: 51 + out = partial(print, file=dest_config) 52 + out( 53 + config_py_template.format( 54 + profile_name=profile.name, 55 + source_config_py=source, 56 + ) 57 + ) 58 + # TODO move to template? 59 + if home_page: 60 + out(f"c.url.start_pages = ['{home_page}']") 61 + 62 + 63 + def link_autoconfig( 64 + profile: Profile, 65 + qb_config_dir: Path, 66 + overwrite: bool = False, 67 + ) -> None: 68 + if not hasattr(Path, "symlink_to"): 69 + return 70 + source = qb_config_dir / "autoconfig.yml" 71 + dest = profile.root / "config" / "autoconfig.yml" 72 + if not source.is_file() or dest.resolve() == source.resolve(): 73 + return 74 + if overwrite and dest.exists(): 75 + back_up(dest) 76 + dest.symlink_to(source) 77 + 78 + 79 + def back_up(dest: Path) -> None: 80 + backup = Path(str(dest) + ".bak") 81 + info(f"backing up existing {dest.name} to {backup}") 82 + dest.replace(backup) 83 + 84 + 85 + def check(profile: Profile) -> bool: 86 + if not profile.check_name(): 87 + return False 88 + exists = profile.root.exists() 89 + if not exists: 90 + error(f"{profile.root} does not exist") 91 + return False 92 + if not profile.root.is_dir(): 93 + error(f"{profile.root} is not a directory") 94 + return False 95 + if not (profile.root / "config").is_dir(): 96 + error(f"no config directory in {profile.root}, is it a profile?") 97 + return False 98 + return True 99 + 100 + 101 + def new_profile( 102 + profile: Profile, 103 + config: Config, 104 + home_page: str | None = None, 105 + overwrite: bool = False, 106 + ) -> bool: 107 + qb_config_dir = config.qutebrowser_config_directory 108 + if qb_config_dir and not qb_config_dir.is_dir(): 109 + error(f"{qb_config_dir} is not a directory") 110 + return False 111 + qb_config_dir = find_qutebrowser_config_dir( 112 + qb_config_dir, config.symlink_autoconfig 113 + ) 114 + if not qb_config_dir: 115 + return False 116 + if not config.config_py_template: 117 + error("no value for config_py_template in config.toml") 118 + return False 119 + if create_profile(profile, overwrite): 120 + create_config( 121 + profile, qb_config_dir, config.config_py_template, home_page, overwrite 122 + ) 123 + if config.symlink_autoconfig: 124 + link_autoconfig(profile, qb_config_dir, overwrite) 125 + if config.generate_desktop_file: 126 + create_desktop_file( 127 + profile, config.desktop_file_directory, config.application_name 128 + ) 129 + print(profile.root) 130 + return True 131 + return False
+50
src/qbpm/session.py
··· 1 + import shutil 2 + import sys 3 + from pathlib import Path 4 + 5 + from . import Profile, profiles 6 + from .config import Config 7 + from .log import error, or_phrase 8 + from .paths import qutebrowser_data_dir 9 + 10 + 11 + def profile_from_session( 12 + session: str, 13 + profile_name: str | None, 14 + config: Config, 15 + overwrite: bool = False, 16 + ) -> Profile | None: 17 + profile, session_path = session_info( 18 + session, profile_name, config.profile_directory 19 + ) 20 + if not profiles.new_profile(profile, config, None, overwrite): 21 + return None 22 + 23 + session_dir = profile.root / "data" / "sessions" 24 + session_dir.mkdir(parents=True, exist_ok=overwrite) 25 + shutil.copy(session_path, session_dir / "_autosave.yml") 26 + 27 + return profile 28 + 29 + 30 + def session_info( 31 + session: str, profile_name: str | None, profile_dir: Path 32 + ) -> tuple[Profile, Path]: 33 + user_session_dir = qutebrowser_data_dir() / "sessions" 34 + session_paths = [] 35 + if "/" not in session: 36 + session_paths.append(user_session_dir / (session + ".yml")) 37 + session_paths.append(Path(session)) 38 + session_path = next(filter(lambda path: path.is_file(), session_paths), None) 39 + 40 + if session_path: 41 + return ( 42 + Profile( 43 + profile_name or session_path.stem, 44 + profile_dir, 45 + ), 46 + session_path, 47 + ) 48 + tried = or_phrase([str(p.resolve()) for p in session_paths]) 49 + error(f"could not find session file at {tried}") 50 + sys.exit(1)
+12
tests/__init__.py
··· 1 + from os import environ 2 + from pathlib import Path 3 + 4 + import pytest 5 + 6 + 7 + @pytest.fixture(autouse=True) 8 + def no_homedir_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 9 + environ["XDG_CONFIG_HOME"] = str(tmp_path) 10 + environ["XDG_DATA_HOME"] = str(tmp_path) 11 + monkeypatch.setattr("qbpm.paths.get_app_dir", lambda *_args, **_kwargs: tmp_path) 12 + monkeypatch.setattr("qbpm.paths.Path.home", lambda: tmp_path)
+21
tests/test.desktop
··· 1 + [Desktop Entry] 2 + Name=test (qutebrowser profile) 3 + StartupWMClass=qutebrowser 4 + GenericName=test 5 + Icon=qutebrowser 6 + Type=Application 7 + Categories=Network;WebBrowser; 8 + Exec={qbpm} --untrusted-args %u 9 + Terminal=false 10 + StartupNotify=true 11 + MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute; 12 + Keywords=Browser 13 + Actions=new-window;preferences; 14 + 15 + [Desktop Action new-window] 16 + Name=New Window 17 + Exec={qbpm} 18 + 19 + [Desktop Action preferences] 20 + Name=Preferences 21 + Exec={qbpm} "qute://settings"
+106
tests/test_choose.py
··· 1 + from os import environ 2 + from pathlib import Path 3 + 4 + from qbpm.choose import choose_profile, find_menu 5 + 6 + from . import no_homedir_fixture # noqa: F401 7 + 8 + 9 + def write_script(parent_dir: Path, name: str = "menu", contents: str = "") -> Path: 10 + parent_dir.mkdir(exist_ok=True) 11 + menu = parent_dir / name 12 + menu.write_text(f"#!/bin/sh\n{contents}") 13 + menu.chmod(0o700) 14 + return menu 15 + 16 + 17 + def test_choose(tmp_path: Path): 18 + log = tmp_path / "log" 19 + log.touch() 20 + menu = write_script(tmp_path / "bin", contents=f"cat > {log}\necho p1") 21 + write_script( 22 + tmp_path / "bin", 23 + name="qutebrowser", 24 + contents=f'echo "\nqutebrowser" "$@" >> {log}', 25 + ) 26 + environ["PATH"] = str(tmp_path / "bin") + ":" + environ["PATH"] 27 + 28 + profile_dir = tmp_path / "profiles" 29 + profile_dir.mkdir() 30 + (profile_dir / "p1").mkdir() 31 + (profile_dir / "p2").mkdir() 32 + assert choose_profile(profile_dir, str(menu), "", False, ()) 33 + assert log.read_text().startswith( 34 + f"""p1 35 + p2 36 + qutebrowser 37 + qutebrowser -B {profile_dir / "p1"}""" 38 + ) 39 + 40 + 41 + def test_find_installed_menu(tmp_path: Path): 42 + write_script(tmp_path / "bin", name="dmenu") 43 + environ["PATH"] = str(tmp_path / "bin") 44 + environ["DISPLAY"] = ":1" 45 + dmenu = find_menu(None) 46 + assert dmenu is not None 47 + assert dmenu.name() == "dmenu" 48 + 49 + 50 + def test_override_menu_priority(tmp_path: Path): 51 + write_script(tmp_path / "bin", name="fuzzel") 52 + write_script(tmp_path / "bin", name="dmenu-wl") 53 + environ["PATH"] = str(tmp_path / "bin") 54 + environ["WAYLAND_DISPLAY"] = "wayland-2" 55 + dmenu = find_menu(None) 56 + assert dmenu is not None 57 + assert dmenu.name() == "fuzzel" 58 + dmenu = find_menu("dmenu-wl") 59 + assert dmenu is not None 60 + assert dmenu.name() == "dmenu-wl" 61 + 62 + 63 + def test_custom_menu(): 64 + dmenu = find_menu("/bin/sh -c") 65 + assert dmenu is not None 66 + assert dmenu.command(["p1"], "prompt", "args") == ["/bin/sh", "-c"] 67 + 68 + 69 + def test_invalid_custom_menu(): 70 + assert find_menu("fake_command") is None 71 + 72 + 73 + def test_custom_menu_space_in_name(tmp_path: Path): 74 + write_script(tmp_path / "bin", name="my menu") 75 + environ["PATH"] = str(tmp_path / "bin") 76 + environ["DISPLAY"] = ":1" 77 + dmenu = find_menu("my\\ menu") 78 + assert dmenu is not None 79 + assert dmenu.installed() 80 + 81 + 82 + def test_custom_menu_default_args(tmp_path: Path): 83 + menu = write_script(tmp_path / "bin", name="rofi") 84 + environ["PATH"] = str(tmp_path / "bin") 85 + environ["DISPLAY"] = ":1" 86 + dmenu = find_menu(str(menu)) 87 + assert dmenu is not None 88 + assert [ 89 + str(menu), 90 + "-dmenu", 91 + "-no-custom", 92 + "-p", 93 + "prompt", 94 + "-mesg", 95 + "", 96 + ] == dmenu.command([], "prompt", "") 97 + 98 + 99 + def test_custom_menu_custom_args(tmp_path: Path): 100 + menu = write_script(tmp_path / "bin", name="rofi") 101 + command = f"{menu} -custom -dmenu" 102 + environ["PATH"] = str(tmp_path / "bin") 103 + environ["DISPLAY"] = ":1" 104 + dmenu = find_menu(command) 105 + assert dmenu is not None 106 + assert [str(menu), "-custom", "-dmenu"] == dmenu.command([], "prompt", "")
+85
tests/test_config.py
··· 1 + from pathlib import Path 2 + 3 + from qbpm.config import ( 4 + DEFAULT_CONFIG_FILE, 5 + Config, 6 + find_config, 7 + find_qutebrowser_config_dir, 8 + ) 9 + 10 + from . import no_homedir_fixture # noqa: F401 11 + 12 + 13 + def test_no_config(): 14 + assert find_config(None) == Config.load(DEFAULT_CONFIG_FILE) 15 + 16 + 17 + def test_empty_config(tmp_path: Path): 18 + file = tmp_path / "config.toml" 19 + file.touch() 20 + assert find_config(file) == Config() 21 + 22 + 23 + def test_default_config_location(tmp_path: Path): 24 + (tmp_path / "qbpm").mkdir() 25 + (tmp_path / "qbpm" / "config.toml").touch() 26 + assert find_config(None) == Config() 27 + 28 + 29 + def test_minimal_config(tmp_path: Path): 30 + file = tmp_path / "config.toml" 31 + file.write_text("""config_py_template = 'template'""") 32 + assert find_config(file) == Config(config_py_template="template") 33 + 34 + 35 + def test_full_config(tmp_path: Path): 36 + file = tmp_path / "config.toml" 37 + file.write_text(""" 38 + config_py_template = \""" 39 + config.load_autoconfig() 40 + \""" 41 + symlink_autoconfig = true 42 + qutebrowser_config_directory = "~/.config/qutebrowser" 43 + profile_directory = "profile" 44 + generate_desktop_file = false 45 + desktop_file_directory = "desktop" 46 + menu = "~/bin/my-dmenu" 47 + menu_prompt = "qbpm" 48 + """) 49 + assert find_config(file) == Config( 50 + config_py_template="config.load_autoconfig()\n", 51 + symlink_autoconfig=True, 52 + qutebrowser_config_directory=Path("~/.config/qutebrowser").expanduser(), 53 + profile_directory=Path("profile"), 54 + desktop_file_directory=Path("desktop"), 55 + generate_desktop_file=False, 56 + menu="~/bin/my-dmenu", 57 + menu_prompt="qbpm", 58 + ) 59 + 60 + 61 + def test_find_qb_config(tmp_path: Path): 62 + qb_dir = tmp_path / "qb" 63 + qb_conf_dir = qb_dir / "config" 64 + qb_conf_dir.mkdir(parents=True) 65 + (qb_conf_dir / "config.py").touch() 66 + assert find_qutebrowser_config_dir(qb_dir) == qb_conf_dir 67 + assert find_qutebrowser_config_dir(qb_dir / "config") == qb_conf_dir 68 + 69 + 70 + def test_find_autoconfig(tmp_path: Path): 71 + qb_dir = tmp_path / "qb" 72 + qb_conf_dir = qb_dir / "config" 73 + qb_conf_dir.mkdir(parents=True) 74 + (qb_conf_dir / "autoconfig.yml").touch() 75 + assert find_qutebrowser_config_dir(qb_dir, autoconfig=True) == qb_conf_dir 76 + 77 + 78 + def test_find_qb_config_default(tmp_path: Path): 79 + (tmp_path / "config.py").touch() 80 + assert find_qutebrowser_config_dir(None) == tmp_path 81 + 82 + 83 + def test_find_qutebrowser_none(tmp_path: Path): 84 + assert find_qutebrowser_config_dir(None) is None 85 + assert find_qutebrowser_config_dir(tmp_path / "config") is None
+25
tests/test_desktop.py
··· 1 + from pathlib import Path 2 + 3 + from qbpm import Profile 4 + from qbpm.config import Config 5 + from qbpm.desktop import create_desktop_file 6 + 7 + TEST_DIR = Path(__file__).resolve().parent 8 + 9 + 10 + def test_create_desktop_file(tmp_path: Path): 11 + application_path = tmp_path / "applications" 12 + application_path.mkdir() 13 + profile = Profile("test", tmp_path) 14 + create_desktop_file(profile, application_path, Config.load(None).application_name) 15 + assert (application_path / "test.desktop").read_text() == ( 16 + TEST_DIR / "test.desktop" 17 + ).read_text().replace("{qbpm}", " ".join(profile.cmdline())) 18 + 19 + 20 + def test_custom_name(tmp_path: Path): 21 + application_path = tmp_path / "applications" 22 + application_path.mkdir() 23 + profile = Profile("test", tmp_path) 24 + create_desktop_file(profile, application_path, "test") 25 + assert "Name=test\n" in (application_path / "test.desktop").read_text()
+92 -9
tests/test_main.py
··· 1 - from os import environ 1 + from os import chdir, environ 2 2 from pathlib import Path 3 + 4 + from click.testing import CliRunner 3 5 4 6 from qbpm.main import main 5 7 8 + from . import no_homedir_fixture # noqa: F401 9 + 10 + 11 + def run(*args: str): 12 + return CliRunner().invoke(main, args) 13 + 6 14 7 15 def test_profile_dir_option(tmp_path: Path): 8 - main(["-P", str(tmp_path), "new", "test"]) 9 - assert list(tmp_path.iterdir()) == [tmp_path / "test"] 16 + (tmp_path / "config.py").touch() 17 + result = run("-P", str(tmp_path), "new", "-C", str(tmp_path), "test") 18 + assert result.exit_code == 0 19 + assert result.output.strip() == str(tmp_path / "test") 20 + assert tmp_path / "test" in list(tmp_path.iterdir()) 21 + assert (tmp_path / "applications" / "qbpm" / "test.desktop").exists() 10 22 11 23 12 24 def test_profile_dir_env(tmp_path: Path): 13 25 environ["QBPM_PROFILE_DIR"] = str(tmp_path) 14 - main(["new", "test"]) 15 - assert list(tmp_path.iterdir()) == [tmp_path / "test"] 26 + (tmp_path / "config.py").touch() 27 + result = run("new", "-C", str(tmp_path), "test") 28 + assert result.exit_code == 0 29 + assert result.output.strip() == str(tmp_path / "test") 30 + assert tmp_path / "test" in list(tmp_path.iterdir()) 31 + 32 + 33 + def test_config_dir_option(tmp_path: Path): 34 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 35 + config = tmp_path / "config.py" 36 + config.touch() 37 + result = run("new", "-C", str(tmp_path), "test") 38 + assert result.exit_code == 0 39 + assert str(config) in (tmp_path / "test/config/config.py").read_text() 40 + 41 + 42 + def test_relative_config_dir(tmp_path: Path): 43 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 44 + config = tmp_path / "config.py" 45 + config.touch() 46 + chdir(tmp_path) 47 + result = run("new", "-C", ".", "test") 48 + assert result.exit_code == 0 49 + assert str(config) in (tmp_path / "test/config/config.py").read_text() 16 50 17 51 18 - def test_from_session(tmp_path: Path): 52 + def test_from_session_path(tmp_path: Path): 19 53 environ["QBPM_PROFILE_DIR"] = str(tmp_path) 54 + (tmp_path / "config.py").touch() 20 55 session = tmp_path / "test.yml" 21 - session.touch() 22 - main(["from-session", str(session)]) 23 - assert set(tmp_path.iterdir()) == {session, tmp_path / "test"} 56 + session.write_text("windows:\n") 57 + result = run("from-session", "-C", str(tmp_path), str(session)) 58 + assert result.exit_code == 0 59 + assert result.output.strip() == str(tmp_path / "test") 60 + assert (tmp_path / "test/data/sessions/_autosave.yml").read_text() == ("windows:\n") 61 + 62 + 63 + def test_from_session_name(tmp_path: Path): 64 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 65 + (tmp_path / "config.py").touch() 66 + environ["XDG_DATA_HOME"] = str(tmp_path) 67 + (tmp_path / "qutebrowser" / "sessions").mkdir(parents=True) 68 + (tmp_path / "qutebrowser" / "sessions" / "test.yml").write_text("windows:\n") 69 + result = run("from-session", "-C", str(tmp_path), "test") 70 + assert result.exit_code == 0 71 + assert result.output.strip() == str(tmp_path / "test") 72 + assert (tmp_path / "test/data/sessions/_autosave.yml").read_text() == ("windows:\n") 73 + 74 + 75 + def test_config_file(tmp_path: Path): 76 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 77 + (tmp_path / "config.py").touch() 78 + config_file = tmp_path / "config.toml" 79 + config_file.write_text("config_py_template = '# Custom template {profile_name}'") 80 + result = run("-c", str(config_file), "new", "test") 81 + assert result.exit_code == 0 82 + profile_config = tmp_path / "test" / "config" / "config.py" 83 + assert "# Custom template test" in profile_config.read_text() 84 + 85 + 86 + def test_bad_config_file(): 87 + result = run("-c", "/nonexistent/config.toml", "list") 88 + assert result.exit_code == 1 89 + assert "not a file" in result.output 90 + 91 + 92 + def test_no_desktop_file(tmp_path: Path): 93 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 94 + (tmp_path / "config.py").touch() 95 + run("-P", str(tmp_path), "new", "--no-desktop-file", "-C", str(tmp_path), "test") 96 + assert not (tmp_path / "applications" / "qbpm" / "test.desktop").exists() 97 + 98 + 99 + def test_desktop_file_directory(tmp_path: Path): 100 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 101 + (tmp_path / "config.py").touch() 102 + config_file = tmp_path / "config.toml" 103 + config_file.write_text(f'''config_py_template = "" 104 + desktop_file_directory="{tmp_path}"''') 105 + run("-P", str(tmp_path), "new", "-C", str(tmp_path), "test") 106 + assert not (tmp_path / "test.desktop").exists()
+19
tests/test_menu.py
··· 1 + from qbpm.menus import Dmenu, custom_dmenu 2 + 3 + 4 + def test_menu_prompt_formatting(): 5 + dmenu = Dmenu(["my-menu", "--prompt", "{prompt}"]) 6 + cmd = dmenu.command(["p1"], "qb ({qb_args})", "example.com") 7 + assert "--prompt" in cmd 8 + assert "qb (example.com)" in cmd 9 + 10 + 11 + def test_known_custom_menu(): 12 + assert custom_dmenu(["fuzzel"]).menu_command == ["fuzzel", "--dmenu"] 13 + assert custom_dmenu("fuzzel").menu_command == ["fuzzel", "--dmenu"] 14 + assert "--dmenu" in custom_dmenu("~/bin/fuzzel").menu_command 15 + 16 + 17 + def test_custom_menu_list(): 18 + menu = ["fuzzel", "--dmenu", "--prompt", "{prompt}>"] 19 + assert custom_dmenu(menu).menu_command == menu
+95 -30
tests/test_profiles.py
··· 1 1 from pathlib import Path 2 - from typing import Optional 3 2 4 3 from qbpm import profiles 4 + from qbpm.config import Config 5 5 from qbpm.profiles import Profile 6 6 7 + from . import no_homedir_fixture # noqa: F401 8 + 7 9 8 10 def check_is_empty(path: Path): 9 11 assert len(list(path.iterdir())) == 0 10 12 11 13 12 - def check_empty_profile(profile: Optional[Profile]): 14 + def check_empty_profile(profile: Profile | None): 13 15 assert profile 14 16 config_dir = profile.root / "config" 15 17 assert list(profile.root.iterdir()) == [config_dir] ··· 52 54 53 55 54 56 def test_create_config(tmp_path: Path): 57 + (tmp_path / "config.py").touch() 55 58 profile = Profile("test", tmp_path) 56 59 config_dir = profile.root / "config" 57 60 config_dir.mkdir(parents=True) 58 - profiles.create_config(profile) 59 - assert list(config_dir.iterdir()) == [config_dir / "config.py"] 61 + profiles.create_config(profile, tmp_path, "{source_config_py}") 62 + config = config_dir / "config.py" 63 + assert list(config_dir.iterdir()) == [config] 64 + assert str(tmp_path / "config.py") in config.read_text() 60 65 61 66 62 67 def test_overwrite_config(tmp_path: Path): 68 + (tmp_path / "config.py").touch() 63 69 profile = Profile("test", tmp_path) 64 70 url = "http://example.com" 65 71 config_dir = profile.root / "config" 66 72 config_dir.mkdir(parents=True) 67 - profiles.create_config(profile) 68 - profiles.create_config(profile, url, True) 69 - assert list(config_dir.iterdir()) == [config_dir / "config.py"] 70 - with open(config_dir / "config.py") as conf: 71 - for line in conf: 72 - if url in line: 73 - return 74 - assert False 73 + config = config_dir / "config.py" 74 + backup = config_dir / "config.py.bak" 75 + profiles.create_config(profile, tmp_path, "") 76 + profiles.create_config(profile, tmp_path, "", url, True) 77 + assert set(config_dir.iterdir()) == {config, backup} 78 + assert url in config.read_text() 79 + assert url not in backup.read_text() 75 80 76 81 77 - def test_ensure_profile_exists_exists(tmp_path: Path): 82 + def test_link_autoconfig(tmp_path: Path): 78 83 profile = Profile("test", tmp_path) 79 - profile.root.mkdir() 80 - assert profiles.ensure_profile_exists(profile, False) 81 - assert profiles.ensure_profile_exists(profile, True) 82 - check_is_empty(profile.root) 84 + config_dir = profile.root / "config" 85 + config_dir.mkdir(parents=True) 86 + (tmp_path / "autoconfig.yml").touch() 87 + profiles.link_autoconfig(profile, tmp_path, False) 88 + config = config_dir / "autoconfig.yml" 89 + assert list(config_dir.iterdir()) == [config] 90 + assert config.resolve().parent == tmp_path 83 91 84 92 85 - def test_ensure_profile_exists_does_not_exist(tmp_path: Path): 86 - assert not profiles.ensure_profile_exists(Profile("test", tmp_path), False) 87 - check_is_empty(tmp_path) 93 + def test_autoconfig_present(tmp_path: Path): 94 + profile = Profile("test", tmp_path) 95 + config_dir = profile.root / "config" 96 + config_dir.mkdir(parents=True) 97 + (tmp_path / "autoconfig.yml").touch() 98 + profiles.link_autoconfig(profile, tmp_path, False) 99 + profiles.link_autoconfig(profile, tmp_path, False) 100 + config = config_dir / "autoconfig.yml" 101 + assert list(config_dir.iterdir()) == [config] 102 + assert config.resolve().parent == tmp_path 88 103 89 104 90 - def test_ensure_profile_exists_not_dir(tmp_path: Path): 105 + def test_overwrite_autoconfig(tmp_path: Path): 91 106 profile = Profile("test", tmp_path) 92 - profile.root.touch() 93 - assert not profiles.ensure_profile_exists(profile, False) 94 - assert not profiles.ensure_profile_exists(profile, True) 107 + config_dir = profile.root / "config" 108 + config_dir.mkdir(parents=True) 109 + (config_dir / "autoconfig.yml").touch() 110 + (tmp_path / "autoconfig.yml").touch() 111 + profiles.link_autoconfig(profile, tmp_path, True) 112 + config = config_dir / "autoconfig.yml" 113 + assert set(config_dir.iterdir()) == {config, config_dir / "autoconfig.yml.bak"} 114 + assert config.resolve().parent == tmp_path 95 115 96 116 97 - def test_ensure_profile_exists_create(tmp_path: Path): 98 - profile = Profile("test", tmp_path) 99 - assert profiles.ensure_profile_exists(profile, True) 117 + def test_new_profile(tmp_path: Path): 118 + (tmp_path / "config.py").touch() 119 + profile = Profile("test", tmp_path / "test") 120 + config = Config.load(None) 121 + config.qutebrowser_config_directory = tmp_path 122 + config.generate_desktop_file = False 123 + assert profiles.new_profile(profile, config) 100 124 check_new_profile(profile) 101 125 102 126 103 - def test_new_profile(tmp_path: Path): 127 + def test_new_profile_autoconfig(tmp_path: Path): 128 + (tmp_path / "autoconfig.yml").touch() 129 + profile = Profile("test", tmp_path / "test") 130 + config = Config.load(None) 131 + config.qutebrowser_config_directory = tmp_path 132 + config.generate_desktop_file = False 133 + config.symlink_autoconfig = True 134 + profiles.new_profile(profile, config) 135 + config_dir = profile.root / "config" 136 + assert set(config_dir.iterdir()) == {config_dir / "autoconfig.yml"} 137 + 138 + 139 + def test_new_profile_both(tmp_path: Path): 140 + (tmp_path / "config.py").touch() 141 + (tmp_path / "autoconfig.yml").touch() 142 + profile = Profile("test", tmp_path / "test") 143 + config = Config.load(None) 144 + config.qutebrowser_config_directory = tmp_path 145 + config.generate_desktop_file = False 146 + config.symlink_autoconfig = True 147 + profiles.new_profile(profile, config) 148 + assert len(set((profile.root / "config").iterdir())) == 2 # noqa: PLR2004 149 + 150 + 151 + def test_config_template(tmp_path: Path): 152 + (tmp_path / "config.py").touch() 104 153 profile = Profile("test", tmp_path) 105 - assert profiles.new_profile(profile) 106 - check_new_profile(profile) 154 + config_dir = profile.root / "config" 155 + config_dir.mkdir(parents=True) 156 + template = "# Profile: {profile_name}\nconfig.source('{source_config_py}')" 157 + profiles.create_profile(profile) 158 + profiles.create_config(profile, tmp_path, template) 159 + config_content = (profile.root / "config" / "config.py").read_text() 160 + assert "# Profile: test" in config_content 161 + assert f"config.source('{tmp_path / 'config.py'}')" in config_content 162 + 163 + 164 + def test_missing_qb_config(tmp_path: Path): 165 + profile = Profile("test", tmp_path / "test") 166 + config = Config.load(None) 167 + config.qutebrowser_config_directory = tmp_path 168 + config.generate_desktop_file = False 169 + assert not profiles.new_profile(profile, config) 170 + config.qutebrowser_config_directory = tmp_path / "nonexistent" 171 + assert not profiles.new_profile(profile, config)