qutebrowser profile manager

Compare changes

Choose any two refs to compare.

+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
··· 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
+1
.gitignore
··· 15 15 .direnv/ 16 16 .pre-commit-config.yaml 17 17 profiles/ 18 + .coverage
+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
··· 1 1 # qutebrowser profile manager 2 2 3 3 [![builds.sr.ht status](https://builds.sr.ht/~pvsr/qbpm/commits/main.svg)](https://builds.sr.ht/~pvsr/qbpm/commits/main?) 4 + [![PyPI](http://img.shields.io/pypi/v/qbpm.svg)](https://pypi.python.org/pypi/qbpm) 4 5 5 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + import os 2 + import platform 3 + import sys 4 + import tomllib 5 + from dataclasses import dataclass, field, fields 6 + from pathlib import Path 7 + 8 + import dacite 9 + 10 + from . import paths 11 + from .log import error, or_phrase 12 + 13 + DEFAULT_CONFIG_FILE = Path(__file__).parent / "config.toml" 14 + 15 + 16 + @dataclass(kw_only=True) 17 + class Config: 18 + config_py_template: str | None = None 19 + symlink_autoconfig: bool = False 20 + qutebrowser_config_directory: Path | None = None 21 + profile_directory: Path = field(default_factory=paths.default_profile_dir) 22 + generate_desktop_file: bool = platform.system() == "Linux" 23 + application_name: str = "{profile_name} (qutebrowser profile)" 24 + desktop_file_directory: Path = field( 25 + default_factory=paths.default_qbpm_application_dir 26 + ) 27 + menu: str | list[str] = field(default_factory=list) 28 + menu_prompt: str = "qutebrowser" 29 + 30 + @classmethod 31 + def load(cls, config_file: Path | None) -> "Config": 32 + config_file = config_file or DEFAULT_CONFIG_FILE 33 + try: 34 + data = tomllib.loads(config_file.read_text(encoding="utf-8")) 35 + if extra := data.keys() - {field.name for field in fields(Config)}: 36 + raise RuntimeError(f'unknown config value: "{next(iter(extra))}"') 37 + return dacite.from_dict( 38 + data_class=Config, 39 + data=data, 40 + config=dacite.Config( 41 + type_hooks={Path: lambda val: Path(val).expanduser()} 42 + ), 43 + ) 44 + except Exception as e: 45 + error(f"loading {config_file} failed with error '{e}'") 46 + sys.exit(1) 47 + 48 + 49 + def find_config(config_path: Path | None) -> Config: 50 + if not config_path: 51 + default = paths.default_qbpm_config_dir() / "config.toml" 52 + if default.is_file(): 53 + config_path = default 54 + elif config_path == Path(os.devnull): 55 + config_path = None 56 + elif not config_path.is_file(): 57 + error(f"{config_path} is not a file") 58 + sys.exit(1) 59 + return Config.load(config_path) 60 + 61 + 62 + def find_qutebrowser_config_dir( 63 + qb_config_dir: Path | None, autoconfig: bool = False 64 + ) -> Path | None: 65 + dirs = ( 66 + [qb_config_dir, qb_config_dir / "config"] 67 + if qb_config_dir 68 + else list(paths.qutebrowser_config_dirs()) 69 + ) 70 + for config_dir in dirs: 71 + if (config_dir / "config.py").exists() or ( 72 + autoconfig and (config_dir / "autoconfig.yml").exists() 73 + ): 74 + return config_dir.absolute() 75 + if autoconfig: 76 + error(f"couldn't find config.py or autoconfig.yml in {or_phrase(dirs)}") 77 + else: 78 + error(f"couldn't find config.py in {or_phrase(dirs)}") 79 + return None
+46
src/qbpm/config.toml
··· 1 + # template that new config.py files are generated from 2 + # supported placeholders: {profile_name}, {source_config_py} 3 + config_py_template = """ 4 + config.source(r'{source_config_py}') 5 + 6 + c.window.title_format += ' ({profile_name})' 7 + 8 + config.load_autoconfig() 9 + """ 10 + 11 + # symlink autoconfig.yml in new profiles if the os supports it 12 + # symlink_autoconfig = false 13 + 14 + # location to store qutebrowser profiles 15 + # profile_directory = "~/.local/share/qutebrowser-profiles" 16 + 17 + # location of the qutebrowser config to inherit from 18 + # qutebrowser_config_directory = "~/.config/qutebrowser" 19 + 20 + # when creating a profile also generate an XDG desktop file that launches the profile 21 + # defaults to true on linux 22 + # generate_desktop_file = false 23 + # desktop_file_directory = "~/.local/share/applications/qbpm" 24 + 25 + # application name in XDG desktop file (replace existing with `qbpm desktop PROFILE_NAME`) 26 + # supported placeholders: {profile_name} 27 + # application_name = "{profile_name} (qutebrowser profile)" 28 + 29 + # profile selection menu for `qbpm choose` 30 + # when not set, qbpm will try to find a menu program on your $PATH 31 + # run `qbpm choose --help` for a list of known menu programs 32 + # if menu is a known menu, dmenu-mode flags are set automatically 33 + # menu = "fuzzel" # gets turned into "fuzzel --dmenu", /path/to/fuzzel also works 34 + # otherwise menu must be a dmenu-compatible commandline 35 + # supported placeholders: {prompt}, {qb_args} 36 + # menu = "~/bin/my-dmenu" 37 + # menu = "fuzzel --dmenu --prompt '{prompt}> ' --lines 20 --width 50" 38 + # optionally menu can be written as a list to simplify quoting 39 + # menu = ["fuzzel", "--dmenu", "--prompt", "{prompt}> ", "--lines", "20", "--width", "50"] 40 + 41 + # value of {prompt} in menu commands 42 + # supported placeholders: {qb_args} 43 + # defaults to "qutebrowser" 44 + # menu_prompt = "qbpm" 45 + # menu_prompt = "profiles" 46 + # menu_prompt = "qutebrowser {qb_args}"
+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
··· 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
··· 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:
+29 -21
src/qbpm/menus.py
··· 21 21 return which(self.name()) is not None 22 22 23 23 def command(self, _profiles: list[str], prompt: str, qb_args: str) -> list[str]: 24 + prompt = prompt.format(qb_args=qb_args) 24 25 return [arg.format(prompt=prompt, qb_args=qb_args) for arg in self.menu_command] 25 26 26 27 ··· 45 46 ] 46 47 47 48 48 - def find_menu(menu: str | None) -> Dmenu | ApplescriptMenu | None: 49 + def find_menu(menu: str | list[str] | None) -> Dmenu | ApplescriptMenu | None: 50 + if menu: 51 + dmenu = custom_dmenu(menu) 52 + if not dmenu.installed(): 53 + error(f"{dmenu.name()} not found") 54 + return None 55 + return dmenu 49 56 menus = list(supported_menus()) 50 - if not menu: 51 - found = next(filter(lambda m: m.installed(), menus), None) 52 - if not found: 53 - error( 54 - "no menu program found, use --menu to provide a dmenu-compatible menu or install one of " 55 - + or_phrase([m.name() for m in menus if isinstance(m, Dmenu)]) 56 - ) 57 - return found 58 - dmenu = custom_dmenu(menu) 59 - if not dmenu.installed(): 60 - error(f"{dmenu.name()} not found") 61 - return None 62 - return dmenu 57 + found = next(filter(lambda m: m.installed(), menus), None) 58 + if not found: 59 + error( 60 + "no menu program found, use --menu to provide a dmenu-compatible menu or install one of " 61 + + or_phrase([m.name() for m in menus if isinstance(m, Dmenu)]) 62 + ) 63 + return found 63 64 64 65 65 - def custom_dmenu(command: str) -> Dmenu: 66 - split = shlex.split(command) 66 + def custom_dmenu(command: str | list[str]) -> Dmenu: 67 + split = shlex.split(command) if isinstance(command, str) else command 67 68 if len(split) == 1 or not split[1]: 68 - name = Path(command).name 69 + command_path = Path(split[0]) 70 + name = command_path.name 69 71 for menu in supported_menus(): 70 72 if isinstance(menu, Dmenu) and menu.name() == name: 71 73 return ( 72 74 menu 73 - if name == command 74 - else replace(menu, menu_command=[command, *menu.menu_command[1::]]) 75 + if name == split[0] 76 + else replace( 77 + menu, 78 + menu_command=[ 79 + str(command_path.expanduser()), 80 + *menu.menu_command[1::], 81 + ], 82 + ) 75 83 ) 76 84 return Dmenu(split) 77 85 ··· 83 91 yield from [ 84 92 # default window is too narrow for a long prompt 85 93 Dmenu(["fuzzel", "--dmenu"]), 86 - Dmenu(["walker", "--dmenu", "--placeholder", "{prompt} {qb_args}"]), 87 - Dmenu(["wofi", "--dmenu", "--prompt", "{prompt} {qb_args}"]), 94 + Dmenu(["walker", "--dmenu", "--placeholder", "{prompt}"]), 95 + Dmenu(["wofi", "--dmenu", "--prompt", "{prompt}"]), 88 96 Dmenu(["tofi", "--prompt-text", "{prompt}> "]), 89 97 Dmenu(["wmenu", "-p", "{prompt}"]), 90 98 Dmenu(["dmenu-wl", "--prompt", "{prompt}"]),
-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
··· 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
··· 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
··· 1 + import shutil 2 + import sys 3 + from pathlib import Path 4 + 5 + from . import Profile, profiles 6 + from .config import Config 7 + from .log import error, or_phrase 8 + from .paths import qutebrowser_data_dir 9 + 10 + 11 + def profile_from_session( 12 + session: str, 13 + profile_name: str | None, 14 + config: Config, 15 + overwrite: bool = False, 16 + ) -> Profile | None: 17 + profile, session_path = session_info( 18 + session, profile_name, config.profile_directory 19 + ) 20 + if not profiles.new_profile(profile, config, None, overwrite): 21 + return None 22 + 23 + session_dir = profile.root / "data" / "sessions" 24 + session_dir.mkdir(parents=True, exist_ok=overwrite) 25 + shutil.copy(session_path, session_dir / "_autosave.yml") 26 + 27 + return profile 28 + 29 + 30 + def session_info( 31 + session: str, profile_name: str | None, profile_dir: Path 32 + ) -> tuple[Profile, Path]: 33 + user_session_dir = qutebrowser_data_dir() / "sessions" 34 + session_paths = [] 35 + if "/" not in session: 36 + session_paths.append(user_session_dir / (session + ".yml")) 37 + session_paths.append(Path(session)) 38 + session_path = next(filter(lambda path: path.is_file(), session_paths), None) 39 + 40 + if session_path: 41 + return ( 42 + Profile( 43 + profile_name or session_path.stem, 44 + profile_dir, 45 + ), 46 + session_path, 47 + ) 48 + tried = or_phrase([str(p.resolve()) for p in session_paths]) 49 + error(f"could not find session file at {tried}") 50 + sys.exit(1)
+12
tests/__init__.py
··· 1 + from os import environ 2 + from pathlib import Path 3 + 4 + import pytest 5 + 6 + 7 + @pytest.fixture(autouse=True) 8 + def no_homedir_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 9 + environ["XDG_CONFIG_HOME"] = str(tmp_path) 10 + environ["XDG_DATA_HOME"] = str(tmp_path) 11 + monkeypatch.setattr("qbpm.paths.get_app_dir", lambda *_args, **_kwargs: tmp_path) 12 + monkeypatch.setattr("qbpm.paths.Path.home", lambda: tmp_path)
+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
··· 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
··· 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
··· 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()
+19
tests/test_menu.py
··· 1 + from qbpm.menus import Dmenu, custom_dmenu 2 + 3 + 4 + def test_menu_prompt_formatting(): 5 + dmenu = Dmenu(["my-menu", "--prompt", "{prompt}"]) 6 + cmd = dmenu.command(["p1"], "qb ({qb_args})", "example.com") 7 + assert "--prompt" in cmd 8 + assert "qb (example.com)" in cmd 9 + 10 + 11 + def test_known_custom_menu(): 12 + assert custom_dmenu(["fuzzel"]).menu_command == ["fuzzel", "--dmenu"] 13 + assert custom_dmenu("fuzzel").menu_command == ["fuzzel", "--dmenu"] 14 + assert "--dmenu" in custom_dmenu("~/bin/fuzzel").menu_command 15 + 16 + 17 + def test_custom_menu_list(): 18 + menu = ["fuzzel", "--dmenu", "--prompt", "{prompt}>"] 19 + assert custom_dmenu(menu).menu_command == menu
+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)