-15
.build.yml
-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
+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
+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
+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
+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
+9
-1
.gitignore
+69
CHANGELOG.md
+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
+58
-62
README.md
···
1
-
# `qpm`: qutebrowser profile manager
2
3
-
[](https://builds.sr.ht/~pvsr/qpm?)
4
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!
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.
27
28
## Usage
29
```
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
34
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
38
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
45
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
-
```
51
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.
56
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
69
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`.
···
1
+
# qutebrowser profile manager
2
3
+
[](https://builds.sr.ht/~pvsr/qbpm/commits/main?)
4
+
[](https://pypi.python.org/pypi/qbpm)
5
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
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.
20
21
## Usage
22
+
To create a new profile called "python" and launch it with the python docs open:
23
```
24
+
$ qbpm new python
25
+
$ qbpm launch python docs.python.org
26
+
```
27
28
+
Note that all arguments after `qbpm launch PROFILE` are passed to qutebrowser,
29
+
so options can be passed too: `qbpm launch python --target window pypi.org`.
30
31
+
If you have multiple profiles you can use `qbpm choose` to bring up a list of
32
+
profiles and select one to launch. Depending on what your system has available
33
+
the menu may be `dmenu`, `fuzzel`, `fzf`, an applescript dialog, or one of many
34
+
other menu programs qbpm can detect. Any dmenu-compatible menu can be used with
35
+
`--menu`, e.g. `qbpm choose --menu 'fuzzel --dmenu'`. As with `qbpm launch`,
36
+
extra arguments are passed to qutebrowser.
37
38
+
Run `qbpm --help` to see other available commands.
39
40
+
By default when you create a new profile a `.desktop` file is created that
41
+
launches the profile. This launcher does not depend on qbpm at all, so if you
42
+
want you can run `qbpm new` once and keep using the profile without needing
43
+
qbpm installed on your system.
44
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`.
48
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
+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
+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
+
}
+10
contrib/qbpm.desktop
+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
+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
-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
+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
+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
+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
+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
qpm/__init__.py
This is a binary file and will not be displayed.
-5
qpm/config.py
-5
qpm/config.py
-133
qpm/main.py
-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
-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
-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
-5
qpm/utils.py
-1
requirements.txt
-1
requirements.txt
···
1
-
pyxdg ~= 0.26
···
-13
setup.py
-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
-10
shell.nix
+38
src/qbpm/__init__.py
+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
+4
src/qbpm/__main__.py
+45
src/qbpm/choose.py
+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
+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
+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
+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
+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
+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
+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)
+42
src/qbpm/paths.py
+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
+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
+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
+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
+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
+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
+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
+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
+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()
+113
-44
tests/test_profiles.py
+113
-44
tests/test_profiles.py
···
1
from pathlib import Path
2
-
from typing import Optional
3
4
-
from qpm import config, profiles
5
-
from qpm.profiles import Profile
6
7
8
def check_is_empty(path: Path):
9
assert len(list(path.iterdir())) == 0
10
11
12
-
def check_empty_profile(profile: Optional[Profile]):
13
assert profile
14
config_dir = profile.root / "config"
15
assert list(profile.root.iterdir()) == [config_dir]
···
24
25
26
def test_set_profile(tmp_path: Path):
27
-
config.profiles_dir = tmp_path
28
-
assert Profile("test").root == tmp_path / "test"
29
30
31
def test_create_profile(tmp_path: Path):
32
-
config.profiles_dir = tmp_path
33
-
profile = Profile("test")
34
assert profiles.create_profile(profile)
35
assert list(tmp_path.iterdir()) == [profile.root]
36
check_empty_profile(profile)
37
38
39
def test_create_profile_conflict(tmp_path: Path):
40
-
config.profiles_dir = tmp_path
41
(tmp_path / "test").touch()
42
-
profile = Profile("test")
43
assert not profiles.create_profile(profile)
44
45
46
def test_create_profile_parent(tmp_path: Path):
47
-
config.profiles_dir = tmp_path / "profiles"
48
-
profile = Profile("../test")
49
assert not profiles.create_profile(profile)
50
assert not (tmp_path / "test").exists()
51
52
53
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"))
57
58
59
def test_create_config(tmp_path: Path):
60
-
config.profiles_dir = tmp_path
61
-
profile = Profile("test")
62
config_dir = profile.root / "config"
63
config_dir.mkdir(parents=True)
64
-
profiles.create_config(profile)
65
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
66
67
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)
75
76
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)
81
82
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)
89
90
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)
96
97
98
def test_new_profile(tmp_path: Path):
99
-
config.profiles_dir = tmp_path
100
-
profile = Profile("test")
101
-
assert profiles.new_profile(profile)
102
check_new_profile(profile)
···
1
from pathlib import Path
2
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
8
9
10
def check_is_empty(path: Path):
11
assert len(list(path.iterdir())) == 0
12
13
14
+
def check_empty_profile(profile: Profile | None):
15
assert profile
16
config_dir = profile.root / "config"
17
assert list(profile.root.iterdir()) == [config_dir]
···
26
27
28
def test_set_profile(tmp_path: Path):
29
+
assert Profile("test", tmp_path).root == tmp_path / "test"
30
31
32
def test_create_profile(tmp_path: Path):
33
+
profile = Profile("test", tmp_path)
34
assert profiles.create_profile(profile)
35
assert list(tmp_path.iterdir()) == [profile.root]
36
check_empty_profile(profile)
37
38
39
def test_create_profile_conflict(tmp_path: Path):
40
(tmp_path / "test").touch()
41
+
profile = Profile("test", tmp_path)
42
assert not profiles.create_profile(profile)
43
44
45
def test_create_profile_parent(tmp_path: Path):
46
+
profile = Profile("../test", tmp_path / "profiles")
47
assert not profiles.create_profile(profile)
48
assert not (tmp_path / "test").exists()
49
50
51
def test_create_profile_nested_conflict(tmp_path: Path):
52
+
assert profiles.create_profile(Profile("test", tmp_path))
53
+
assert not profiles.create_profile(Profile("test/a", tmp_path))
54
55
56
def test_create_config(tmp_path: Path):
57
+
(tmp_path / "config.py").touch()
58
+
profile = Profile("test", tmp_path)
59
config_dir = profile.root / "config"
60
config_dir.mkdir(parents=True)
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()
65
66
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()
80
81
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
91
92
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
103
104
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
115
116
117
def test_new_profile(tmp_path: Path):
118
+
(tmp_path / "config.py").touch()
119
+
profile = Profile("test", tmp_path / "test")
120
+
config = Config.load(None)
121
+
config.qutebrowser_config_directory = tmp_path
122
+
config.generate_desktop_file = False
123
+
assert profiles.new_profile(profile, config)
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)