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 - - 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 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 -12
.builds/nix.yml
··· 4 4 environment: 5 5 NIX_CONFIG: "experimental-features = nix-command flakes" 6 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 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
+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 + 1 34 # 1.0rc4 2 35 - `choose`: support `walker`, `tofi`, and `wmenu` 3 36 - better detection of invalid/nonexistent profiles ··· 20 53 - `qbpm launch`'s `-n`/`--new` renamed to `-c`/`--create` 21 54 - expand fish shell completions 22 55 23 - # 1.0rc: 56 + # 1.0rc1: 24 57 - add a man page 25 58 26 59 # 0.6
+1
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
+2 -2
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 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
-34
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 5 "lastModified": 1752950548, ··· 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 },
+51 -45
flake.nix
··· 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 ··· 52 54 license = pkgs.lib.licenses.gpl3Plus; 53 55 }; 54 56 }; 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; 57 + default = self.packages.${pkgs.system}.qbpm; 58 + }); 58 59 59 - 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 { 60 70 packages = [ 61 71 pkgs.ruff 62 - pkgs.nixfmt-rfc-style 63 - (pyprojectEnv ( 64 - ps: with ps; [ 65 - flit 66 - pytest 67 - mypy 68 - pylsp-mypy 69 - ] 70 - )) 72 + (pyprojectEnv pkgs.python3 (ps: [ 73 + ps.flit 74 + ps.pytest 75 + ps.pytest-cov 76 + ps.mypy 77 + ps.pylsp-mypy 78 + ])) 71 79 ]; 72 80 }; 81 + }); 73 82 74 - formatter = pkgs.nixfmt-tree.override { 75 - runtimeInputs = with pkgs; [ ruff ]; 83 + formatter = forAllSystems ( 84 + pkgs: 85 + pkgs.nixfmt-tree.override { 86 + runtimeInputs = [ pkgs.ruff ]; 76 87 settings = { 77 - on-unmatched = "info"; 78 88 tree-root-file = "flake.nix"; 79 89 formatter.ruff = { 80 90 command = "ruff"; 81 91 options = [ "format" ]; 82 92 includes = [ "*.py" ]; 83 - }; 84 - formatter.nixfmt = { 85 - command = "nixfmt"; 86 - includes = [ "*.nix" ]; 87 93 }; 88 94 }; 89 - }; 90 - } 91 - ); 95 + } 96 + ); 97 + }; 92 98 }
+7 -2
pyproject.toml
··· 1 1 [project] 2 2 name = "qbpm" 3 - version = "1.0" 3 + version = "2.2" 4 4 description = "qutebrowser profile manager" 5 5 license = "GPL-3.0-or-later" 6 6 license-files = ["LICENSE"] ··· 15 15 "Typing :: Typed", 16 16 ] 17 17 requires-python = ">= 3.11" 18 - dependencies = ["click", "xdg-base-dirs"] 18 + dependencies = [ 19 + "click", 20 + "xdg-base-dirs", 21 + "dacite", 22 + ] 19 23 20 24 [project.urls] 21 25 homepage = "https://github.com/pvsr/qbpm" 22 26 repository = "https://github.com/pvsr/qbpm" 27 + issues = "https://github.com/pvsr/qbpm/issues" 23 28 changelog = "https://github.com/pvsr/qbpm/blob/main/CHANGELOG.md" 24 29 25 30 [project.scripts]
+13 -10
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
+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
+97 -48
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) 193 218 if not profiles.check(profile): 194 219 sys.exit(1) 195 220 exit_with(launch_qutebrowser(profile, foreground, qb_args)) ··· 216 241 Support is built in for many X and Wayland launchers, as well as applescript dialogs. 217 242 All QB_ARGS are passed on to qutebrowser. 218 243 """ 219 - 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 + ) 220 254 221 255 222 256 @main.command() ··· 224 258 @click.pass_obj 225 259 def edit(context: Context, profile_name: str) -> None: 226 260 """Edit a profile's config.py.""" 227 - profile = Profile(profile_name, **vars(context)) 261 + profile = Profile(profile_name, context.load_config().profile_directory) 228 262 if not profiles.check(profile): 229 263 sys.exit(1) 230 264 click.edit(filename=str(profile.root / "config" / "config.py")) ··· 234 268 @click.pass_obj 235 269 def list_(context: Context) -> None: 236 270 """List existing profiles.""" 237 - for profile in sorted(context.profile_dir.iterdir()): 271 + for profile in sorted(context.load_config().profile_directory.iterdir()): 238 272 print(profile.name) 239 273 240 274 ··· 246 280 profile_name: str, 247 281 ) -> None: 248 282 """Create an XDG desktop entry for an existing profile.""" 249 - profile = Profile(profile_name, **vars(context)) 250 - 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) 251 291 252 292 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) 293 + @main.group() 294 + def config() -> None: 295 + """Commands to create a qbpm config file. 262 296 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) 297 + qbpm config default > "$(qbpm config path)" 298 + """ 299 + pass 267 300 268 - return (Profile(profile_name or session_path.stem, **vars(context)), session_path) 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="") 269 318 270 319 271 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}"]),
-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
··· 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"
+58 -28
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", ··· 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) 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) 54 83 55 84 56 85 def check(profile: Profile) -> bool: ··· 71 100 72 101 def new_profile( 73 102 profile: Profile, 74 - qb_config_dir: Path | None, 103 + config: Config, 75 104 home_page: str | None = None, 76 - desktop_file: bool | None = None, 77 105 overwrite: bool = False, 78 106 ) -> bool: 79 - 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 + ) 80 114 if not qb_config_dir: 81 115 return False 116 + if not config.config_py_template: 117 + error("no value for config_py_template in config.toml") 118 + return False 82 119 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) 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) 86 130 return True 87 131 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
+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)