+15
-11
.builds/arch.yml
+15
-11
.builds/arch.yml
···
2
2
sources:
3
3
- https://git.sr.ht/~pvsr/qbpm
4
4
- https://aur.archlinux.org/python-xdg-base-dirs.git
5
+
- https://aur.archlinux.org/python-dacite.git
5
6
packages:
7
+
- ruff
8
+
- mypy
6
9
- python-pytest
7
10
tasks:
8
-
- pytest: |
9
-
cd qbpm
10
-
pytest tests
11
-
- xdg-base-dirs: |
12
-
cd python-xdg-base-dirs
13
-
makepkg -si --noconfirm
14
-
- makepkg: |
15
-
cd qbpm/contrib
16
-
sed -i 's|^source.*|source=("git+file:///home/build/qbpm")|' PKGBUILD
17
-
sudo pacman -Sy
18
-
makepkg -si --noconfirm
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
19
23
- run: |
20
24
mkdir -p ~/.config/qutebrowser
21
25
touch ~/.config/qutebrowser/config.py
+7
-6
.builds/nix.yml
+7
-6
.builds/nix.yml
···
4
4
environment:
5
5
NIX_CONFIG: "experimental-features = nix-command flakes"
6
6
tasks:
7
-
- check: |
8
-
cd qbpm
9
-
nix flake check --quiet
10
-
- build: |
11
-
cd qbpm
12
-
nix build --quiet
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
+53
-6
CHANGELOG.md
+53
-6
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
+
1
34
# 1.0rc4
2
-
- built in support for more wayland menus:
3
-
- walker
4
-
- tofi
5
-
- wmenu
35
+
- `choose`: support `walker`, `tofi`, and `wmenu`
36
+
- better detection of invalid/nonexistent profiles
6
37
7
38
# 1.0rc3
8
39
- breaking: stop sourcing files from `~/.config/qutebrowser/conf.d/`
···
17
48
- make generated `.desktop` files match qutebrowser's more closely
18
49
19
50
# 1.0rc2:
20
-
- `choose`: builtin support for `fzf` and `fuzzel`
21
-
- moved argument handling to click
51
+
- `choose`: support `fzf` and `fuzzel`
52
+
- use `click `for CLI parsing
22
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
+25
-31
README.md
+25
-31
README.md
···
1
1
# qutebrowser profile manager
2
2
3
3
[](https://builds.sr.ht/~pvsr/qbpm/commits/main?)
4
+
[](https://pypi.python.org/pypi/qbpm)
4
5
5
6
qbpm (qutebrowser profile manager) is a tool for creating, managing, and running
6
7
[qutebrowser](https://github.com/qutebrowser/qutebrowser) profiles. Profile support
···
18
19
instances of qutebrowser which can be opened and closed independently.
19
20
20
21
## Usage
21
-
Create a new profile called "python", edit its `config.py`, then launch it:
22
+
To create a new profile called "python" and launch it with the python docs open:
22
23
```
23
24
$ qbpm new python
24
-
$ qbpm edit python
25
25
$ qbpm launch python docs.python.org
26
-
$ qbpm choose # run dmenu or another launcher to pick a profile
27
26
```
28
27
29
-
`qbpm from-session` can copy the tabs of a [saved qutebrowser
30
-
session](https://qutebrowser.org/doc/help/commands.html#session-save) to a new
31
-
profile. If you have a window full of tabs related to planning a vacation, you
32
-
could save it to a session called "vacation" using `:session-save -o vacation`
33
-
in qutebrowser, then create a new profile with those tabs:
34
-
```
35
-
$ qbpm from-session vacation
36
-
```
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`.
37
30
38
-
The default profile directory is `$XDG_DATA_HOME/qutebrowser-profiles`, where
39
-
`$XDG_DATA_HOME` is usually `~/.local/share`, but you can create and launch
40
-
profiles from anywhere using `--profile-dir`/`-P`:
41
-
```
42
-
$ qbpm --profile-dir ~/dev/my-project new qb-profile
43
-
$ cd ~/dev/my-project
44
-
$ qbpm -P . launch qb-profile
45
-
# or
46
-
$ qutebrowser --basedir qb-profile
47
-
```
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.
48
44
49
45
## Installation
50
46
If you use Nix, you can install or run qbpm as a [Nix flake](https://nixos.wiki/wiki/Flakes).
···
52
48
53
49
On Arch and derivatives, you can install the AUR package: [qbpm-git](https://aur.archlinux.org/packages/qbpm-git).
54
50
55
-
Otherwise you'll need to install from source, directly or using a tool like [uv](https://docs.astral.sh/uv/guides/tools/).
56
-
Using uv you can run qbpm without installing it using
57
-
`uv tool run --with git+https://github.com/pvsr/qbpm qbpm`, or install to `~/.local/bin` with
58
-
`uv tool install --with git+https://github.com/pvsr/qbpm qbpm`.
59
-
The downside of a source installation is that the [man page](https://github.com/pvsr/qbpm/blob/main/qbpm.1.scd)
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)
60
55
and shell completions will not be installed automatically.
61
56
62
57
On Linux you can copy [`contrib/qbpm.desktop`](https://raw.githubusercontent.com/pvsr/qbpm/main/contrib/qbpm.desktop)
···
66
61
### MacOS
67
62
68
63
Nix and uv will install qbpm as a command-line application, but if you want a
69
-
native Mac application you can clone this repository or copy the contents of
70
-
[`contrib/qbpm.platypus`](https://raw.githubusercontent.com/pvsr/qbpm/main/contrib/qbpm.platypus)
71
-
to a local file, install [platypus](https://sveinbjorn.org/platypus),
72
-
and create a qbpm app by running `platypus -P qbpm.platypus /Applications/qbpm.app`.
73
-
That will also make qbpm available as a default browser in `System Preferences > General > Default web browser`.
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`.
74
68
75
69
Note that there is currently [a qutebrowser bug](https://github.com/qutebrowser/qutebrowser/issues/3719)
76
70
that results in unnecessary `file:///*` tabs being opened.
+3
-3
contrib/PKGBUILD
+3
-3
contrib/PKGBUILD
···
1
1
# Maintainer: Peter Rice <{first name}@peterrice.xyz>
2
2
3
3
pkgname=qbpm-git
4
-
pkgver=1.0.rc2.r1
4
+
pkgver=2.0.r5
5
5
pkgrel=1
6
6
pkgdesc="A profile manager for qutebrowser"
7
7
url="https://github.com/pvsr/qbpm"
8
-
license=('GPL')
8
+
license=('GPL-3.0-or-later')
9
9
sha512sums=('SKIP')
10
10
arch=('any')
11
-
depends=('python' 'python-click' 'python-xdg-base-dirs')
11
+
depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-dacite')
12
12
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
13
13
provides=('qbpm')
14
14
source=("git+https://github.com/pvsr/qbpm")
+5
-3
contrib/qbpm.desktop
+5
-3
contrib/qbpm.desktop
···
1
1
[Desktop Entry]
2
-
Type=Application
3
2
Name=qbpm
4
3
Icon=qutebrowser
5
-
Exec=qbpm choose %u
6
-
Categories=['Network']
4
+
Type=Application
5
+
Categories=Network;WebBrowser;
6
+
Exec=qbpm choose --untrusted-args %u
7
7
Terminal=False
8
8
StartupNotify=True
9
+
MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/webp;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute;
10
+
Keywords=Browser
+6
-40
flake.lock
+6
-40
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"flake-utils": {
4
-
"inputs": {
5
-
"systems": "systems"
6
-
},
7
-
"locked": {
8
-
"lastModified": 1731533236,
9
-
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
-
"owner": "numtide",
11
-
"repo": "flake-utils",
12
-
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
-
"type": "github"
14
-
},
15
-
"original": {
16
-
"owner": "numtide",
17
-
"repo": "flake-utils",
18
-
"type": "github"
19
-
}
20
-
},
21
3
"nixpkgs": {
22
4
"locked": {
23
-
"lastModified": 1748929857,
24
-
"narHash": "sha256-lcZQ8RhsmhsK8u7LIFsJhsLh/pzR9yZ8yqpTzyGdj+Q=",
5
+
"lastModified": 1752950548,
6
+
"narHash": "sha256-NS6BLD0lxOrnCiEOcvQCDVPXafX1/ek1dfJHX1nUIzc=",
25
7
"owner": "nixos",
26
8
"repo": "nixpkgs",
27
-
"rev": "c2a03962b8e24e669fb37b7df10e7c79531ff1a4",
9
+
"rev": "c87b95e25065c028d31a94f06a62927d18763fdf",
28
10
"type": "github"
29
11
},
30
12
"original": {
···
41
23
]
42
24
},
43
25
"locked": {
44
-
"lastModified": 1743085397,
45
-
"narHash": "sha256-mCJgxAltNx9uzYTpaSNr6yQtDMXnRykXL87L2bLmsPo=",
26
+
"lastModified": 1753063596,
27
+
"narHash": "sha256-el1vFxDk6DR2hKGYnMfQHR7+K4aMiJDKQRMP3gdh+ZI=",
46
28
"owner": "nix-community",
47
29
"repo": "pyproject.nix",
48
-
"rev": "af4c3ccf8cffcd49626b0455defb0f6b22cc1910",
30
+
"rev": "cac90713492f23be5f1072bae88406890b9c68f6",
49
31
"type": "github"
50
32
},
51
33
"original": {
···
56
38
},
57
39
"root": {
58
40
"inputs": {
59
-
"flake-utils": "flake-utils",
60
41
"nixpkgs": "nixpkgs",
61
42
"pyproject-nix": "pyproject-nix"
62
-
}
63
-
},
64
-
"systems": {
65
-
"locked": {
66
-
"lastModified": 1681028828,
67
-
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
68
-
"owner": "nix-systems",
69
-
"repo": "default",
70
-
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
71
-
"type": "github"
72
-
},
73
-
"original": {
74
-
"owner": "nix-systems",
75
-
"repo": "default",
76
-
"type": "github"
77
43
}
78
44
}
79
45
},
+59
-46
flake.nix
+59
-46
flake.nix
···
1
1
{
2
-
description = "Tool for creating, managing, and running qutebrowser profiles";
2
+
description = "A profile manager for qutebrowser";
3
3
4
4
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
5
5
inputs.pyproject-nix.url = "github:nix-community/pyproject.nix";
6
6
inputs.pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
7
-
inputs.flake-utils.url = "github:numtide/flake-utils";
8
7
9
8
outputs =
10
9
{
11
10
self,
12
11
nixpkgs,
13
12
pyproject-nix,
14
-
flake-utils,
15
13
}:
16
-
flake-utils.lib.eachDefaultSystem (
17
-
system:
18
-
let
19
-
pkgs = nixpkgs.legacyPackages.${system};
20
-
pyproject = pyproject-nix.lib.project.loadPyproject { projectRoot = ./.; };
21
-
python = pkgs.python3;
22
-
pyprojectPackage =
23
-
args:
24
-
python.pkgs.buildPythonApplication (
25
-
args // pyproject.renderers.buildPythonPackage { inherit python; }
26
-
);
27
-
pyprojectEnv =
28
-
extraPackages:
29
-
python.withPackages (pyproject.renderers.withPackages { inherit python extraPackages; });
30
-
in
31
-
{
32
-
packages.qbpm = pyprojectPackage {
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 {
33
36
nativeBuildInputs = [
34
37
pkgs.scdoc
35
38
pkgs.installShellFiles
36
39
];
37
-
nativeCheckInputs = [ python.pkgs.pytestCheckHook ];
38
-
doInstallCheck = true;
39
-
installCheckPhase = "$out/bin/qbpm --help";
40
+
nativeCheckInputs = [ pkgs.python3.pkgs.pytestCheckHook ];
41
+
postInstallCheck = "$out/bin/qbpm --help";
40
42
postInstall = ''
41
43
_QBPM_COMPLETE=bash_source $out/bin/qbpm > completions/qbpm.bash
42
44
_QBPM_COMPLETE=zsh_source $out/bin/qbpm > completions/qbpm.zsh
···
44
46
scdoc < qbpm.1.scd > qbpm.1
45
47
installManPage qbpm.1
46
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
+
};
47
56
};
48
-
packages.default = self.packages.${system}.qbpm;
49
-
apps.qbpm = flake-utils.lib.mkApp { drv = self.packages.${system}.qbpm; };
50
-
apps.default = self.apps.${system}.qbpm;
57
+
default = self.packages.${pkgs.system}.qbpm;
58
+
});
51
59
52
-
devShells.default = pkgs.mkShell {
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 {
53
70
packages = [
54
71
pkgs.ruff
55
-
pkgs.nixfmt-rfc-style
56
-
(pyprojectEnv (
57
-
ps: with ps; [
58
-
flit
59
-
pytest
60
-
mypy
61
-
pylsp-mypy
62
-
]
63
-
))
72
+
(pyprojectEnv pkgs.python3 (ps: [
73
+
ps.flit
74
+
ps.pytest
75
+
ps.pytest-cov
76
+
ps.mypy
77
+
ps.pylsp-mypy
78
+
]))
64
79
];
65
80
};
81
+
});
66
82
67
-
formatter = pkgs.nixfmt-tree.override {
68
-
runtimeInputs = with pkgs; [ ruff ];
83
+
formatter = forAllSystems (
84
+
pkgs:
85
+
pkgs.nixfmt-tree.override {
86
+
runtimeInputs = [ pkgs.ruff ];
69
87
settings = {
70
-
on-unmatched = "info";
71
88
tree-root-file = "flake.nix";
72
89
formatter.ruff = {
73
90
command = "ruff";
74
91
options = [ "format" ];
75
92
includes = [ "*.py" ];
76
-
};
77
-
formatter.nixfmt = {
78
-
command = "nixfmt";
79
-
includes = [ "*.nix" ];
80
93
};
81
94
};
82
-
};
83
-
}
84
-
);
95
+
}
96
+
);
97
+
};
85
98
}
+9
-3
pyproject.toml
+9
-3
pyproject.toml
···
1
1
[project]
2
2
name = "qbpm"
3
-
version = "1.0rc4"
3
+
version = "2.2"
4
4
description = "qutebrowser profile manager"
5
5
license = "GPL-3.0-or-later"
6
6
license-files = ["LICENSE"]
7
7
readme = "README.md"
8
8
authors = [{ name = "Peter Rice", email = "peter@peterrice.xyz" }]
9
9
classifiers = [
10
-
"Development Status :: 4 - Beta",
11
10
"Environment :: Console",
12
11
"Intended Audience :: End Users/Desktop",
13
12
"Operating System :: MacOS",
···
16
15
"Typing :: Typed",
17
16
]
18
17
requires-python = ">= 3.11"
19
-
dependencies = ["click", "xdg-base-dirs"]
18
+
dependencies = [
19
+
"click",
20
+
"xdg-base-dirs",
21
+
"dacite",
22
+
]
20
23
21
24
[project.urls]
25
+
homepage = "https://github.com/pvsr/qbpm"
22
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"
23
29
24
30
[project.scripts]
25
31
qbpm = "qbpm.main:main"
+18
-11
qbpm.1.scd
+18
-11
qbpm.1.scd
···
6
6
7
7
# SYNOPSIS
8
8
9
-
*qbpm* [--profile-dir=<path>|-P <path>] <command> [<args>]
9
+
*qbpm* [--profile-dir=<path>|-P <path>] [--config-file|-c <path>] <command> [<args>]
10
10
11
11
# DESCRIPTION
12
12
···
30
30
Use _path_ as the profile directory instead of the default location. Takes
31
31
precedence over the QBPM_PROFILE_DIR environment variable.
32
32
33
-
*-C, --config-dir*
34
-
Source config files from the provided directory instead of the global
35
-
qutebrowser config location.
33
+
*-c, --config-file* <path>
34
+
Read configuration for qbpm from _path_. Defaults to ~/.config/qbpm/config.toml.
36
35
37
36
# COMMANDS
38
37
···
48
47
*-f, --foreground*
49
48
If --launch is set, run qutebrowser in the foreground.
50
49
51
-
*--no-desktop-file*
52
-
Do not generate an XDG desktop entry for the profile. Always true on
53
-
non-linux systems. See https://wiki.archlinux.org/title/Desktop_entries
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
54
57
for information on desktop entries.
55
58
56
59
*--overwrite*
···
58
61
already exists. --overwrite disables this check and replaces the existing
59
62
profile's configuration files. Profile data is left untouched.
60
63
61
-
*launch* [options] <profile> [argument...]
64
+
*launch* [options] <profile> [arguments...]
62
65
Start qutebrowser with --basedir set to the location of _profile_. All
63
66
arguments following _profile_ will be passed on to qutebrowser.
64
67
···
80
83
qbpm launch -n qb-dev --debug --json-logging
81
84
```
82
85
83
-
*choose* [options]
86
+
*choose* [options] [arguments...]
84
87
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
85
88
to dmenu or another compatible menu program such as rofi, and on macOS this
86
-
will be an applescript dialog.
89
+
will be an applescript dialog. All arguments are passed to qutebrowser.
87
90
88
91
*-m, --menu* <menu>
89
92
Use _menu_ instead of the default menu program. This may be the name of a
···
126
129
127
130
Peter Rice
128
131
129
-
Contribute at https://github.com/pvsr/qbpm
132
+
# CONTRIBUTE
133
+
134
+
_https://github.com/pvsr/qbpm_
135
+
136
+
_https://codeberg.org/pvsr/qbpm_
+5
-9
src/qbpm/__init__.py
+5
-9
src/qbpm/__init__.py
···
1
1
from pathlib import Path
2
-
from typing import Optional
3
2
4
3
from .log import error
5
4
from .paths import qutebrowser_exe
···
20
19
self.profile_dir = profile_dir
21
20
self.root = self.profile_dir / name
22
21
23
-
def check(self) -> Optional["Profile"]:
24
-
if "/" in self.name:
25
-
error("profile name cannot contain slashes")
26
-
return None
27
-
return self
28
-
29
-
def exists(self) -> bool:
30
-
return self.root.exists() and self.root.is_dir()
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
31
27
32
28
def cmdline(self) -> list[str]:
33
29
return [
+6
-2
src/qbpm/choose.py
+6
-2
src/qbpm/choose.py
···
8
8
9
9
10
10
def choose_profile(
11
-
profile_dir: Path, menu: str | None, foreground: bool, qb_args: tuple[str, ...]
11
+
profile_dir: Path,
12
+
menu: str | list[str],
13
+
prompt: str,
14
+
foreground: bool,
15
+
qb_args: tuple[str, ...],
12
16
) -> bool:
13
17
dmenu = find_menu(menu)
14
18
if not dmenu:
···
19
23
error("no profiles")
20
24
return False
21
25
profiles = [*real_profiles, "qutebrowser"]
22
-
command = dmenu.command(sorted(profiles), "qutebrowser", " ".join(qb_args))
26
+
command = dmenu.command(sorted(profiles), prompt, " ".join(qb_args))
23
27
selection_cmd = subprocess.run(
24
28
command,
25
29
text=True,
+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}"
+6
-5
src/qbpm/desktop.py
+6
-5
src/qbpm/desktop.py
···
2
2
from pathlib import Path
3
3
4
4
from . import Profile
5
-
from .paths import default_qbpm_application_dir
6
5
7
6
MIME_TYPES = [
8
7
"text/html",
···
19
18
]
20
19
21
20
22
-
# TODO expose application_dir through config
23
-
def create_desktop_file(profile: Profile, application_dir: Path | None = None) -> None:
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)
24
25
text = textwrap.dedent(f"""\
25
26
[Desktop Entry]
26
-
Name={profile.name} (qutebrowser profile)
27
+
Name={application_name}
27
28
StartupWMClass=qutebrowser
28
29
GenericName={profile.name}
29
30
Icon=qutebrowser
···
44
45
Name=Preferences
45
46
Exec={" ".join([*profile.cmdline(), '"qute://settings"'])}
46
47
""")
47
-
application_dir = application_dir or default_qbpm_application_dir()
48
+
application_dir.mkdir(parents=True, exist_ok=True)
48
49
(application_dir / f"{profile.name}.desktop").write_text(text)
+1
-1
src/qbpm/launch.py
+1
-1
src/qbpm/launch.py
···
24
24
p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
25
25
try:
26
26
# give qb a chance to validate input before returning to shell
27
-
stdout, stderr = p.communicate(timeout=0.1)
27
+
_stdout, stderr = p.communicate(timeout=0.1)
28
28
print(stderr.decode(errors="ignore"), end="")
29
29
except subprocess.TimeoutExpired:
30
30
pass
+100
-50
src/qbpm/main.py
+100
-50
src/qbpm/main.py
···
8
8
9
9
import click
10
10
11
-
from . import Profile, operations, profiles
11
+
from . import Profile, profiles
12
12
from .choose import choose_profile
13
+
from .config import DEFAULT_CONFIG_FILE, Config, find_config
14
+
from .desktop import create_desktop_file
13
15
from .launch import launch_qutebrowser
14
-
from .log import error, or_phrase
15
16
from .menus import supported_menus
16
-
from .paths import default_profile_dir, qutebrowser_data_dir
17
+
from .paths import default_qbpm_config_dir
18
+
from .session import profile_from_session
17
19
18
-
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
20
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"], "max_content_width": 91}
19
21
20
22
21
23
@dataclass
22
24
class Context:
23
-
profile_dir: Path
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
24
33
25
34
26
35
@dataclass
···
28
37
qb_config_dir: Path | None
29
38
launch: bool
30
39
foreground: bool
31
-
desktop_file: bool
40
+
desktop_file: bool | None
32
41
overwrite: bool
33
42
34
43
···
41
50
qb_config_dir: Path | None,
42
51
launch: bool,
43
52
foreground: bool,
44
-
desktop_file: bool,
53
+
desktop_file: bool | None,
45
54
overwrite: bool,
46
55
*args: Any, # noqa: ANN401
47
56
**kwargs: Any, # noqa: ANN401
···
61
70
"--qutebrowser-config-dir",
62
71
"qb_config_dir",
63
72
type=click.Path(file_okay=False, readable=True, path_type=Path),
64
-
help="Location of the qutebrowser config to inherit from.",
73
+
help="Location of the qutebrowser config to source.",
65
74
),
66
75
click.option("-l", "--launch", is_flag=True, help="Launch the profile."),
67
76
click.option(
···
71
80
help="If --launch is set, run qutebrowser in the foreground.",
72
81
),
73
82
click.option(
74
-
"--no-desktop-file",
75
-
"desktop_file",
76
-
default=True,
77
-
is_flag=True,
78
-
flag_value=False,
79
-
help="Do not generate an XDG desktop entry for the profile.",
83
+
"--desktop-file/--no-desktop-file",
84
+
default=None,
85
+
help="Generate an XDG desktop entry for the profile.",
80
86
),
81
87
click.option(
82
88
"--overwrite",
···
102
108
"--profile-dir",
103
109
type=click.Path(file_okay=False, writable=True, path_type=Path),
104
110
envvar="QBPM_PROFILE_DIR",
105
-
show_envvar=True,
111
+
show_envvar=False,
106
112
default=None,
107
113
help="Location to store qutebrowser profiles.",
108
114
)
109
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(
110
122
"-l",
111
123
"--log-level",
112
124
default="error",
113
125
type=click.Choice(["debug", "info", "error"], case_sensitive=False),
114
126
)
115
127
@click.pass_context
116
-
def main(ctx: click.Context, profile_dir: Path | None, log_level: str) -> None:
128
+
def main(
129
+
ctx: click.Context,
130
+
profile_dir: Path | None,
131
+
config_file: Path | None,
132
+
log_level: str,
133
+
) -> None:
117
134
root_logger = logging.getLogger()
118
135
root_logger.setLevel(log_level.upper())
119
136
handler = logging.StreamHandler()
120
137
handler.setFormatter(LowerCaseFormatter("{levelname}: {message}", style="{"))
121
138
root_logger.addHandler(handler)
122
-
ctx.obj = Context(profile_dir or default_profile_dir())
139
+
ctx.obj = Context(profile_dir, config_file)
123
140
124
141
125
142
@main.command()
···
134
151
c_opts: CreatorOptions,
135
152
) -> None:
136
153
"""Create a new profile."""
137
-
profile = Profile(profile_name, **vars(context))
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
138
160
exit_with(
139
161
profiles.new_profile(
140
162
profile,
141
-
c_opts.qb_config_dir,
163
+
config,
142
164
home_page,
143
-
c_opts.desktop_file,
144
165
c_opts.overwrite,
145
166
)
146
167
and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground))
···
163
184
SESSION may be the name of a session in the global qutebrowser profile
164
185
or a path to a session yaml file.
165
186
"""
166
-
profile, session_path = session_info(session, profile_name, context)
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
+
)
167
198
exit_with(
168
-
operations.from_session(
169
-
profile,
170
-
session_path,
171
-
c_opts.qb_config_dir,
172
-
c_opts.desktop_file,
173
-
c_opts.overwrite,
174
-
)
199
+
profile is not None
175
200
and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground))
176
201
)
177
202
···
189
214
"""Launch qutebrowser with a specific profile.
190
215
191
216
All QB_ARGS are passed on to qutebrowser."""
192
-
profile = Profile(profile_name, **vars(context))
217
+
profile = Profile(profile_name, context.load_config().profile_directory)
218
+
if not profiles.check(profile):
219
+
sys.exit(1)
193
220
exit_with(launch_qutebrowser(profile, foreground, qb_args))
194
221
195
222
···
214
241
Support is built in for many X and Wayland launchers, as well as applescript dialogs.
215
242
All QB_ARGS are passed on to qutebrowser.
216
243
"""
217
-
exit_with(choose_profile(context.profile_dir, menu, foreground, qb_args))
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
+
)
218
254
219
255
220
256
@main.command()
···
222
258
@click.pass_obj
223
259
def edit(context: Context, profile_name: str) -> None:
224
260
"""Edit a profile's config.py."""
225
-
profile = Profile(profile_name, **vars(context))
226
-
if not profile.exists():
227
-
error(f"profile {profile.name} not found at {profile.root}")
261
+
profile = Profile(profile_name, context.load_config().profile_directory)
262
+
if not profiles.check(profile):
228
263
sys.exit(1)
229
264
click.edit(filename=str(profile.root / "config" / "config.py"))
230
265
···
233
268
@click.pass_obj
234
269
def list_(context: Context) -> None:
235
270
"""List existing profiles."""
236
-
for profile in sorted(context.profile_dir.iterdir()):
271
+
for profile in sorted(context.load_config().profile_directory.iterdir()):
237
272
print(profile.name)
238
273
239
274
···
245
280
profile_name: str,
246
281
) -> None:
247
282
"""Create an XDG desktop entry for an existing profile."""
248
-
profile = Profile(profile_name, **vars(context))
249
-
exit_with(operations.desktop(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.
250
296
297
+
qbpm config default > "$(qbpm config path)"
298
+
"""
299
+
pass
251
300
252
-
def session_info(
253
-
session: str, profile_name: str | None, context: Context
254
-
) -> tuple[Profile, Path]:
255
-
user_session_dir = qutebrowser_data_dir() / "sessions"
256
-
session_paths = []
257
-
if "/" not in session:
258
-
session_paths.append(user_session_dir / (session + ".yml"))
259
-
session_paths.append(Path(session))
260
-
session_path = next(filter(lambda path: path.is_file(), session_paths), None)
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")
261
312
262
-
if not session_path:
263
-
tried = or_phrase([str(p.resolve()) for p in session_paths])
264
-
error(f"could not find session file at {tried}")
265
-
sys.exit(1)
266
313
267
-
return (Profile(profile_name or session_path.stem, **vars(context)), session_path)
314
+
@config.command
315
+
def default() -> None:
316
+
"""Print the default qbpm config file."""
317
+
print(DEFAULT_CONFIG_FILE.read_text(), end="")
268
318
269
319
270
320
def exit_with(result: bool) -> NoReturn:
-32
src/qbpm/operations.py
-32
src/qbpm/operations.py
···
1
-
import shutil
2
-
from pathlib import Path
3
-
4
-
from . import Profile, profiles
5
-
from .desktop import create_desktop_file
6
-
from .log import error
7
-
8
-
9
-
def from_session(
10
-
profile: Profile,
11
-
session_path: Path,
12
-
qb_config_dir: Path | None,
13
-
desktop_file: bool = True,
14
-
overwrite: bool = False,
15
-
) -> bool:
16
-
if not profiles.new_profile(profile, qb_config_dir, None, desktop_file, overwrite):
17
-
return False
18
-
19
-
session_dir = profile.root / "data" / "sessions"
20
-
session_dir.mkdir(parents=True, exist_ok=overwrite)
21
-
shutil.copy(session_path, session_dir / "_autosave.yml")
22
-
23
-
return True
24
-
25
-
26
-
def desktop(profile: Profile) -> bool:
27
-
exists = profile.exists()
28
-
if exists:
29
-
create_desktop_file(profile)
30
-
else:
31
-
error(f"profile {profile.name} not found at {profile.root}")
32
-
return exists
+15
-18
src/qbpm/paths.py
+15
-18
src/qbpm/paths.py
···
1
1
import platform
2
+
from collections.abc import Iterator
2
3
from pathlib import Path
3
4
4
5
from click import get_app_dir
···
7
8
8
9
def qutebrowser_exe() -> str:
9
10
macos_app = "/Applications/qutebrowser.app/Contents/MacOS/qutebrowser"
10
-
if platform == "darwin" and Path(macos_app).exists():
11
+
if platform.system() == "Darwin" and Path(macos_app).exists():
11
12
return macos_app
12
13
else:
13
14
return "qutebrowser"
14
15
15
16
17
+
def default_qbpm_config_dir() -> Path:
18
+
return xdg_config_home() / "qbpm"
19
+
20
+
16
21
def default_qbpm_application_dir() -> Path:
17
-
path = xdg_data_home() / "applications" / "qbpm"
18
-
path.mkdir(parents=True, exist_ok=True)
19
-
return path
22
+
return xdg_data_home() / "applications" / "qbpm"
20
23
21
24
22
25
def default_profile_dir() -> Path:
23
-
path = xdg_data_home() / "qutebrowser-profiles"
24
-
path.mkdir(parents=True, exist_ok=True)
25
-
return path
26
+
return xdg_data_home() / "qutebrowser-profiles"
26
27
27
28
28
29
def qutebrowser_data_dir() -> Path:
···
32
33
return Path(get_app_dir("qutebrowser", roaming=True))
33
34
34
35
35
-
def qutebrowser_config_dirs() -> list[Path]:
36
-
# deduplicate while maintaining order
37
-
return list(
38
-
dict.fromkeys(
39
-
[
40
-
Path(get_app_dir("qutebrowser", roaming=True)),
41
-
xdg_config_home() / "qutebrowser",
42
-
Path.home() / ".qutebrowser",
43
-
]
44
-
)
45
-
)
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"
+69
-33
src/qbpm/profiles.py
+69
-33
src/qbpm/profiles.py
···
1
1
from functools import partial
2
2
from pathlib import Path
3
-
from sys import platform
4
3
5
4
from . import Profile
5
+
from .config import Config, find_qutebrowser_config_dir
6
6
from .desktop import create_desktop_file
7
-
from .log import error, or_phrase
8
-
from .paths import qutebrowser_config_dirs
7
+
from .log import error, info
9
8
10
9
MIME_TYPES = [
11
10
"text/html",
···
23
22
24
23
25
24
def create_profile(profile: Profile, overwrite: bool = False) -> bool:
26
-
if not profile.check():
25
+
if not profile.check_name():
27
26
return False
28
27
29
28
if not overwrite and profile.root.exists():
···
32
31
33
32
config_dir = profile.root / "config"
34
33
config_dir.mkdir(parents=True, exist_ok=overwrite)
35
-
print(profile.root)
36
34
return True
37
35
38
36
39
37
def create_config(
40
38
profile: Profile,
41
39
qb_config_dir: Path,
40
+
config_py_template: str,
42
41
home_page: str | None = None,
43
42
overwrite: bool = False,
44
43
) -> None:
44
+
source = qb_config_dir / "config.py"
45
+
if not source.is_file():
46
+
return
45
47
user_config = profile.root / "config" / "config.py"
48
+
if overwrite and user_config.exists():
49
+
back_up(user_config)
46
50
with user_config.open(mode="w" if overwrite else "x") as dest_config:
47
51
out = partial(print, file=dest_config)
48
-
out("config.load_autoconfig()")
49
-
title_prefix = "{perc}{current_title}{title_sep}"
50
-
out(f"c.window.title_format = '{title_prefix} qutebrowser ({profile.name})'")
52
+
out(
53
+
config_py_template.format(
54
+
profile_name=profile.name,
55
+
source_config_py=source,
56
+
)
57
+
)
58
+
# TODO move to template?
51
59
if home_page:
52
60
out(f"c.url.start_pages = ['{home_page}']")
53
-
out(f"config.source(r'{qb_config_dir / 'config.py'}')")
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)
54
77
55
78
56
-
def exists(profile: Profile) -> bool:
57
-
if profile.root.exists() and not profile.root.is_dir():
58
-
error(f"{profile.root} is not a directory")
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():
59
87
return False
60
-
if not profile.root.exists():
88
+
exists = profile.root.exists()
89
+
if not exists:
61
90
error(f"{profile.root} does not exist")
62
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
63
98
return True
64
99
65
100
66
101
def new_profile(
67
102
profile: Profile,
68
-
qb_config_dir: Path | None,
103
+
config: Config,
69
104
home_page: str | None = None,
70
-
desktop_file: bool | None = None,
71
105
overwrite: bool = False,
72
106
) -> bool:
73
-
qb_config_dir = find_qutebrowser_config_dir(qb_config_dir)
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
+
)
74
114
if not qb_config_dir:
75
115
return False
116
+
if not config.config_py_template:
117
+
error("no value for config_py_template in config.toml")
118
+
return False
76
119
if create_profile(profile, overwrite):
77
-
create_config(profile, qb_config_dir, home_page, overwrite)
78
-
if desktop_file is True or (desktop_file is not False and platform == "linux"):
79
-
create_desktop_file(profile)
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)
80
130
return True
81
131
return False
82
-
83
-
84
-
def find_qutebrowser_config_dir(qb_config_dir: Path | None) -> Path | None:
85
-
config_file = "config.py"
86
-
dirs = (
87
-
[qb_config_dir, qb_config_dir / "config"]
88
-
if qb_config_dir
89
-
else qutebrowser_config_dirs()
90
-
)
91
-
for config_dir in dirs:
92
-
if (config_dir / config_file).exists():
93
-
return config_dir.absolute()
94
-
error(f"could not find {config_file} in {or_phrase(dirs)}")
95
-
return None
+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)
+3
-1
tests/test_choose.py
+3
-1
tests/test_choose.py
···
3
3
4
4
from qbpm.choose import choose_profile, find_menu
5
5
6
+
from . import no_homedir_fixture # noqa: F401
7
+
6
8
7
9
def write_script(parent_dir: Path, name: str = "menu", contents: str = "") -> Path:
8
10
parent_dir.mkdir(exist_ok=True)
···
27
29
profile_dir.mkdir()
28
30
(profile_dir / "p1").mkdir()
29
31
(profile_dir / "p2").mkdir()
30
-
assert choose_profile(profile_dir, str(menu), False, ())
32
+
assert choose_profile(profile_dir, str(menu), "", False, ())
31
33
assert log.read_text().startswith(
32
34
f"""p1
33
35
p2
+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
+10
-1
tests/test_desktop.py
+10
-1
tests/test_desktop.py
···
1
1
from pathlib import Path
2
2
3
3
from qbpm import Profile
4
+
from qbpm.config import Config
4
5
from qbpm.desktop import create_desktop_file
5
6
6
7
TEST_DIR = Path(__file__).resolve().parent
···
10
11
application_path = tmp_path / "applications"
11
12
application_path.mkdir()
12
13
profile = Profile("test", tmp_path)
13
-
create_desktop_file(profile, application_path)
14
+
create_desktop_file(profile, application_path, Config.load(None).application_name)
14
15
assert (application_path / "test.desktop").read_text() == (
15
16
TEST_DIR / "test.desktop"
16
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()
+58
-16
tests/test_main.py
+58
-16
tests/test_main.py
···
5
5
6
6
from qbpm.main import main
7
7
8
-
no_desktop = "--no-desktop-file"
8
+
from . import no_homedir_fixture # noqa: F401
9
+
10
+
11
+
def run(*args: str):
12
+
return CliRunner().invoke(main, args)
9
13
10
14
11
15
def test_profile_dir_option(tmp_path: Path):
12
16
(tmp_path / "config.py").touch()
13
-
runner = CliRunner()
14
-
result = runner.invoke(
15
-
main, ["-P", str(tmp_path), "new", "-C", str(tmp_path), no_desktop, "test"]
16
-
)
17
+
result = run("-P", str(tmp_path), "new", "-C", str(tmp_path), "test")
17
18
assert result.exit_code == 0
18
19
assert result.output.strip() == str(tmp_path / "test")
19
20
assert tmp_path / "test" in list(tmp_path.iterdir())
21
+
assert (tmp_path / "applications" / "qbpm" / "test.desktop").exists()
20
22
21
23
22
24
def test_profile_dir_env(tmp_path: Path):
23
25
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
24
26
(tmp_path / "config.py").touch()
25
-
runner = CliRunner()
26
-
result = runner.invoke(main, ["new", "-C", str(tmp_path), no_desktop, "test"])
27
+
result = run("new", "-C", str(tmp_path), "test")
27
28
assert result.exit_code == 0
28
29
assert result.output.strip() == str(tmp_path / "test")
29
30
assert tmp_path / "test" in list(tmp_path.iterdir())
···
33
34
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
34
35
config = tmp_path / "config.py"
35
36
config.touch()
36
-
runner = CliRunner()
37
-
result = runner.invoke(main, ["new", "-C", str(tmp_path), no_desktop, "test"])
37
+
result = run("new", "-C", str(tmp_path), "test")
38
38
assert result.exit_code == 0
39
39
assert str(config) in (tmp_path / "test/config/config.py").read_text()
40
40
···
44
44
config = tmp_path / "config.py"
45
45
config.touch()
46
46
chdir(tmp_path)
47
-
runner = CliRunner()
48
-
result = runner.invoke(main, ["new", "-C", ".", no_desktop, "test"])
47
+
result = run("new", "-C", ".", "test")
49
48
assert result.exit_code == 0
50
49
assert str(config) in (tmp_path / "test/config/config.py").read_text()
51
50
52
51
53
-
def test_from_session(tmp_path: Path):
52
+
def test_from_session_path(tmp_path: Path):
54
53
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
55
54
(tmp_path / "config.py").touch()
56
55
session = tmp_path / "test.yml"
57
56
session.write_text("windows:\n")
58
-
runner = CliRunner()
59
-
result = runner.invoke(
60
-
main, ["from-session", "-C", str(tmp_path), no_desktop, str(session)]
61
-
)
57
+
result = run("from-session", "-C", str(tmp_path), str(session))
62
58
assert result.exit_code == 0
63
59
assert result.output.strip() == str(tmp_path / "test")
64
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()
+102
-11
tests/test_profiles.py
+102
-11
tests/test_profiles.py
···
1
1
from pathlib import Path
2
2
3
3
from qbpm import profiles
4
+
from qbpm.config import Config
4
5
from qbpm.profiles import Profile
6
+
7
+
from . import no_homedir_fixture # noqa: F401
5
8
6
9
7
10
def check_is_empty(path: Path):
···
51
54
52
55
53
56
def test_create_config(tmp_path: Path):
57
+
(tmp_path / "config.py").touch()
54
58
profile = Profile("test", tmp_path)
55
59
config_dir = profile.root / "config"
56
60
config_dir.mkdir(parents=True)
57
-
profiles.create_config(profile, tmp_path)
58
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
61
+
profiles.create_config(profile, tmp_path, "{source_config_py}")
62
+
config = config_dir / "config.py"
63
+
assert list(config_dir.iterdir()) == [config]
64
+
assert str(tmp_path / "config.py") in config.read_text()
59
65
60
66
61
67
def test_overwrite_config(tmp_path: Path):
68
+
(tmp_path / "config.py").touch()
62
69
profile = Profile("test", tmp_path)
63
70
url = "http://example.com"
64
71
config_dir = profile.root / "config"
65
72
config_dir.mkdir(parents=True)
66
-
profiles.create_config(profile, tmp_path)
67
-
profiles.create_config(profile, tmp_path, url, True)
68
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
69
-
with (config_dir / "config.py").open() as conf:
70
-
for line in conf:
71
-
if url in line:
72
-
return
73
-
raise AssertionError()
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
74
115
75
116
76
117
def test_new_profile(tmp_path: Path):
77
118
(tmp_path / "config.py").touch()
78
119
profile = Profile("test", tmp_path / "test")
79
-
assert profiles.new_profile(profile, tmp_path, desktop_file=False)
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)
80
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)