qutebrowser profile manager

Compare changes

Choose any two refs to compare.

-15
.build.yml
··· 1 - image: nixos/unstable 2 - repositories: 3 - nixpkgs: https://nixos.org/channels/nixos-unstable 4 - sources: 5 - - https://git.sr.ht/~pvsr/qpm 6 - tasks: 7 - - py38: | 8 - cd qpm 9 - nix-shell --argstr python python38 --run pytest 10 - - py37: | 11 - cd qpm 12 - nix-shell --argstr python python37 --run pytest 13 - - py36: | 14 - cd qpm 15 - nix-shell --argstr python python36 --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
+9 -1
.gitignore
··· 6 6 lib/ 7 7 build/ 8 8 dist/ 9 - qpm.egg-info/ 9 + result 10 + qbpm.egg-info/ 10 11 .tox/ 12 + qbpm/version.py 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
+58 -62
README.md
··· 1 - # `qpm`: qutebrowser profile manager 1 + # qutebrowser profile manager 2 2 3 - [![builds.sr.ht status](https://builds.sr.ht/~pvsr/qpm.svg)](https://builds.sr.ht/~pvsr/qpm?) 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](https://github.com/qutebrowser/qutebrowser) is a web browser with 6 - vim-like keybindings. It's great! qpm is a small tool for creating qutebrowser 7 - "profiles", directories you can tell qutebrowser to store its config and data in 8 - using the `--basedir` flag. You can use qpm to create profiles that share 9 - config with your standard qutebrowser installation and run them using the 10 - `launch` subcommand, which wraps qutebrowser and points `--basedir` at your 11 - profile directory. qutebrowser sessions started with different base directories 12 - are entirely separate, have their own histories and sessions, and can be opened 13 - and closed independently. They're very useful! 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 9 + which allows qutebrowser to use any directory as the location of its config and 10 + data and effectively act as a profile. qbpm creates profiles that source your 11 + main qutebrowser `config.py`, but have their own separate `autoconfig.yml`, bookmarks, cookies, 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 - ## Use cases 16 - - Use a "work" profile to isolate your work logins from your personal ones. 17 - Especially important if you have a work account on Google or Github! 18 - - Project-based profiles. I have a "qpm" profile which has library 19 - documentation, qutebrowser config, CI results, issues and PRs, and everything 20 - I need to work on qpm. 21 - - Because web browsers are hideous monstrosities, qutebrowser leaks a little 22 - bit of memory. If you leave it open 24/7 that can become a lot. I use 23 - profiles both to organize my browsing and to keep my number of open tabs 24 - down, especially on machines with less memory. Since profiles open and close 25 - very quickly and keep a persisent sesion, I can open sets of tabs when I need 26 - them and close them when I don't, knowing I won't lose them. 15 + qutebrowser shares session depending on the basedir, so launching the same 16 + profile twice will result in two windows sharing a session, which means running 17 + `:quit` in one will exit both and launching the profile again will reopen both 18 + windows. But launching two distinct profiles will start two entirely separate 19 + instances of qutebrowser which can be opened and closed independently. 27 20 28 21 ## Usage 22 + To create a new profile called "python" and launch it with the python docs open: 29 23 ``` 30 - # create and launch a new profile called "finance" in $XDG_DATA_HOME/qutebrowser-profiles: 31 - $ qpm new finance --launch 32 - # or 33 - $ qpm launch --new finance 24 + $ qbpm new python 25 + $ qbpm launch python docs.python.org 26 + ``` 34 27 35 - # convert the contents of a window into a new profile: 36 - # in qutebrowser, run: "session-save -o profile-name" 37 - $ qpm from-session profile-name 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`. 38 30 39 - # you can store profiles anywhere: 40 - $ qpm --profile-dir ~/dev/my-project new project-info 41 - $ cd ~/dev/my-project 42 - $ qpm --profile-dir . launch project-info 43 - # or 44 - $ qutebrowser --basedir profile-name 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. 45 37 46 - # launch passes arguments it doesn't recognize to qutebrowser: 47 - $ qpm launch python docs.python.org --target window --loglevel info 48 - # is functionally equivalent to: 49 - $ qutebrowser --basedir $XDG_DATA_HOME/qutebrowser-profiles/python docs.python.org --target window --loglevel info 50 - ``` 38 + Run `qbpm --help` to see other available commands. 51 39 52 - ## Disclaimer 53 - This is alpha-quality software. Even though it doesn't do anything particularly 54 - dangerous to the filesystem, there is always the risk that it will mangle your 55 - data. 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. 56 44 57 - ## Future work 58 - - More shared config and data (configurable) 59 - - Generated binaries and `.desktop` files 60 - - Delete profiles? 61 - - Use any profile as a base for new profiles (currently only the main config in 62 - `$XDG_CONFIG_HOME` is supported) 63 - - Source `autoconfig.yml` instead of `config.py` 64 - - Customizable config sourcing for those who like to split their config into 65 - multiple files 66 - - Bundled config file optimized for single-site browsing 67 - - `qpm.conf` to configure the features above 68 - - Someday: qutebrowser plugin 45 + ## Installation 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`. 69 48 70 - ## Known limitations 71 - - If your config relies on `config.configdir` to dynamically source other config 72 - files (I may be the only person who does this), those config files will not be 73 - present in `qpm`-created profiles There are plenty of workarounds, such as 74 - hardcoding your main config dir instead of using `config.configdir`. 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.
+34
completions/qbpm.fish
··· 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 10 + end 11 + 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) 14 + 15 + complete -c qbpm -f 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)" 34 + complete -c qbpm -n "__fish_seen_subcommand_from from-session" -a "(ls $data_home/qutebrowser/sessions | xargs basename -a -s .yml)"
+44
contrib/PKGBUILD
··· 1 + # Maintainer: Peter Rice <{first name}@peterrice.xyz> 2 + 3 + pkgname=qbpm-git 4 + pkgver=2.0.r5 5 + pkgrel=1 6 + pkgdesc="A profile manager for qutebrowser" 7 + url="https://github.com/pvsr/qbpm" 8 + license=('GPL-3.0-or-later') 9 + sha512sums=('SKIP') 10 + arch=('any') 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 + provides=('qbpm') 14 + source=("git+https://github.com/pvsr/qbpm") 15 + 16 + pkgver() { 17 + cd qbpm 18 + git describe --long --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g' 19 + } 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 + 30 + package() { 31 + cd qbpm 32 + install -D -m644 completions/qbpm.fish ${pkgdir}/usr/share/fish/vendor_completions.d/qbpm.fish 33 + install -D -m644 LICENSE ${pkgdir}/usr/share/licenses/qbpm/LICENSE 34 + 35 + scdoc < qbpm.1.scd > qbpm.1 36 + install -D -m644 qbpm.1 ${pkgdir}/usr/share/man/man1/qbpm.1 37 + 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 44 + }
+3
contrib/qbpm-choose
··· 1 + #!/bin/sh 2 + 3 + qbpm choose "$@"
+10
contrib/qbpm.desktop
··· 1 + [Desktop Entry] 2 + Name=qbpm 3 + Icon=qutebrowser 4 + Type=Application 5 + Categories=Network;WebBrowser; 6 + Exec=qbpm choose --untrusted-args %u 7 + Terminal=False 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
+261
contrib/qbpm.platypus
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>AcceptsFiles</key> 6 + <true/> 7 + <key>AcceptsText</key> 8 + <false/> 9 + <key>Authentication</key> 10 + <false/> 11 + <key>Author</key> 12 + <string>Peter Rice</string> 13 + <key>BundledFiles</key> 14 + <array/> 15 + <key>Creator</key> 16 + <string>Platypus-5.3</string> 17 + <key>DeclareService</key> 18 + <false/> 19 + <key>Destination</key> 20 + <string>/Applications/qbpm.app</string> 21 + <key>DevelopmentVersion</key> 22 + <false/> 23 + <key>DocIconPath</key> 24 + <string></string> 25 + <key>Droppable</key> 26 + <true/> 27 + <key>ExecutablePath</key> 28 + <string>/usr/local/share/platypus/ScriptExec</string> 29 + <key>Identifier</key> 30 + <string>org.qbpm.qbpm</string> 31 + <key>InterfaceType</key> 32 + <string>None</string> 33 + <key>InterpreterArgs</key> 34 + <array> 35 + <string>-l</string> 36 + </array> 37 + <key>InterpreterPath</key> 38 + <string>/bin/bash</string> 39 + <key>Name</key> 40 + <string>qbpm</string> 41 + <key>NibPath</key> 42 + <string>/usr/local/share/platypus/MainMenu.nib</string> 43 + <key>OptimizeApplication</key> 44 + <true/> 45 + <key>Overwrite</key> 46 + <false/> 47 + <key>PromptForFileOnLaunch</key> 48 + <false/> 49 + <key>RemainRunning</key> 50 + <false/> 51 + <key>RunInBackground</key> 52 + <false/> 53 + <key>ScriptArgs</key> 54 + <array/> 55 + <key>ScriptPath</key> 56 + <string>./contrib/qbpm-choose</string> 57 + <key>StatusItemDisplayType</key> 58 + <string>Text</string> 59 + <key>StatusItemIcon</key> 60 + <data> 61 + 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 146 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 147 + AAAAAAAAAABtbHVjAAAAAAAAAB8AAAAMc2tTSwAAAC4AAAGEZGFESwAAADoAAAGyY2FF 148 + UwAAADgAAAHsdmlWTgAAAEAAAAIkcHRCUgAAAEoAAAJkdWtVQQAAACwAAAKuZnJGVQAA 149 + AD4AAALaaHVIVQAAADQAAAMYemhUVwAAABoAAANMa29LUgAAACIAAANmbmJOTwAAADoA 150 + AAOIY3NDWgAAACgAAAPCaGVJTAAAACQAAAPqcm9STwAAACoAAAQOZGVERQAAAE4AAAQ4 151 + aXRJVAAAAE4AAASGc3ZTRQAAADgAAATUemhDTgAAABoAAAUMamFKUAAAACYAAAUmZWxH 152 + UgAAACoAAAVMcHRQTwAAAFIAAAV2bmxOTAAAAEAAAAXIZXNFUwAAAEwAAAYIdGhUSAAA 153 + ADIAAAZUdHJUUgAAACQAAAaGZmlGSQAAAEYAAAaqaHJIUgAAAD4AAAbwcGxQTAAAAEoA 154 + AAcuYXJFRwAAACwAAAd4cnVSVQAAADoAAAekZW5VUwAAADwAAAfeAFYBYQBlAG8AYgBl 155 + AGMAbgDhACAAcwBpAHYA4QAgAGcAYQBtAGEAIAAyACwAMgBHAGUAbgBlAHIAaQBzAGsA 156 + IABnAHIA5QAgADIALAAyACAAZwBhAG0AbQBhAC0AcAByAG8AZgBpAGwARwBhAG0AbQBh 157 + ACAAZABlACAAZwByAGkAcwBvAHMAIABnAGUAbgDoAHIAaQBjAGEAIAAyAC4AMgBDHqUA 158 + dQAgAGgA7ABuAGgAIABNAOAAdQAgAHgA4QBtACAAQwBoAHUAbgBnACAARwBhAG0AbQBh 159 + ACAAMgAuADIAUABlAHIAZgBpAGwAIABHAGUAbgDpAHIAaQBjAG8AIABkAGEAIABHAGEA 160 + bQBhACAAZABlACAAQwBpAG4AegBhAHMAIAAyACwAMgQXBDAEMwQwBDsETAQ9BDAAIABH 161 + AHIAYQB5AC0EMwQwBDwEMAAgADIALgAyAFAAcgBvAGYAaQBsACAAZwDpAG4A6QByAGkA 162 + cQB1AGUAIABnAHIAaQBzACAAZwBhAG0AbQBhACAAMgAsADIAwQBsAHQAYQBsAOEAbgBv 163 + AHMAIABzAHoA/AByAGsAZQAgAGcAYQBtAG0AYQAgADIALgAykBp1KHBwlo5RSV6mADIA 164 + LgAygnJfaWPPj/DHfLwYACDWjMDJACCsELnIACAAMgAuADIAINUEuFzTDMd8AEcAZQBu 165 + AGUAcgBpAHMAawAgAGcAcgDlACAAZwBhAG0AbQBhACAAMgAsADIALQBwAHIAbwBmAGkA 166 + bABPAGIAZQBjAG4A4QAgAWEAZQBkAOEAIABnAGEAbQBhACAAMgAuADIF0gXQBd4F1AAg 167 + BdAF5AXVBegAIAXbBdwF3AXZACAAMgAuADIARwBhAG0AYQAgAGcAcgBpACAAZwBlAG4A 168 + ZQByAGkAYwEDACAAMgAsADIAQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAARwByAGEAdQBz 169 + AHQAdQBmAGUAbgAtAFAAcgBvAGYAaQBsACAARwBhAG0AbQBhACAAMgAsADIAUAByAG8A 170 + ZgBpAGwAbwAgAGcAcgBpAGcAaQBvACAAZwBlAG4AZQByAGkAYwBvACAAZABlAGwAbABh 171 + ACAAZwBhAG0AbQBhACAAMgAsADIARwBlAG4AZQByAGkAcwBrACAAZwByAOUAIAAyACwA 172 + MgAgAGcAYQBtAG0AYQBwAHIAbwBmAGkAbGZukBpwcF6mfPtlcAAyAC4AMmPPj/Blh072 173 + TgCCLDCwMOwwpDCsMPMw3gAgADIALgAyACAw1zDtMNUwoTCkMOsDkwO1A70DuQO6A8wA 174 + IAOTA7oDwQO5ACADkwOsA7wDvAOxACAAMgAuADIAUABlAHIAZgBpAGwAIABnAGUAbgDp 175 + AHIAaQBjAG8AIABkAGUAIABjAGkAbgB6AGUAbgB0AG8AcwAgAGQAYQAgAEcAYQBtAG0A 176 + YQAgADIALAAyAEEAbABnAGUAbQBlAGUAbgAgAGcAcgBpAGoAcwAgAGcAYQBtAG0AYQAg 177 + ADIALAAyAC0AcAByAG8AZgBpAGUAbABQAGUAcgBmAGkAbAAgAGcAZQBuAOkAcgBpAGMA 178 + bwAgAGQAZQAgAGcAYQBtAG0AYQAgAGQAZQAgAGcAcgBpAHMAZQBzACAAMgAsADIOIw4x 179 + DgcOKg41DkEOAQ4hDiEOMg5ADgEOIw4iDkwOFw4xDkgOJw5EDhsAIAAyAC4AMgBHAGUA 180 + bgBlAGwAIABHAHIAaQAgAEcAYQBtAGEAIAAyACwAMgBZAGwAZQBpAG4AZQBuACAAaABh 181 + AHIAbQBhAGEAbgAgAGcAYQBtAG0AYQAgADIALAAyACAALQBwAHIAbwBmAGkAaQBsAGkA 182 + RwBlAG4AZQByAGkBDQBrAGkAIABHAHIAYQB5ACAARwBhAG0AbQBhACAAMgAuADIAIABw 183 + AHIAbwBmAGkAbABVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBmAGkAbAAgAHMA 184 + egBhAHIAbwFbAGMAaQAgAGcAYQBtAG0AYQAgADIALAAyBjoGJwZFBicAIAAyAC4AMgAg 185 + BkQGSAZGACAGMQZFBicGLwZKACAGOQYnBkUEHgQxBEkEMARPACAEQQQ1BEAEMARPACAE 186 + MwQwBDwEPAQwACAAMgAsADIALQQ/BEAEPgREBDgEOwRMAEcAZQBuAGUAcgBpAGMAIABH 187 + AHIAYQB5ACAARwBhAG0AbQBhACAAMgAuADIAIABQAHIAbwBmAGkAbABlAAB0ZXh0AAAA 188 + AENvcHlyaWdodCBBcHBsZSBJbmMuLCAyMDEyAABYWVogAAAAAAAA81EAAQAAAAEWzGN1 189 + cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4A 190 + YwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDg 191 + AOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwB 192 + gwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJU 193 + Al0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oD 194 + ZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSo 195 + BLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicG 196 + NwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4 197 + CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsK 198 + EQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxc 199 + DHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4P 200 + CQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHo 201 + EgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIV 202 + NBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihiv 203 + GNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHsc 204 + oxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDE 205 + IPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTgl 206 + aCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1 207 + KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ov 208 + kS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUT 209 + NU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87 210 + LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFq 211 + QaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVI 212 + S0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9J 213 + T5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW 214 + 91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69 215 + Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhn 216 + PWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/R 217 + cCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5 218 + KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKS 219 + gvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOM 220 + yo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cK 221 + l3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobai 222 + JqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1E 223 + rbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5 224 + SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVL 225 + xcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7S 226 + P9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p 227 + 36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77Ibt 228 + Ee2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn 229 + +3f8B/yY/Sn9uv5L/tz/bf// 230 + </data> 231 + <key>StatusItemIconIsTemplate</key> 232 + <true/> 233 + <key>StatusItemTitle</key> 234 + <string>Title</string> 235 + <key>StatusItemUseSystemFont</key> 236 + <true/> 237 + <key>Suffixes</key> 238 + <array/> 239 + <key>TextBackground</key> 240 + <string>#ffffff</string> 241 + <key>TextFont</key> 242 + <string>Monaco</string> 243 + <key>TextForeground</key> 244 + <string>#000000</string> 245 + <key>TextSize</key> 246 + <real>13</real> 247 + <key>URISchemes</key> 248 + <array> 249 + <string>http</string> 250 + <string>https</string> 251 + <string>file</string> 252 + </array> 253 + <key>UniformTypes</key> 254 + <array> 255 + <string>public.html</string> 256 + <string>public.xhtml</string> 257 + </array> 258 + <key>Version</key> 259 + <string>1.0</string> 260 + </dict> 261 + </plist>
-13
default.nix
··· 1 - { pkgs ? import <nixpkgs> {} 2 - , python ? "python3" 3 - , pythonPackages ? builtins.getAttr (python + "Packages") pkgs }: 4 - 5 - with pythonPackages; 6 - buildPythonPackage rec { 7 - pname = "qpm"; 8 - version = "0.1"; 9 - src = ./.; 10 - doCheck = true; 11 - propagatedBuildInputs = [ pyxdg ]; 12 - checkInputs = [ pytest ]; 13 - }
+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 + }
+98
flake.nix
··· 1 + { 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 + }); 59 + 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 + }); 67 + 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 + ]; 80 + }; 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 + }; 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"]
+136
qbpm.1.scd
··· 1 + qbpm(1) 2 + 3 + # NAME 4 + 5 + qbpm - qutebrowser profile manager 6 + 7 + # SYNOPSIS 8 + 9 + *qbpm* [--profile-dir=<path>|-P <path>] [--config-file|-c <path>] <command> [<args>] 10 + 11 + # DESCRIPTION 12 + 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. 20 + 21 + # OPTIONS 22 + 23 + *-h, --help* 24 + Show help message and quit. 25 + 26 + *--version* 27 + Show version information and quit. 28 + 29 + *-P, --profile-dir* <path> 30 + Use _path_ as the profile directory instead of the default location. Takes 31 + precedence over the QBPM_PROFILE_DIR environment variable. 32 + 33 + *-c, --config-file* <path> 34 + Read configuration for qbpm from _path_. Defaults to ~/.config/qbpm/config.toml. 35 + 36 + # COMMANDS 37 + 38 + *new* [options] <profile> [<url>] 39 + Create a new qutebrowser profile named _profile_. If _url_ is present it will 40 + be used as the profile's home page. 41 + 42 + Options: 43 + 44 + *-l, --launch* 45 + Launch the profile after it is created. 46 + 47 + *-f, --foreground* 48 + If --launch is set, run qutebrowser in the foreground. 49 + 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. 58 + 59 + *--overwrite* 60 + By default qbpm will refuse to create a profile if one with the same name 61 + already exists. --overwrite disables this check and replaces the existing 62 + profile's configuration files. Profile data is left untouched. 63 + 64 + *launch* [options] <profile> [arguments...] 65 + Start qutebrowser with --basedir set to the location of _profile_. All 66 + arguments following _profile_ will be passed on to qutebrowser. 67 + 68 + Options: 69 + 70 + *-f, --foreground* 71 + Run qutebrowser in the foreground instead of forking off a new process. 72 + 73 + *-c, --create* 74 + Create the profile if it does not exist. 75 + 76 + Examples: 77 + 78 + ``` 79 + \# launch my profile called work and open internal.mycompany.com 80 + qbpm launch work internal.mycompany.com 81 + 82 + \# launch a new profile called qb-dev, passing the debugging flags to qutebrowser 83 + qbpm launch -n qb-dev --debug --json-logging 84 + ``` 85 + 86 + *choose* [options] [arguments...] 87 + Open a menu to choose a qutebrowser profile to launch. On linux this defaults 88 + to dmenu or another compatible menu program such as rofi, and on macOS this 89 + will be an applescript dialog. All arguments are passed to qutebrowser. 90 + 91 + *-m, --menu* <menu> 92 + Use _menu_ instead of the default menu program. This may be the name of a 93 + program on $PATH or a path to a program, in which case it will be run in 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. 97 + 98 + Examples: 99 + 100 + ``` 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 107 + 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' 110 + ``` 111 + 112 + *from-session* [options] <session> [<name>] 113 + Create a new qutebrowser profile from _session_, which may either be the name 114 + of a session in the default qutebrowser data directory, or a path to a session 115 + file. By default the new profile will be named after _session_, but a custom 116 + profile name can be set via the _name_ argument. Supports the same options as 117 + *new*. 118 + 119 + *desktop* <profile> 120 + Generate an XDG desktop entry for _profile_. 121 + 122 + *edit* <profile> 123 + Open _profile_'s config.py in your default editor. 124 + 125 + *list* 126 + List qutebrowser profiles. 127 + 128 + # AUTHOR 129 + 130 + Peter Rice 131 + 132 + # CONTRIBUTE 133 + 134 + _https://github.com/pvsr/qbpm_ 135 + 136 + _https://codeberg.org/pvsr/qbpm_
qpm/__init__.py

