+15
-11
.builds/arch.yml
+15
-11
.builds/arch.yml
···
2
sources:
3
- https://git.sr.ht/~pvsr/qbpm
4
- https://aur.archlinux.org/python-xdg-base-dirs.git
5
packages:
6
- python-pytest
7
tasks:
8
-
- xdg-base-dirs: |
9
-
cd python-xdg-base-dirs
10
-
makepkg -si --noconfirm
11
-
- makepkg: |
12
-
cd qbpm/contrib
13
-
sed -i 's|^source.*|source=("git+file:///home/build/qbpm")|' PKGBUILD
14
-
sudo pacman -Sy
15
-
makepkg -si --noconfirm
16
-
- pytest: |
17
-
cd qbpm
18
-
pytest tests
19
- run: |
20
mkdir -p ~/.config/qutebrowser
21
touch ~/.config/qutebrowser/config.py
···
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
+7
-12
.builds/nix.yml
+7
-12
.builds/nix.yml
···
4
environment:
5
NIX_CONFIG: "experimental-features = nix-command flakes"
6
tasks:
7
-
- format: |
8
-
cd qbpm
9
-
nix develop --quiet -c ruff format --check
10
-
- ruff: |
11
-
cd qbpm
12
-
nix develop -c ruff check
13
-
- mypy: |
14
-
cd qbpm
15
-
nix develop -c mypy src tests
16
-
- build: |
17
-
cd qbpm
18
-
nix build --quiet
···
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
+1
.gitignore
+1
.gitignore
+34
-1
CHANGELOG.md
+34
-1
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
···
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
+1
README.md
+1
README.md
···
1
# qutebrowser profile manager
2
3
[](https://builds.sr.ht/~pvsr/qbpm/commits/main?)
4
5
qbpm (qutebrowser profile manager) is a tool for creating, managing, and running
6
[qutebrowser](https://github.com/qutebrowser/qutebrowser) profiles. Profile support
···
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
+2
-2
contrib/PKGBUILD
+2
-2
contrib/PKGBUILD
···
1
# Maintainer: Peter Rice <{first name}@peterrice.xyz>
2
3
pkgname=qbpm-git
4
-
pkgver=1.0.rc2.r1
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')
12
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
13
provides=('qbpm')
14
source=("git+https://github.com/pvsr/qbpm")
···
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")
+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
-34
flake.lock
-34
flake.lock
···
1
{
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
"nixpkgs": {
22
"locked": {
23
"lastModified": 1752950548,
···
56
},
57
"root": {
58
"inputs": {
59
-
"flake-utils": "flake-utils",
60
"nixpkgs": "nixpkgs",
61
"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
}
78
}
79
},
+51
-45
flake.nix
+51
-45
flake.nix
···
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
-
inputs.flake-utils.url = "github:numtide/flake-utils";
8
9
outputs =
10
{
11
self,
12
nixpkgs,
13
pyproject-nix,
14
-
flake-utils,
15
}:
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 {
33
nativeBuildInputs = [
34
pkgs.scdoc
35
pkgs.installShellFiles
36
];
37
-
nativeCheckInputs = [ python.pkgs.pytestCheckHook ];
38
-
doInstallCheck = true;
39
-
installCheckPhase = "$out/bin/qbpm --help";
40
postInstall = ''
41
_QBPM_COMPLETE=bash_source $out/bin/qbpm > completions/qbpm.bash
42
_QBPM_COMPLETE=zsh_source $out/bin/qbpm > completions/qbpm.zsh
···
52
license = pkgs.lib.licenses.gpl3Plus;
53
};
54
};
55
-
packages.default = self.packages.${system}.qbpm;
56
-
apps.qbpm = flake-utils.lib.mkApp { drv = self.packages.${system}.qbpm; };
57
-
apps.default = self.apps.${system}.qbpm;
58
59
-
devShells.default = pkgs.mkShell {
60
packages = [
61
pkgs.ruff
62
-
pkgs.nixfmt-rfc-style
63
-
(pyprojectEnv (
64
-
ps: with ps; [
65
-
flit
66
-
pytest
67
-
mypy
68
-
pylsp-mypy
69
-
]
70
-
))
71
];
72
};
73
74
-
formatter = pkgs.nixfmt-tree.override {
75
-
runtimeInputs = with pkgs; [ ruff ];
76
settings = {
77
-
on-unmatched = "info";
78
tree-root-file = "flake.nix";
79
formatter.ruff = {
80
command = "ruff";
81
options = [ "format" ];
82
includes = [ "*.py" ];
83
-
};
84
-
formatter.nixfmt = {
85
-
command = "nixfmt";
86
-
includes = [ "*.nix" ];
87
};
88
};
89
-
};
90
-
}
91
-
);
92
}
···
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
···
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
}
+7
-2
pyproject.toml
+7
-2
pyproject.toml
···
1
[project]
2
name = "qbpm"
3
-
version = "1.0"
4
description = "qutebrowser profile manager"
5
license = "GPL-3.0-or-later"
6
license-files = ["LICENSE"]
···
15
"Typing :: Typed",
16
]
17
requires-python = ">= 3.11"
18
-
dependencies = ["click", "xdg-base-dirs"]
19
20
[project.urls]
21
homepage = "https://github.com/pvsr/qbpm"
22
repository = "https://github.com/pvsr/qbpm"
23
changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md"
24
25
[project.scripts]
···
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"]
···
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]
+13
-10
qbpm.1.scd
+13
-10
qbpm.1.scd
···
6
7
# SYNOPSIS
8
9
-
*qbpm* [--profile-dir=<path>|-P <path>] <command> [<args>]
10
11
# DESCRIPTION
12
···
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-dir*
34
-
Source config files from the provided directory instead of the global
35
-
qutebrowser config location.
36
37
# COMMANDS
38
···
48
*-f, --foreground*
49
If --launch is set, run qutebrowser in the foreground.
50
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
54
for information on desktop entries.
55
56
*--overwrite*
···
58
already exists. --overwrite disables this check and replaces the existing
59
profile's configuration files. Profile data is left untouched.
60
61
-
*launch* [options] <profile> [argument...]
62
Start qutebrowser with --basedir set to the location of _profile_. All
63
arguments following _profile_ will be passed on to qutebrowser.
64
···
80
qbpm launch -n qb-dev --debug --json-logging
81
```
82
83
-
*choose* [options]
84
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
85
to dmenu or another compatible menu program such as rofi, and on macOS this
86
-
will be an applescript dialog.
87
88
*-m, --menu* <menu>
89
Use _menu_ instead of the default menu program. This may be the name of a
···
6
7
# SYNOPSIS
8
9
+
*qbpm* [--profile-dir=<path>|-P <path>] [--config-file|-c <path>] <command> [<args>]
10
11
# DESCRIPTION
12
···
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
···
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*
···
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
···
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
+6
-2
src/qbpm/choose.py
+6
-2
src/qbpm/choose.py
···
8
9
10
def choose_profile(
11
-
profile_dir: Path, menu: str | None, foreground: bool, qb_args: tuple[str, ...]
12
) -> bool:
13
dmenu = find_menu(menu)
14
if not dmenu:
···
19
error("no profiles")
20
return False
21
profiles = [*real_profiles, "qutebrowser"]
22
-
command = dmenu.command(sorted(profiles), "qutebrowser", " ".join(qb_args))
23
selection_cmd = subprocess.run(
24
command,
25
text=True,
···
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:
···
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,
+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
from pathlib import Path
3
4
from . import Profile
5
-
from .paths import default_qbpm_application_dir
6
7
MIME_TYPES = [
8
"text/html",
···
19
]
20
21
22
-
# TODO expose application_dir through config
23
-
def create_desktop_file(profile: Profile, application_dir: Path | None = None) -> None:
24
text = textwrap.dedent(f"""\
25
[Desktop Entry]
26
-
Name={profile.name} (qutebrowser profile)
27
StartupWMClass=qutebrowser
28
GenericName={profile.name}
29
Icon=qutebrowser
···
44
Name=Preferences
45
Exec={" ".join([*profile.cmdline(), '"qute://settings"'])}
46
""")
47
-
application_dir = application_dir or default_qbpm_application_dir()
48
(application_dir / f"{profile.name}.desktop").write_text(text)
···
2
from pathlib import Path
3
4
from . import Profile
5
6
MIME_TYPES = [
7
"text/html",
···
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
···
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)
+1
-1
src/qbpm/launch.py
+1
-1
src/qbpm/launch.py
···
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
···
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
+97
-48
src/qbpm/main.py
+97
-48
src/qbpm/main.py
···
8
9
import click
10
11
-
from . import Profile, operations, profiles
12
from .choose import choose_profile
13
from .launch import launch_qutebrowser
14
-
from .log import error, or_phrase
15
from .menus import supported_menus
16
-
from .paths import default_profile_dir, qutebrowser_data_dir
17
18
-
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
19
20
21
@dataclass
22
class Context:
23
-
profile_dir: Path
24
25
26
@dataclass
···
28
qb_config_dir: Path | None
29
launch: bool
30
foreground: bool
31
-
desktop_file: bool
32
overwrite: bool
33
34
···
41
qb_config_dir: Path | None,
42
launch: bool,
43
foreground: bool,
44
-
desktop_file: bool,
45
overwrite: bool,
46
*args: Any, # noqa: ANN401
47
**kwargs: Any, # noqa: ANN401
···
61
"--qutebrowser-config-dir",
62
"qb_config_dir",
63
type=click.Path(file_okay=False, readable=True, path_type=Path),
64
-
help="Location of the qutebrowser config to inherit from.",
65
),
66
click.option("-l", "--launch", is_flag=True, help="Launch the profile."),
67
click.option(
···
71
help="If --launch is set, run qutebrowser in the foreground.",
72
),
73
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.",
80
),
81
click.option(
82
"--overwrite",
···
102
"--profile-dir",
103
type=click.Path(file_okay=False, writable=True, path_type=Path),
104
envvar="QBPM_PROFILE_DIR",
105
-
show_envvar=True,
106
default=None,
107
help="Location to store qutebrowser profiles.",
108
)
109
@click.option(
110
"-l",
111
"--log-level",
112
default="error",
113
type=click.Choice(["debug", "info", "error"], case_sensitive=False),
114
)
115
@click.pass_context
116
-
def main(ctx: click.Context, profile_dir: Path | None, log_level: str) -> None:
117
root_logger = logging.getLogger()
118
root_logger.setLevel(log_level.upper())
119
handler = logging.StreamHandler()
120
handler.setFormatter(LowerCaseFormatter("{levelname}: {message}", style="{"))
121
root_logger.addHandler(handler)
122
-
ctx.obj = Context(profile_dir or default_profile_dir())
123
124
125
@main.command()
···
134
c_opts: CreatorOptions,
135
) -> None:
136
"""Create a new profile."""
137
-
profile = Profile(profile_name, **vars(context))
138
exit_with(
139
profiles.new_profile(
140
profile,
141
-
c_opts.qb_config_dir,
142
home_page,
143
-
c_opts.desktop_file,
144
c_opts.overwrite,
145
)
146
and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground))
···
163
SESSION may be the name of a session in the global qutebrowser profile
164
or a path to a session yaml file.
165
"""
166
-
profile, session_path = session_info(session, profile_name, context)
167
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
-
)
175
and ((not c_opts.launch) or launch_qutebrowser(profile, c_opts.foreground))
176
)
177
···
189
"""Launch qutebrowser with a specific profile.
190
191
All QB_ARGS are passed on to qutebrowser."""
192
-
profile = Profile(profile_name, **vars(context))
193
if not profiles.check(profile):
194
sys.exit(1)
195
exit_with(launch_qutebrowser(profile, foreground, qb_args))
···
216
Support is built in for many X and Wayland launchers, as well as applescript dialogs.
217
All QB_ARGS are passed on to qutebrowser.
218
"""
219
-
exit_with(choose_profile(context.profile_dir, menu, foreground, qb_args))
220
221
222
@main.command()
···
224
@click.pass_obj
225
def edit(context: Context, profile_name: str) -> None:
226
"""Edit a profile's config.py."""
227
-
profile = Profile(profile_name, **vars(context))
228
if not profiles.check(profile):
229
sys.exit(1)
230
click.edit(filename=str(profile.root / "config" / "config.py"))
···
234
@click.pass_obj
235
def list_(context: Context) -> None:
236
"""List existing profiles."""
237
-
for profile in sorted(context.profile_dir.iterdir()):
238
print(profile.name)
239
240
···
246
profile_name: str,
247
) -> None:
248
"""Create an XDG desktop entry for an existing profile."""
249
-
profile = Profile(profile_name, **vars(context))
250
-
exit_with(operations.desktop(profile))
251
252
253
-
def session_info(
254
-
session: str, profile_name: str | None, context: Context
255
-
) -> tuple[Profile, Path]:
256
-
user_session_dir = qutebrowser_data_dir() / "sessions"
257
-
session_paths = []
258
-
if "/" not in session:
259
-
session_paths.append(user_session_dir / (session + ".yml"))
260
-
session_paths.append(Path(session))
261
-
session_path = next(filter(lambda path: path.is_file(), session_paths), None)
262
263
-
if not session_path:
264
-
tried = or_phrase([str(p.resolve()) for p in session_paths])
265
-
error(f"could not find session file at {tried}")
266
-
sys.exit(1)
267
268
-
return (Profile(profile_name or session_path.stem, **vars(context)), session_path)
269
270
271
def exit_with(result: bool) -> NoReturn:
···
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
···
37
qb_config_dir: Path | None
38
launch: bool
39
foreground: bool
40
+
desktop_file: bool | None
41
overwrite: bool
42
43
···
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
···
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(
···
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",
···
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()
···
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))
···
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
···
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))
···
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()
···
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"))
···
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
···
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:
-29
src/qbpm/operations.py
-29
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
-
7
-
8
-
def from_session(
9
-
profile: Profile,
10
-
session_path: Path,
11
-
qb_config_dir: Path | None,
12
-
desktop_file: bool = True,
13
-
overwrite: bool = False,
14
-
) -> bool:
15
-
if not profiles.new_profile(profile, qb_config_dir, None, desktop_file, overwrite):
16
-
return False
17
-
18
-
session_dir = profile.root / "data" / "sessions"
19
-
session_dir.mkdir(parents=True, exist_ok=overwrite)
20
-
shutil.copy(session_path, session_dir / "_autosave.yml")
21
-
22
-
return True
23
-
24
-
25
-
def desktop(profile: Profile) -> bool:
26
-
exists = profiles.check(profile)
27
-
if exists:
28
-
create_desktop_file(profile)
29
-
return exists
···
+15
-18
src/qbpm/paths.py
+15
-18
src/qbpm/paths.py
···
1
import platform
2
from pathlib import Path
3
4
from click import get_app_dir
···
7
8
def qutebrowser_exe() -> str:
9
macos_app = "/Applications/qutebrowser.app/Contents/MacOS/qutebrowser"
10
-
if platform == "darwin" and Path(macos_app).exists():
11
return macos_app
12
else:
13
return "qutebrowser"
14
15
16
def default_qbpm_application_dir() -> Path:
17
-
path = xdg_data_home() / "applications" / "qbpm"
18
-
path.mkdir(parents=True, exist_ok=True)
19
-
return path
20
21
22
def default_profile_dir() -> Path:
23
-
path = xdg_data_home() / "qutebrowser-profiles"
24
-
path.mkdir(parents=True, exist_ok=True)
25
-
return path
26
27
28
def qutebrowser_data_dir() -> Path:
···
32
return Path(get_app_dir("qutebrowser", roaming=True))
33
34
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
-
)
···
1
import platform
2
+
from collections.abc import Iterator
3
from pathlib import Path
4
5
from click import get_app_dir
···
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:
···
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"
+58
-28
src/qbpm/profiles.py
+58
-28
src/qbpm/profiles.py
···
1
from functools import partial
2
from pathlib import Path
3
-
from sys import platform
4
5
from . import Profile
6
from .desktop import create_desktop_file
7
-
from .log import error, or_phrase
8
-
from .paths import qutebrowser_config_dirs
9
10
MIME_TYPES = [
11
"text/html",
···
32
33
config_dir = profile.root / "config"
34
config_dir.mkdir(parents=True, exist_ok=overwrite)
35
-
print(profile.root)
36
return True
37
38
39
def create_config(
40
profile: Profile,
41
qb_config_dir: Path,
42
home_page: str | None = None,
43
overwrite: bool = False,
44
) -> None:
45
user_config = profile.root / "config" / "config.py"
46
with user_config.open(mode="w" if overwrite else "x") as dest_config:
47
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})'")
51
if home_page:
52
out(f"c.url.start_pages = ['{home_page}']")
53
-
out(f"config.source(r'{qb_config_dir / 'config.py'}')")
54
55
56
def check(profile: Profile) -> bool:
···
71
72
def new_profile(
73
profile: Profile,
74
-
qb_config_dir: Path | None,
75
home_page: str | None = None,
76
-
desktop_file: bool | None = None,
77
overwrite: bool = False,
78
) -> bool:
79
-
qb_config_dir = find_qutebrowser_config_dir(qb_config_dir)
80
if not qb_config_dir:
81
return False
82
if create_profile(profile, overwrite):
83
-
create_config(profile, qb_config_dir, home_page, overwrite)
84
-
if desktop_file is True or (desktop_file is not False and platform == "linux"):
85
-
create_desktop_file(profile)
86
return True
87
return False
88
-
89
-
90
-
def find_qutebrowser_config_dir(qb_config_dir: Path | None) -> Path | None:
91
-
config_file = "config.py"
92
-
dirs = (
93
-
[qb_config_dir, qb_config_dir / "config"]
94
-
if qb_config_dir
95
-
else qutebrowser_config_dirs()
96
-
)
97
-
for config_dir in dirs:
98
-
if (config_dir / config_file).exists():
99
-
return config_dir.absolute()
100
-
error(f"could not find {config_file} in {or_phrase(dirs)}")
101
-
return None
···
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",
···
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:
···
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)
+3
-1
tests/test_choose.py
+3
-1
tests/test_choose.py
···
3
4
from qbpm.choose import choose_profile, find_menu
5
6
7
def write_script(parent_dir: Path, name: str = "menu", contents: str = "") -> Path:
8
parent_dir.mkdir(exist_ok=True)
···
27
profile_dir.mkdir()
28
(profile_dir / "p1").mkdir()
29
(profile_dir / "p2").mkdir()
30
-
assert choose_profile(profile_dir, str(menu), False, ())
31
assert log.read_text().startswith(
32
f"""p1
33
p2
···
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)
···
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
+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
from pathlib import Path
2
3
from qbpm import Profile
4
from qbpm.desktop import create_desktop_file
5
6
TEST_DIR = Path(__file__).resolve().parent
···
10
application_path = tmp_path / "applications"
11
application_path.mkdir()
12
profile = Profile("test", tmp_path)
13
-
create_desktop_file(profile, application_path)
14
assert (application_path / "test.desktop").read_text() == (
15
TEST_DIR / "test.desktop"
16
).read_text().replace("{qbpm}", " ".join(profile.cmdline()))
···
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
···
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()
+58
-16
tests/test_main.py
+58
-16
tests/test_main.py
···
5
6
from qbpm.main import main
7
8
-
no_desktop = "--no-desktop-file"
9
10
11
def test_profile_dir_option(tmp_path: Path):
12
(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
assert result.exit_code == 0
18
assert result.output.strip() == str(tmp_path / "test")
19
assert tmp_path / "test" in list(tmp_path.iterdir())
20
21
22
def test_profile_dir_env(tmp_path: Path):
23
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
24
(tmp_path / "config.py").touch()
25
-
runner = CliRunner()
26
-
result = runner.invoke(main, ["new", "-C", str(tmp_path), no_desktop, "test"])
27
assert result.exit_code == 0
28
assert result.output.strip() == str(tmp_path / "test")
29
assert tmp_path / "test" in list(tmp_path.iterdir())
···
33
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
34
config = tmp_path / "config.py"
35
config.touch()
36
-
runner = CliRunner()
37
-
result = runner.invoke(main, ["new", "-C", str(tmp_path), no_desktop, "test"])
38
assert result.exit_code == 0
39
assert str(config) in (tmp_path / "test/config/config.py").read_text()
40
···
44
config = tmp_path / "config.py"
45
config.touch()
46
chdir(tmp_path)
47
-
runner = CliRunner()
48
-
result = runner.invoke(main, ["new", "-C", ".", no_desktop, "test"])
49
assert result.exit_code == 0
50
assert str(config) in (tmp_path / "test/config/config.py").read_text()
51
52
53
-
def test_from_session(tmp_path: Path):
54
environ["QBPM_PROFILE_DIR"] = str(tmp_path)
55
(tmp_path / "config.py").touch()
56
session = tmp_path / "test.yml"
57
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
-
)
62
assert result.exit_code == 0
63
assert result.output.strip() == str(tmp_path / "test")
64
assert (tmp_path / "test/data/sessions/_autosave.yml").read_text() == ("windows:\n")
···
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())
···
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
···
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()
+102
-11
tests/test_profiles.py
+102
-11
tests/test_profiles.py
···
1
from pathlib import Path
2
3
from qbpm import profiles
4
from qbpm.profiles import Profile
5
6
7
def check_is_empty(path: Path):
···
51
52
53
def test_create_config(tmp_path: Path):
54
profile = Profile("test", tmp_path)
55
config_dir = profile.root / "config"
56
config_dir.mkdir(parents=True)
57
-
profiles.create_config(profile, tmp_path)
58
-
assert list(config_dir.iterdir()) == [config_dir / "config.py"]
59
60
61
def test_overwrite_config(tmp_path: Path):
62
profile = Profile("test", tmp_path)
63
url = "http://example.com"
64
config_dir = profile.root / "config"
65
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()
74
75
76
def test_new_profile(tmp_path: Path):
77
(tmp_path / "config.py").touch()
78
profile = Profile("test", tmp_path / "test")
79
-
assert profiles.new_profile(profile, tmp_path, desktop_file=False)
80
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):
···
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)