-19
.build.yml
-19
.build.yml
···
1
-
image: nixos/unstable
2
-
repositories:
3
-
nixpkgs: https://nixos.org/channels/nixos-unstable
4
-
sources:
5
-
- https://git.sr.ht/~pvsr/qpm
6
-
tasks:
7
-
- deps: |
8
-
cd qpm
9
-
nix-shell --quiet --run exit
10
-
- format: |
11
-
cd qpm
12
-
nix-shell --run 'black --diff --check qbpm tests'
13
-
- mypy: |
14
-
cd qpm
15
-
nix-shell --run 'python setup.py --version'
16
-
nix-shell --run 'mypy qbpm tests'
17
-
- pytest: |
18
-
cd qpm
19
-
nix-shell --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
+7
.gitignore
+7
.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
-27
PKGBUILD
-27
PKGBUILD
···
1
-
# Maintainer: Peter Rice <{first name}@peterrice.xyz>
2
-
3
-
pkgname=qbpm-git
4
-
pkgver=0.3.r16.g7899e67
5
-
pkgrel=1
6
-
pkgdesc="A profile manager for qutebrowser"
7
-
url="https://github.com/pvsr/qbpm"
8
-
license=('GPL')
9
-
sha512sums=('SKIP')
10
-
arch=('any')
11
-
depends=('python' 'python-pyxdg')
12
-
makedepends=('git' 'python-setuptools' 'python-setuptools-scm')
13
-
provides=('qbpm')
14
-
conflicts=('qbpm')
15
-
source=("git://github.com/pvsr/qbpm")
16
-
17
-
pkgver() {
18
-
cd qbpm
19
-
git describe --long --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g'
20
-
}
21
-
22
-
package() {
23
-
cd qbpm
24
-
install -D -m644 completions/qbpm.fish ${pkgdir}/usr/share/fish/vendor_completions.d/qbpm.fish
25
-
install -D -m644 LICENSE ${pkgdir}/usr/share/licenses/qbpm/LICENSE
26
-
python setup.py install --root="$pkgdir" --optimize=1
27
-
}
···
+50
-56
README.md
+50
-56
README.md
···
1
# qutebrowser profile manager
2
3
-
[](https://builds.sr.ht/~pvsr/qpm?)
4
5
-
qutebrowser profile manager (qbpm) is a tool for creating and managing
6
-
[qutebrowser](https://github.com/qutebrowser/qutebrowser) profiles. There isn't
7
-
any built in concept of profiles in qutebrowser, but there is a `--basedir` flag
8
-
which allows you to use any directory as the location of qutebrowser's config
9
-
and data. qbpm creates `--basedir` profiles that symlink to your main
10
-
qutebrowser config files but have their own separate bookmarks, cookies,
11
-
history, and other data. It also acts as a wrapper around qutebrowser that sets
12
-
up `--basedir` for you, so you can treat `qbpm launch` as an alias for
13
-
`qutebrowser`, such as to open a url: `qbpm launch my-profile example.org`.
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
···
19
instances of qutebrowser which can be opened and closed independently.
20
21
## Usage
22
-
Create a new profile called "python", edit its `config.py`, then launch it:
23
```
24
$ qbpm new python
25
-
$ qbpm edit python
26
$ qbpm launch python docs.python.org
27
-
$ qbpm choose # run dmenu or another launcher to pick a profile
28
```
29
30
-
`qbpm from-session` can copy the tabs of a [saved qutebrowser
31
-
session](https://qutebrowser.org/doc/help/commands.html#session-save) to a new
32
-
profile. If you have a window full of tabs related to planning a vacation, you
33
-
could save it to a session called "vacation" using `:session-save -o vacation`
34
-
in qutebrowser, then create a new profile with those tabs:
35
-
```
36
-
$ qbpm from-session vacation
37
-
```
38
39
-
The default profile directory is `$XDG_DATA_HOME/qutebrowser-profiles`, where
40
-
`$XDG_DATA_HOME` is usually `$HOME/.local/share`, but you can create and launch
41
-
profiles from anywhere using `--profile-dir`/`-P`:
42
-
```
43
-
$ qbpm --profile-dir ~/dev/my-project new qb-profile
44
-
$ cd ~/dev/my-project
45
-
$ qbpm -P . launch qb-profile
46
-
# or
47
-
$ qutebrowser --basedir qb-profile
48
-
```
49
50
## Installation
51
-
- Pip: `pip install git+https://github.com/pvsr/qbpm.git#egg=qbpm`
52
-
- Arch: [qbpm-git](https://aur.archlinux.org/packages/qbpm-git) in the AUR
53
-
- Nix: clone the repository and run `nix-env -if default.nix`
54
-
- MacOS: For command-line only usage, the pip command above is sufficient, but
55
-
if you would like to set qbpm as the default browser app, first clone this
56
-
repository, then install platypus by running `brew install playtpus`, and
57
-
finally install the app by running `platypus -P contrib/qbpm.platypus
58
-
/Applications/qbpm.app` inside the cloned repository. You should then be
59
-
able to select qbpm as your default browser under: System Preferences
60
-
\> General > Default web browser. Note that there is currently [an
61
-
issue](https://github.com/qutebrowser/qutebrowser/issues/3719) with
62
-
qutebrowser itself that results in unnecessary `file:///*` tabs being
63
-
opened.
64
-
- If you're on linux, you can copy `contrib/qbpm.desktop` to `~/.local/share/applications`.
65
-
That desktop entry will run `qbpm choose`, which shows an application
66
-
launcher (dmenu or rofi) with your qutebrowser profiles as the options.
67
68
-
## Future ideas that may or may not happen
69
-
- Release through github
70
-
- More shared or copied config and data
71
-
- Use any profile as a base for new profiles (currently only the main config in
72
-
`$XDG_CONFIG_HOME` is supported)
73
-
- Source `autoconfig.yml` instead of `config.py`
74
-
- Bundled config file optimized for single-site browsing
75
-
- `qbpm.conf` to configure the features above
76
-
- Someday: qutebrowser plugin
···
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
···
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.
+31
-4
completions/qbpm.fish
+31
-4
completions/qbpm.fish
···
1
-
set -l commands new from-session desktop launch run list edit
2
3
complete -c qbpm -f
4
-
complete -c qbpm -n "not __fish_seen_subcommand_from $commands" -a "launch new from-session edit list"
5
-
complete -c qbpm -n "__fish_seen_subcommand_from launch edit" -a "(qbpm list)"
6
-
set -l data_home (set -q XDG_DATA_HOME; and echo $XDG_DATA_HOME; or echo ~/.local/share)
7
complete -c qbpm -n "__fish_seen_subcommand_from from-session" -a "(ls $data_home/qutebrowser/sessions | xargs basename -a -s .yml)"
···
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
+
}
+5
-3
contrib/qbpm.desktop
+5
-3
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
-20
default.nix
-20
default.nix
···
1
-
{ pkgs ? import <nixpkgs> { }
2
-
, python ? "python3"
3
-
, pythonPackages ? builtins.getAttr (python + "Packages") pkgs
4
-
}:
5
-
6
-
with pythonPackages;
7
-
buildPythonPackage rec {
8
-
pname = "qbpm";
9
-
version = "0.6";
10
-
src = ./.;
11
-
doCheck = true;
12
-
SETUPTOOLS_SCM_PRETEND_VERSION = version;
13
-
nativeBuildInputs = [ setuptools-scm ];
14
-
propagatedBuildInputs = [ pyxdg ];
15
-
checkInputs = [ pytest ];
16
-
postInstall = ''
17
-
mkdir -p $out/share/fish/vendor_completions.d
18
-
cp completions/qbpm.fish $out/share/fish/vendor_completions.d/
19
-
'';
20
-
}
···
+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
+
}
+93
-14
flake.nix
+93
-14
flake.nix
···
1
{
2
-
description = "A tool for creating and managing qutebrowser profiles";
3
4
-
inputs.flake-utils.url = "github:numtide/flake-utils";
5
6
-
outputs = { self, nixpkgs, flake-utils }:
7
-
flake-utils.lib.eachDefaultSystem (system:
8
-
let pkgs = nixpkgs.legacyPackages.${system}; in
9
-
rec {
10
-
packages = flake-utils.lib.flattenTree {
11
-
qbpm = import ./. { inherit pkgs; };
12
};
13
-
defaultPackage = packages.qbpm;
14
-
apps.qbpm = flake-utils.lib.mkApp { drv = packages.qbpm; };
15
-
defaultApp = apps.qbpm;
16
-
devShell = import ./shell.nix { inherit pkgs; };
17
-
}
18
-
);
19
}
···
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"]
-4
qbpm/__init__.py
-4
qbpm/__init__.py
-203
qbpm/main.py
-203
qbpm/main.py
···
1
-
import argparse
2
-
from os import environ
3
-
from pathlib import Path
4
-
from typing import Any, Callable, Optional
5
-
6
-
from xdg import BaseDirectory # type: ignore
7
-
8
-
from qbpm import __version__, operations, profiles
9
-
from qbpm.profiles import Profile
10
-
from qbpm.utils import SUPPORTED_MENUS
11
-
12
-
DEFAULT_PROFILE_DIR = Path(BaseDirectory.xdg_data_home) / "qutebrowser-profiles"
13
-
14
-
15
-
def main(mock_args=None) -> None:
16
-
parser = argparse.ArgumentParser(description="qutebrowser profile manager")
17
-
parser.set_defaults(operation=lambda args: parser.print_help())
18
-
parser.add_argument(
19
-
"-P",
20
-
"--profile-dir",
21
-
metavar="directory",
22
-
type=Path,
23
-
help="directory in which profiles are stored",
24
-
)
25
-
parser.add_argument(
26
-
"--set-app-id",
27
-
action="store_true",
28
-
help="set wayland app_id to this profile's name. requires qutebrowser v2.0.0+",
29
-
)
30
-
parser.add_argument(
31
-
"--version",
32
-
action="version",
33
-
version=__version__,
34
-
)
35
-
36
-
subparsers = parser.add_subparsers()
37
-
new = subparsers.add_parser("new", help="create a new profile")
38
-
new.add_argument("profile_name", metavar="profile", help="name of the new profile")
39
-
new.add_argument("home_page", metavar="url", nargs="?", help="profile's home page")
40
-
new.set_defaults(
41
-
operation=lambda args: profiles.new_profile(
42
-
build_profile(args),
43
-
args.home_page,
44
-
args.desktop_file,
45
-
args.overwrite,
46
-
)
47
-
)
48
-
creator_args(new)
49
-
50
-
session = subparsers.add_parser(
51
-
"from-session", help="create a new profile from a qutebrowser session"
52
-
)
53
-
session.add_argument(
54
-
"session",
55
-
help="path to session file or name of session. "
56
-
"e.g. ~/.local/share/qutebrowser/sessions/example.yml or example",
57
-
)
58
-
session.add_argument(
59
-
"profile_name",
60
-
metavar="profile",
61
-
nargs="?",
62
-
help="name of the new profile. if unset the session name will be used",
63
-
)
64
-
session.set_defaults(
65
-
operation=lambda args: operations.from_session(
66
-
args.session,
67
-
args.profile_name,
68
-
args.profile_dir,
69
-
args.desktop_file,
70
-
args.overwrite,
71
-
)
72
-
)
73
-
creator_args(session)
74
-
75
-
desktop = subparsers.add_parser(
76
-
"desktop", help="create a desktop file for an existing profile"
77
-
)
78
-
desktop.add_argument(
79
-
"profile_name", metavar="profile", help="profile to create a desktop file for"
80
-
)
81
-
desktop.set_defaults(operation=lambda args: operations.desktop(build_profile(args)))
82
-
83
-
launch = subparsers.add_parser(
84
-
"launch", aliases=["run"], help="launch qutebrowser with the given profile"
85
-
)
86
-
launch.add_argument(
87
-
"profile_name",
88
-
metavar="profile",
89
-
help="profile to launch. it will be created if it does not exist, unless -s is set",
90
-
)
91
-
launch.add_argument(
92
-
"-n",
93
-
"--new",
94
-
action="store_false",
95
-
dest="strict",
96
-
help="create the profile if it doesn't exist",
97
-
)
98
-
launch.add_argument(
99
-
"-f",
100
-
"--foreground",
101
-
action="store_true",
102
-
help="launch qutebrowser in the foreground and print its stdout and stderr to the console",
103
-
)
104
-
launch.set_defaults(
105
-
operation=lambda args: operations.launch(
106
-
build_profile(args), args.strict, args.foreground, args.qb_args
107
-
)
108
-
)
109
-
110
-
list_ = subparsers.add_parser("list", help="list existing profiles")
111
-
list_.set_defaults(operation=operations.list_)
112
-
113
-
choose = subparsers.add_parser(
114
-
"choose",
115
-
help="choose profile using a dmenu-compatible launcher or an applescript dialog",
116
-
)
117
-
menus = sorted(SUPPORTED_MENUS)
118
-
choose.add_argument(
119
-
"-m",
120
-
"--menu",
121
-
help=f'menu application to use. this may be any dmenu-compatible command (e.g. "dmenu -i -p qbpm" or "/path/to/rofi -d") or one of the following menus with built-in support: {menus}',
122
-
)
123
-
choose.add_argument(
124
-
"-f",
125
-
"--foreground",
126
-
action="store_true",
127
-
help="launch qutebrowser in the foreground and print its stdout and stderr to the console",
128
-
)
129
-
choose.set_defaults(operation=operations.choose)
130
-
131
-
edit = subparsers.add_parser(
132
-
"edit", help="edit a profile's config.py using $EDITOR"
133
-
)
134
-
edit.add_argument("profile_name", metavar="profile", help="profile to edit")
135
-
edit.set_defaults(operation=lambda args: operations.edit(build_profile(args)))
136
-
137
-
raw_args = parser.parse_known_args(mock_args)
138
-
args = raw_args[0]
139
-
args.qb_args = raw_args[1]
140
-
if not args.profile_dir:
141
-
args.profile_dir = Path(environ.get("QPM_PROFILE_DIR") or DEFAULT_PROFILE_DIR)
142
-
if not args.operation(args):
143
-
exit(1)
144
-
145
-
146
-
def creator_args(parser: argparse.ArgumentParser) -> None:
147
-
parser.add_argument(
148
-
"-l",
149
-
"--launch",
150
-
action=ThenLaunchAction,
151
-
dest="operation",
152
-
help="launch the profile after creating",
153
-
)
154
-
parser.add_argument(
155
-
"-f",
156
-
"--foreground",
157
-
action="store_true",
158
-
help="if --launch is set, launch qutebrowser in the foreground",
159
-
)
160
-
parser.add_argument(
161
-
"--no-desktop-file",
162
-
dest="desktop_file",
163
-
action="store_false",
164
-
help="do not generate a desktop file for the profile",
165
-
)
166
-
parser.add_argument(
167
-
"--overwrite",
168
-
action="store_true",
169
-
help="replace existing profile config",
170
-
)
171
-
parser.set_defaults(strict=True)
172
-
173
-
174
-
class ThenLaunchAction(argparse.Action):
175
-
def __init__(self, option_strings, dest, nargs=0, **kwargs):
176
-
super(ThenLaunchAction, self).__init__(
177
-
option_strings, dest, nargs=nargs, **kwargs
178
-
)
179
-
180
-
def __call__(self, parser, namespace, values, option_string=None):
181
-
if operation := getattr(namespace, self.dest):
182
-
setattr(namespace, self.dest, lambda args: then_launch(args, operation))
183
-
184
-
185
-
def then_launch(
186
-
args: argparse.Namespace,
187
-
operation: Callable[[argparse.Namespace], Optional[Any]],
188
-
) -> bool:
189
-
if result := operation(args):
190
-
if isinstance(result, Profile):
191
-
profile = result
192
-
else:
193
-
profile = build_profile(args)
194
-
return operations.launch(profile, False, args.foreground, [])
195
-
return False
196
-
197
-
198
-
def build_profile(args: argparse.Namespace) -> Profile:
199
-
return Profile(args.profile_name, args.profile_dir, args.set_app_id)
200
-
201
-
202
-
if __name__ == "__main__":
203
-
main()
···
-158
qbpm/operations.py
-158
qbpm/operations.py
···
1
-
import argparse
2
-
import os
3
-
import shutil
4
-
import subprocess
5
-
from pathlib import Path
6
-
from sys import platform, stderr
7
-
from typing import List, Optional
8
-
9
-
from xdg import BaseDirectory # type: ignore
10
-
from xdg.DesktopEntry import DesktopEntry # type: ignore
11
-
12
-
from qbpm import profiles
13
-
from qbpm.profiles import Profile
14
-
from qbpm.utils import SUPPORTED_MENUS, error, get_default_menu, user_data_dir
15
-
16
-
17
-
def from_session(
18
-
session: str,
19
-
profile_name: Optional[str] = None,
20
-
profile_dir: Optional[Path] = None,
21
-
desktop_file: bool = True,
22
-
overwrite: bool = False,
23
-
) -> Optional[Profile]:
24
-
if session.endswith(".yml"):
25
-
session_file = Path(session).expanduser()
26
-
session_name = session_file.stem
27
-
else:
28
-
session_name = session
29
-
session_file = user_data_dir() / "sessions" / (session_name + ".yml")
30
-
if not session_file.is_file():
31
-
error(f"{session_file} is not a file")
32
-
return None
33
-
34
-
profile = Profile(profile_name or session_name, profile_dir)
35
-
if not profiles.new_profile(profile, None, desktop_file, overwrite):
36
-
return None
37
-
38
-
session_dir = profile.root / "data" / "sessions"
39
-
session_dir.mkdir(parents=True, exist_ok=overwrite)
40
-
shutil.copy(session_file, session_dir / "_autosave.yml")
41
-
42
-
return profile
43
-
44
-
45
-
def launch(
46
-
profile: Profile, strict: bool, foreground: bool, qb_args: List[str]
47
-
) -> bool:
48
-
if not profiles.ensure_profile_exists(profile, not strict):
49
-
return False
50
-
51
-
args = profile.cmdline() + qb_args
52
-
if not shutil.which(args[0]):
53
-
error("qutebrowser is not installed")
54
-
return False
55
-
56
-
if foreground:
57
-
os.execlp("qutebrowser", *args)
58
-
else:
59
-
p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
60
-
try:
61
-
# give qb a chance to validate input before returning to shell
62
-
stdout, stderr = p.communicate(timeout=0.1)
63
-
print(stderr.decode(errors="ignore"), end="")
64
-
except subprocess.TimeoutExpired:
65
-
pass
66
-
67
-
return True
68
-
69
-
70
-
application_dir = Path(BaseDirectory.xdg_data_home) / "applications" / "qbpm"
71
-
72
-
73
-
def desktop(profile: Profile) -> bool:
74
-
exists = profile.exists()
75
-
if exists:
76
-
profiles.create_desktop_file(profile)
77
-
else:
78
-
error(f"profile {profile.name} not found at {profile.root}")
79
-
return exists
80
-
81
-
82
-
def list_(args: argparse.Namespace) -> bool:
83
-
for profile in sorted(args.profile_dir.iterdir()):
84
-
print(profile.name)
85
-
return True
86
-
87
-
88
-
def choose(args: argparse.Namespace) -> bool:
89
-
menu = args.menu or get_default_menu()
90
-
if not menu:
91
-
error(f"No menu program found, please install one of: {SUPPORTED_MENUS}")
92
-
return False
93
-
if menu == "applescript" and platform != "darwin":
94
-
error(f"Menu applescript cannot be used on a {platform} host")
95
-
return False
96
-
profiles = [profile.name for profile in sorted(args.profile_dir.iterdir())]
97
-
if len(profiles) == 0:
98
-
error("No profiles")
99
-
return False
100
-
101
-
command = menu_command(menu, profiles, args)
102
-
program = command.split(" ")[0]
103
-
if not shutil.which(program):
104
-
error(f"'{program}' not found on path")
105
-
return False
106
-
107
-
selection_cmd = subprocess.Popen(
108
-
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
109
-
)
110
-
out = selection_cmd.stdout
111
-
if not out:
112
-
error(f"Could not read stdout from {command}")
113
-
return False
114
-
selection = out.read().decode(errors="ignore").rstrip("\n")
115
-
116
-
if selection:
117
-
profile = Profile(selection, args.profile_dir, args.set_app_id)
118
-
launch(profile, True, args.foreground, args.qb_args)
119
-
else:
120
-
error("No profile selected")
121
-
if err := selection_cmd.stderr:
122
-
msg = err.read().decode(errors="ignore").rstrip("\n")
123
-
if msg:
124
-
for line in msg.split("\n"):
125
-
print(f"stderr: {line}", file=stderr)
126
-
return False
127
-
return True
128
-
129
-
130
-
def menu_command(menu: str, profiles, args: argparse.Namespace) -> str:
131
-
arg_string = " ".join(args.qb_args)
132
-
if menu == "applescript":
133
-
profile_list = '", "'.join(profiles)
134
-
return f"""osascript -e \'set profiles to {{"{profile_list}"}}
135
-
set profile to choose from list profiles with prompt "qutebrowser: {arg_string}" default items {{item 1 of profiles}}
136
-
item 1 of profile\'"""
137
-
138
-
prompt = "-p qutebrowser"
139
-
command = menu
140
-
if len(menu.split(" ")) == 1:
141
-
program = Path(menu).name
142
-
if program == "rofi":
143
-
command = f"{menu} -dmenu -no-custom {prompt} -mesg {arg_string}"
144
-
elif program == "wofi":
145
-
command = f"{menu} --dmenu {prompt}"
146
-
elif program in ["dmenu", "dmenu-wl"]:
147
-
command = f"{menu} {prompt}"
148
-
profile_list = "\n".join(profiles)
149
-
return f'echo "{profile_list}" | {command}'
150
-
151
-
152
-
def edit(profile: Profile) -> bool:
153
-
if not profile.exists():
154
-
error(f"profile {profile.name} not found at {profile.root}")
155
-
return False
156
-
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vim"
157
-
os.execlp(editor, editor, str(profile.root / "config" / "config.py"))
158
-
return True
···
-120
qbpm/profiles.py
-120
qbpm/profiles.py
···
1
-
from functools import partial
2
-
from pathlib import Path
3
-
from sys import platform
4
-
from typing import List, Optional
5
-
6
-
from xdg import BaseDirectory # type: ignore
7
-
from xdg.DesktopEntry import DesktopEntry # type: ignore
8
-
9
-
from qbpm.utils import error, user_config_dir
10
-
11
-
12
-
class Profile:
13
-
name: str
14
-
profile_dir: Path
15
-
set_app_id: bool
16
-
root: Path
17
-
18
-
def __init__(
19
-
self, name: str, profile_dir: Optional[Path], set_app_id: bool = False
20
-
) -> None:
21
-
self.name = name
22
-
self.profile_dir = profile_dir or Path(
23
-
BaseDirectory.save_data_path("qutebrowser-profiles")
24
-
)
25
-
self.set_app_id = set_app_id
26
-
self.root = self.profile_dir / name
27
-
28
-
def check(self) -> Optional["Profile"]:
29
-
if "/" in self.name:
30
-
error("profile name cannot contain slashes")
31
-
return None
32
-
if not self.profile_dir.resolve().is_dir():
33
-
error(f"{self.profile_dir} is not a directory")
34
-
return None
35
-
return self
36
-
37
-
def exists(self) -> bool:
38
-
return self.root.exists() and self.root.is_dir()
39
-
40
-
def cmdline(self) -> List[str]:
41
-
macos_app = "/Applications/qutebrowser.app/Contents/MacOS/qutebrowser"
42
-
if platform == "darwin" and Path(macos_app).exists():
43
-
qb = macos_app
44
-
else:
45
-
qb = "qutebrowser"
46
-
return [qb, "-B", str(self.root), "--qt-arg", "name", self.name] + (
47
-
["--desktop-file-name", self.name] if self.set_app_id else []
48
-
)
49
-
50
-
51
-
def create_profile(profile: Profile, overwrite: bool = False) -> bool:
52
-
if not profile.check():
53
-
return False
54
-
55
-
if not overwrite and profile.root.exists():
56
-
error(f"{profile.root} already exists")
57
-
return False
58
-
59
-
config_dir = profile.root / "config"
60
-
config_dir.mkdir(parents=True, exist_ok=overwrite)
61
-
print(profile.root)
62
-
return True
63
-
64
-
65
-
def create_config(
66
-
profile: Profile, home_page: Optional[str] = None, overwrite: bool = False
67
-
) -> None:
68
-
user_config = profile.root / "config" / "config.py"
69
-
with user_config.open(mode="w" if overwrite else "x") as dest_config:
70
-
out = partial(print, file=dest_config)
71
-
out("config.load_autoconfig()")
72
-
title_prefix = "{perc}{current_title}{title_sep}"
73
-
out(f"c.window.title_format = '{title_prefix} qutebrowser ({profile.name})'")
74
-
if home_page:
75
-
out(f"c.url.start_pages = ['{home_page}']")
76
-
main_config_dir = user_config_dir()
77
-
out(f"config.source('{main_config_dir / 'config.py'}')")
78
-
for conf in main_config_dir.glob("conf.d/*.py"):
79
-
out(f"config.source('{conf}')")
80
-
81
-
82
-
application_dir = Path(BaseDirectory.xdg_data_home) / "applications" / "qbpm"
83
-
84
-
85
-
def create_desktop_file(profile: Profile):
86
-
desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
87
-
desktop.set("Name", f"{profile.name} (qutebrowser profile)")
88
-
# TODO allow passing in an icon value
89
-
desktop.set("Icon", "qutebrowser")
90
-
desktop.set("Exec", " ".join(profile.cmdline()) + " %u")
91
-
desktop.set("Categories", ["Network"])
92
-
desktop.set("Terminal", False)
93
-
desktop.set("StartupNotify", True)
94
-
desktop.write()
95
-
96
-
97
-
def ensure_profile_exists(profile: Profile, create: bool = True) -> bool:
98
-
if profile.root.exists() and not profile.root.is_dir():
99
-
error(f"{profile.root} is not a directory")
100
-
return False
101
-
if not profile.root.exists() and create:
102
-
return new_profile(profile)
103
-
if not profile.root.exists():
104
-
error(f"{profile.root} does not exist")
105
-
return False
106
-
return True
107
-
108
-
109
-
def new_profile(
110
-
profile: Profile,
111
-
home_page: Optional[str] = None,
112
-
desktop_file: bool = True,
113
-
overwrite: bool = False,
114
-
) -> bool:
115
-
if create_profile(profile, overwrite):
116
-
create_config(profile, home_page, overwrite)
117
-
if desktop_file:
118
-
create_desktop_file(profile)
119
-
return True
120
-
return False
···
-45
qbpm/utils.py
-45
qbpm/utils.py
···
1
-
import platform
2
-
import subprocess
3
-
import sys
4
-
from pathlib import Path
5
-
from shutil import which
6
-
from sys import exit, stderr
7
-
from typing import Optional
8
-
9
-
from xdg import BaseDirectory # type: ignore
10
-
11
-
SUPPORTED_MENUS = ["wofi", "rofi", "dmenu", "dmenu-wl", "applescript"]
12
-
13
-
14
-
def error(msg: str) -> None:
15
-
print(f"Error: {msg}", file=stderr)
16
-
17
-
18
-
def user_data_dir() -> Path:
19
-
if platform.system() == "Linux":
20
-
return Path(BaseDirectory.xdg_data_home) / "qutebrowser"
21
-
if platform.system() == "Darwin":
22
-
return Path.home() / "Library" / "Application Support" / "qutebrowser"
23
-
error("This operation is only implemented for linux and macOS.")
24
-
print(
25
-
"If you're interested in adding support for another OS, send a PR "
26
-
"to github.com/pvsr/qbpm adding the location of qutebrowser data such "
27
-
"as history.sqlite on your OS to user_data_dir() in qbpm/utils.py.",
28
-
file=stderr,
29
-
)
30
-
exit(1)
31
-
32
-
33
-
def user_config_dir() -> Path:
34
-
return Path(BaseDirectory.xdg_config_home) / "qutebrowser"
35
-
36
-
37
-
def get_default_menu() -> Optional[str]:
38
-
if sys.platform == "darwin":
39
-
return "applescript"
40
-
for menu_cmd in SUPPORTED_MENUS:
41
-
if menu_cmd == "applescript":
42
-
continue
43
-
if which(menu_cmd) is not None:
44
-
return menu_cmd
45
-
return None
···
+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_
-15
setup.py
-15
setup.py
···
1
-
from setuptools import setup, find_packages
2
-
3
-
setup(
4
-
name="qbpm",
5
-
version="0.6",
6
-
url="https://github.com/pvsr/qbpm",
7
-
packages=find_packages(),
8
-
entry_points={"console_scripts": ["qbpm = qbpm.main:main"]},
9
-
install_requires=["pyxdg"],
10
-
author="Peter Rice",
11
-
author_email="peter@peterrice.xyz",
12
-
description="qutebrowser profile manager",
13
-
use_scm_version={"write_to": "qbpm/version.py"},
14
-
setup_requires=["setuptools_scm"],
15
-
)
···
-14
shell.nix
-14
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()
+94
-11
tests/test_main.py
+94
-11
tests/test_main.py
···
1
-
from os import environ
2
from pathlib import Path
3
4
from qbpm.main import main
5
6
7
def test_profile_dir_option(tmp_path: Path):
8
-
main(["-P", str(tmp_path), "new", "test"])
9
-
assert list(tmp_path.iterdir()) == [tmp_path / "test"]
10
11
12
def test_profile_dir_env(tmp_path: Path):
13
-
environ["QPM_PROFILE_DIR"] = str(tmp_path)
14
-
main(["new", "test"])
15
-
assert list(tmp_path.iterdir()) == [tmp_path / "test"]
16
17
18
-
def test_from_session(tmp_path: Path):
19
-
environ["QPM_PROFILE_DIR"] = str(tmp_path)
20
session = tmp_path / "test.yml"
21
-
session.touch()
22
-
main(["from-session", str(session)])
23
-
assert set(tmp_path.iterdir()) == {session, tmp_path / "test"}
···
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()
+95
-30
tests/test_profiles.py
+95
-30
tests/test_profiles.py
···
1
from pathlib import Path
2
-
from typing import Optional
3
4
from qbpm import profiles
5
from qbpm.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]
···
52
53
54
def test_create_config(tmp_path: Path):
55
profile = Profile("test", tmp_path)
56
config_dir = profile.root / "config"
57
config_dir.mkdir(parents=True)
58
-
profiles.create_config(profile)
59
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
60
61
62
def test_overwrite_config(tmp_path: Path):
63
profile = Profile("test", tmp_path)
64
url = "http://example.com"
65
config_dir = profile.root / "config"
66
config_dir.mkdir(parents=True)
67
-
profiles.create_config(profile)
68
-
profiles.create_config(profile, url, True)
69
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
70
-
with open(config_dir / "config.py") as conf:
71
-
for line in conf:
72
-
if url in line:
73
-
return
74
-
assert False
75
76
77
-
def test_ensure_profile_exists_exists(tmp_path: Path):
78
profile = Profile("test", tmp_path)
79
-
profile.root.mkdir()
80
-
assert profiles.ensure_profile_exists(profile, False)
81
-
assert profiles.ensure_profile_exists(profile, True)
82
-
check_is_empty(profile.root)
83
84
85
-
def test_ensure_profile_exists_does_not_exist(tmp_path: Path):
86
-
assert not profiles.ensure_profile_exists(Profile("test", tmp_path), False)
87
-
check_is_empty(tmp_path)
88
89
90
-
def test_ensure_profile_exists_not_dir(tmp_path: Path):
91
profile = Profile("test", tmp_path)
92
-
profile.root.touch()
93
-
assert not profiles.ensure_profile_exists(profile, False)
94
-
assert not profiles.ensure_profile_exists(profile, True)
95
96
97
-
def test_ensure_profile_exists_create(tmp_path: Path):
98
-
profile = Profile("test", tmp_path)
99
-
assert profiles.ensure_profile_exists(profile, True)
100
check_new_profile(profile)
101
102
103
-
def test_new_profile(tmp_path: Path):
104
profile = Profile("test", tmp_path)
105
-
assert profiles.new_profile(profile)
106
-
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]
···
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)