This is a binary file and will not be displayed.

-5
qpm/config.py
··· 1 - from pathlib import Path 2 - 3 - from xdg import BaseDirectory # type: ignore 4 - 5 - profiles_dir = Path(BaseDirectory.xdg_data_home) / "qutebrowser-profiles"
-133
qpm/main.py
··· 1 - import argparse 2 - import sys 3 - from pathlib import Path 4 - from typing import Callable, Optional 5 - 6 - from qpm import config, operations, profiles 7 - from qpm.profiles import Profile 8 - from qpm.utils import error 9 - 10 - 11 - def main() -> None: 12 - parser = argparse.ArgumentParser(description="Qutebrowser profile manager") 13 - parser.set_defaults(operation=lambda args: parser.print_help()) 14 - parser.add_argument( 15 - "-P", 16 - "--profile-dir", 17 - metavar="directory", 18 - type=Path, 19 - help="directory in which profiles are stored", 20 - ) 21 - 22 - subparsers = parser.add_subparsers() 23 - new = subparsers.add_parser("new", help="create a new profile") 24 - new.set_defaults( 25 - operation=lambda args: wrap_op(args.profile_name, profiles.new_profile) 26 - ) 27 - new.add_argument("profile_name", metavar="name", help="name of the new profile") 28 - creator_args(new) 29 - 30 - session = subparsers.add_parser( 31 - "from-session", help="create a new profile from a qutebrowser session" 32 - ) 33 - session.set_defaults( 34 - operation=lambda args: operations.from_session(args.session, args.profile_name), 35 - ) 36 - session.add_argument( 37 - "session", help="session to create a new profile from", 38 - ) 39 - session.add_argument( 40 - "profile_name", 41 - metavar="name", 42 - nargs="?", 43 - help="name of the new profile. if unset the session name will be used", 44 - ) 45 - creator_args(session) 46 - 47 - launch = subparsers.add_parser( 48 - "launch", aliases=["run"], help="launch qutebrowser with the given profile" 49 - ) 50 - launch.set_defaults( 51 - operation=lambda args: wrap_op( 52 - args.profile_name, 53 - lambda profile: operations.launch( 54 - profile, args.strict, args.foreground, args.qb_args or [] 55 - ), 56 - ) 57 - ) 58 - launch.add_argument( 59 - "profile_name", 60 - metavar="name", 61 - help="profile to launch. it will be created if it does not exist, unless -s is set", 62 - ) 63 - launch.add_argument( 64 - "-n", 65 - "--new", 66 - action="store_false", 67 - dest="strict", 68 - help="create the profile if it doesn't exist", 69 - ) 70 - launch.add_argument( 71 - "-f", 72 - "--foreground", 73 - action="store_true", 74 - help="launch qutebrowser in the foreground and print its stdout and stderr to the console", 75 - ) 76 - 77 - list_ = subparsers.add_parser("list", help="list existing qutebrowser profiles") 78 - list_.set_defaults(operation=lambda args: operations.list_()) 79 - 80 - raw_args = parser.parse_known_args() 81 - args = raw_args[0] 82 - args.qb_args = raw_args[1] 83 - if args.profile_dir: 84 - if not args.profile_dir.is_dir(): 85 - error(f"{args.profile_dir} is not a directory") 86 - sys.exit(1) 87 - config.profiles_dir = args.profile_dir 88 - args.operation(args) 89 - 90 - 91 - def creator_args(parser: argparse.ArgumentParser) -> None: 92 - parser.add_argument( 93 - "-l", 94 - "--launch", 95 - action=ThenLaunchAction, 96 - dest="operation", 97 - help="launch the profile after creating", 98 - ) 99 - parser.set_defaults( 100 - strict=True, foreground=False, 101 - ) 102 - 103 - 104 - def wrap_op(profile_name: str, op: Callable[[Profile], bool]) -> Optional[Profile]: 105 - profile = Profile(profile_name) 106 - return profile if op(profile) else None 107 - 108 - 109 - class ThenLaunchAction(argparse.Action): 110 - def __init__(self, option_strings, dest, nargs=0, **kwargs): 111 - super(ThenLaunchAction, self).__init__( 112 - option_strings, dest, nargs=nargs, **kwargs 113 - ) 114 - 115 - def __call__(self, parser, namespace, values, option_string=None): 116 - operation = getattr(namespace, self.dest) 117 - if operation: 118 - composed = lambda args: then_launch(args, operation) 119 - setattr(namespace, self.dest, composed) 120 - 121 - 122 - def then_launch( 123 - args: argparse.Namespace, 124 - operation: Callable[[argparse.Namespace], Optional[Profile]], 125 - ) -> bool: 126 - profile = operation(args) 127 - if profile: 128 - return operations.launch(profile, args.strict, args.foreground, []) 129 - return False 130 - 131 - 132 - if __name__ == "__main__": 133 - main()
-56
qpm/operations.py
··· 1 - import os 2 - import shutil 3 - import subprocess 4 - from typing import Iterable, Optional 5 - 6 - from qpm import config, profiles 7 - from qpm.profiles import Profile 8 - from qpm.utils import error 9 - 10 - 11 - def from_session( 12 - session_name: str, profile_name: Optional[str] = None 13 - ) -> Optional[Profile]: 14 - session = profiles.main_data_dir / "sessions" / (session_name + ".yml") 15 - if not session.is_file(): 16 - error(f"{session} is not a file") 17 - return None 18 - 19 - profile = Profile(profile_name or session_name) 20 - if not profiles.new_profile(profile): 21 - return None 22 - 23 - session_dir = profile.root / "data" / "sessions" 24 - session_dir.mkdir(parents=True) 25 - shutil.copy(session, session_dir / "_autosave.yml") 26 - 27 - return profile 28 - 29 - 30 - def launch( 31 - profile: Profile, strict: bool, foreground: bool, args: Iterable[str] 32 - ) -> bool: 33 - if not profiles.ensure_profile_exists(profile, not strict): 34 - return False 35 - 36 - if foreground: 37 - os.execlp("qutebrowser", "qutebrowser", "-B", str(profile.root), *args) 38 - else: 39 - p = subprocess.Popen( 40 - ["qutebrowser", "-B", str(profile.root), *args], 41 - stdout=subprocess.DEVNULL, 42 - stderr=subprocess.PIPE, 43 - ) 44 - try: 45 - # give qb a chance to validate input before returning to shell 46 - stdout, stderr = p.communicate(timeout=0.1) 47 - print(stderr.decode(errors="ignore"), end="") 48 - except subprocess.TimeoutExpired: 49 - pass 50 - 51 - return True 52 - 53 - 54 - def list_() -> None: 55 - for profile in config.profiles_dir.iterdir(): 56 - print(profile.name)
-84
qpm/profiles.py
··· 1 - import platform 2 - import sys 3 - from pathlib import Path 4 - 5 - from xdg import BaseDirectory # type: ignore 6 - 7 - from qpm import config 8 - from qpm.utils import error 9 - 10 - 11 - class Profile: 12 - name: str 13 - root: Path 14 - 15 - def __init__(self, name: str) -> None: 16 - self.name = name 17 - self.root = config.profiles_dir / name 18 - 19 - 20 - main_config_dir = Path(BaseDirectory.xdg_data_home) / "qutebrowser" 21 - 22 - if platform.system() == "Linux": 23 - main_data_dir = Path(BaseDirectory.xdg_data_home) / "qutebrowser" 24 - elif platform.system() == "Darwin": 25 - main_data_dir = Path.home() / "Library" / "Application Support" / "qutebrowser" 26 - else: 27 - error("lol") 28 - sys.exit(1) 29 - 30 - 31 - def check_profile(profile_root: Path) -> bool: 32 - if config.profiles_dir.resolve() not in profile_root.resolve().parents: 33 - error("will not create profile outside of profile dir. consider using -P") 34 - return False 35 - if profile_root.exists(): 36 - error(f"{profile_root} already exists") 37 - return False 38 - for parent in profile_root.parents: 39 - if parent == config.profiles_dir: 40 - break 41 - if parent.exists(): 42 - error(f"{parent} already exists") 43 - return False 44 - return True 45 - 46 - 47 - def create_profile(profile: Profile) -> bool: 48 - if not check_profile(profile.root): 49 - return False 50 - 51 - config_dir = profile.root / "config" 52 - config_dir.mkdir(parents=True) 53 - return True 54 - 55 - 56 - def create_config(profile: Profile) -> None: 57 - with (profile.root / "config" / "config.py").open(mode="x") as dest_config: 58 - print( 59 - "c.window.title_format = '{perc}{current_title}{title_sep}" 60 - + f"qutebrowser ({profile.name})'", 61 - file=dest_config, 62 - ) 63 - print(f"config.source('{main_config_dir / 'config.py'}')", file=dest_config) 64 - for conf in main_config_dir.glob("conf.d/*.py"): 65 - print(f"config.source('{conf}')", file=dest_config) 66 - 67 - 68 - def ensure_profile_exists(profile: Profile, create: bool = True) -> bool: 69 - if profile.root.exists() and not profile.root.is_dir(): 70 - error(f"{profile.root} is not a directory") 71 - return False 72 - if not profile.root.exists() and create: 73 - return new_profile(profile) 74 - if not profile.root.exists(): 75 - error(f"{profile.root} does not exist") 76 - return False 77 - return True 78 - 79 - 80 - def new_profile(profile: Profile) -> bool: 81 - if create_profile(profile): 82 - create_config(profile) 83 - return True 84 - return False
-5
qpm/utils.py
··· 1 - from sys import stderr 2 - 3 - 4 - def error(msg: str) -> None: 5 - print(f"Error: {msg}", file=stderr)
-1
requirements.txt
··· 1 - pyxdg ~= 0.26
-13
setup.py
··· 1 - from setuptools import setup, find_packages 2 - 3 - setup( 4 - name="qpm", 5 - version="0.1", 6 - url="https://git.sr.ht/~pvsr/qpm", 7 - packages=find_packages(), 8 - entry_points={"console_scripts": ["qpm = qpm.main:main"]}, 9 - install_requires=["pyxdg"], 10 - author="Peter Rice", 11 - author_email="peter@peterrice.xyz", 12 - description="qutebrowser profile manager", 13 - )
-10
shell.nix
··· 1 - with import <nixpkgs> {}; 2 - 3 - mkShell { 4 - buildInputs = [ 5 - (python3.withPackages (ps: with ps; [ 6 - pyxdg 7 - pytest 8 - ])) 9 - ]; 10 - }
+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()
+106
tests/test_main.py
··· 1 + from os import chdir, environ 2 + from pathlib import Path 3 + 4 + from click.testing import CliRunner 5 + 6 + from qbpm.main import main 7 + 8 + from . import no_homedir_fixture # noqa: F401 9 + 10 + 11 + def run(*args: str): 12 + return CliRunner().invoke(main, args) 13 + 14 + 15 + def test_profile_dir_option(tmp_path: Path): 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() 22 + 23 + 24 + def test_profile_dir_env(tmp_path: Path): 25 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 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() 50 + 51 + 52 + def test_from_session_path(tmp_path: Path): 53 + environ["QBPM_PROFILE_DIR"] = str(tmp_path) 54 + (tmp_path / "config.py").touch() 55 + session = tmp_path / "test.yml" 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
+113 -44
tests/test_profiles.py
··· 1 1 from pathlib import Path 2 - from typing import Optional 3 2 4 - from qpm import config, profiles 5 - from qpm.profiles import Profile 3 + from qbpm import profiles 4 + from qbpm.config import Config 5 + from qbpm.profiles import Profile 6 + 7 + from . import no_homedir_fixture # noqa: F401 6 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] ··· 24 26 25 27 26 28 def test_set_profile(tmp_path: Path): 27 - config.profiles_dir = tmp_path 28 - assert Profile("test").root == tmp_path / "test" 29 + assert Profile("test", tmp_path).root == tmp_path / "test" 29 30 30 31 31 32 def test_create_profile(tmp_path: Path): 32 - config.profiles_dir = tmp_path 33 - profile = Profile("test") 33 + profile = Profile("test", tmp_path) 34 34 assert profiles.create_profile(profile) 35 35 assert list(tmp_path.iterdir()) == [profile.root] 36 36 check_empty_profile(profile) 37 37 38 38 39 39 def test_create_profile_conflict(tmp_path: Path): 40 - config.profiles_dir = tmp_path 41 40 (tmp_path / "test").touch() 42 - profile = Profile("test") 41 + profile = Profile("test", tmp_path) 43 42 assert not profiles.create_profile(profile) 44 43 45 44 46 45 def test_create_profile_parent(tmp_path: Path): 47 - config.profiles_dir = tmp_path / "profiles" 48 - profile = Profile("../test") 46 + profile = Profile("../test", tmp_path / "profiles") 49 47 assert not profiles.create_profile(profile) 50 48 assert not (tmp_path / "test").exists() 51 49 52 50 53 51 def test_create_profile_nested_conflict(tmp_path: Path): 54 - config.profiles_dir = tmp_path 55 - assert profiles.create_profile(Profile("test")) 56 - assert not profiles.create_profile(Profile("test/a")) 52 + assert profiles.create_profile(Profile("test", tmp_path)) 53 + assert not profiles.create_profile(Profile("test/a", tmp_path)) 57 54 58 55 59 56 def test_create_config(tmp_path: Path): 60 - config.profiles_dir = tmp_path 61 - profile = Profile("test") 57 + (tmp_path / "config.py").touch() 58 + profile = Profile("test", tmp_path) 62 59 config_dir = profile.root / "config" 63 60 config_dir.mkdir(parents=True) 64 - profiles.create_config(profile) 65 - 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() 66 65 67 66 68 - def test_ensure_profile_exists_exists(tmp_path: Path): 69 - config.profiles_dir = tmp_path 70 - profile = Profile("test") 71 - profile.root.mkdir() 72 - assert profiles.ensure_profile_exists(profile, False) 73 - assert profiles.ensure_profile_exists(profile, True) 74 - check_is_empty(profile.root) 67 + def test_overwrite_config(tmp_path: Path): 68 + (tmp_path / "config.py").touch() 69 + profile = Profile("test", tmp_path) 70 + url = "http://example.com" 71 + config_dir = profile.root / "config" 72 + config_dir.mkdir(parents=True) 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_does_not_exist(tmp_path: Path): 78 - config.profiles_dir = tmp_path 79 - assert not profiles.ensure_profile_exists(Profile("test"), False) 80 - check_is_empty(tmp_path) 82 + def test_link_autoconfig(tmp_path: Path): 83 + profile = Profile("test", tmp_path) 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 81 91 82 92 83 - def test_ensure_profile_exists_not_dir(tmp_path: Path): 84 - config.profiles_dir = tmp_path 85 - profile = Profile("test") 86 - profile.root.touch() 87 - assert not profiles.ensure_profile_exists(profile, False) 88 - assert not profiles.ensure_profile_exists(profile, True) 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 89 103 90 104 91 - def test_ensure_profile_exists_create(tmp_path: Path): 92 - config.profiles_dir = tmp_path 93 - profile = Profile("test") 94 - assert profiles.ensure_profile_exists(profile, True) 95 - check_new_profile(profile) 105 + def test_overwrite_autoconfig(tmp_path: Path): 106 + profile = Profile("test", tmp_path) 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 96 115 97 116 98 117 def test_new_profile(tmp_path: Path): 99 - config.profiles_dir = tmp_path 100 - profile = Profile("test") 101 - assert profiles.new_profile(profile) 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) 102 124 check_new_profile(profile) 125 + 126 + 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() 153 + profile = Profile("test", tmp_path) 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)