Repository: essembeh/gnome-extensions-cli Branch: main Commit: 73837d998be8 Files: 38 Total size: 97.0 KB Directory structure: gitextract_c9d6yaeg/ ├── .envrc ├── .github/ │ └── workflows/ │ └── poetry.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── Justfile ├── LICENSE ├── README.md ├── gnome_extensions_cli/ │ ├── __init__.py │ ├── cli.py │ ├── commands/ │ │ ├── __init__.py │ │ ├── disable.py │ │ ├── enable.py │ │ ├── install.py │ │ ├── list_.py │ │ ├── preferences.py │ │ ├── search.py │ │ ├── show.py │ │ ├── uninstall.py │ │ └── update.py │ ├── dbus.py │ ├── filesystem.py │ ├── icons.py │ ├── manager.py │ ├── schema.py │ ├── store.py │ └── utils.py ├── poetry.toml ├── pyproject.toml └── tests/ ├── __init__.py ├── samples/ │ ├── available-alt.json │ ├── available.json │ ├── installed.json │ └── search.json ├── test_cli.py ├── test_dbus.py ├── test_filesystem.py ├── test_model.py └── test_store.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .envrc ================================================ export VIRTUAL_ENV=.venv layout python python3 ================================================ FILE: .github/workflows/poetry.yml ================================================ name: Build & Tests on: [push] jobs: test: name: Build and test App runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | sudo apt update sudo apt install -qy --no-install-recommends gnome-shell-extensions sudo apt install -qy libgirepository1.0-dev gcc libcairo2-dev pkg-config python3-dev gir1.2-gtk-3.0 - name: Install poetry run: pip install poetry - name: Build app run: poetry build - name: Test app run: | poetry install poetry run pytest --cov=gnome_extensions_cli tests/ publish: name: Publish App on PyPI if: startsWith(github.ref, 'refs/tags/') needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install poetry run: pip install poetry - name: Publish app env: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} run: poetry publish --build --username "__token__" --password "$PYPI_TOKEN" ================================================ FILE: .gitignore ================================================ # Python __pycache__ *.pyc # Generated /.coverage /coverage.xml /htmlcov/ /dist/ # IDE /.vscode/* !/.vscode/settings.json ================================================ FILE: .vscode/settings.json ================================================ { "python.pythonPath": ".venv/bin/python", } ================================================ FILE: Justfile ================================================ release bump="patch": echo "{{bump}}" | grep -E "^(major|minor|patch)$" poetry version "{{bump}}" git add pyproject.toml git commit --message "🔖 New release: `poetry version -s`" git tag "`poetry version -s`" [confirm('Confirm push --tags ?')] publish: git log -1 --pretty="%B" | grep '^🔖 New release: ' git push git push --tags ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ ![Github](https://img.shields.io/github/tag/essembeh/gnome-extensions-cli.svg) ![PyPi](https://img.shields.io/pypi/v/gnome-extensions-cli.svg) ![Python](https://img.shields.io/pypi/pyversions/gnome-extensions-cli.svg) ![CI](https://github.com/essembeh/gnome-extensions-cli/actions/workflows/poetry.yml/badge.svg) # gnome-extensions-cli Install, update and manage your Gnome Shell extensions from your terminal ! # Features - You can install any extension available on [Gnome website](https://extensions.gnome.org) - Use _DBus_ to communicate with _Gnome Shell_ like the Firefox addon does - Also support non-DBus installations if needed - Automatically select the compatible version to install for your Gnome Shell - Update all your extensions with one command: `gext update` Available commands: - `gext list` to list you installed extensions - `gext search` to search for extensions on [Gnome website](https://extensions.gnome.org) - `gext install` to install extensions - `gext update` to update any or all your extensions - `gext uninstall` to uninstall extensions - `gext show` to show details about extensions - `gext enable` to enable extensions - `gext disable` to disable extensions - `gext preferences` to open the extension configuration window > Note: `gext` is an alias of `gnome-extensions-cli` # Install ## Releases Releases are available on [PyPI](https://pypi.org/project/gnome-extensions-cli/) > Note: [PipX](https://pypi.org/project/pipx/) is the recommended way to install 3rd-party apps in dedicated environments. ```sh # install using pip $ pip3 install --upgrade gnome-extensions-cli # or using pipx (you need to install pipx first) $ pipx install gnome-extensions-cli --system-site-packages # gext is an alias for gnome-extensions-cli $ gnome-extensions-cli --help $ gext --help ``` ## From the source You can also install the _latest_ version from the Git repository: ```sh $ pip3 install --upgrade git+https://github.com/essembeh/gnome-extensions-cli ``` You can setup a development environment with, requires [Poetry](https://python-poetry.org/) ```sh # dependencies to install PyGObject with pip $ sudo apt install libgirepository1.0-dev gcc libcairo2-dev pkg-config python3-dev gir1.2-gtk-3.0 # clone the repository $ git clone https://github.com/essembeh/gnome-extensions-cli $ cd gnome-extensions-cli # install poetry if you don't have it yet $ pipx install poetry # create the venv using poetry $ poetry install $ poetry shell (.venv) $ gnome-extensions-cli --help ``` # Using By default commands output use terminal colors and styles for a better experience. If you want to disable the colors and style, when using `gext` in shell scripts for example, you can - use `gext --no-color ...` - or set the environment variable `export NO_COLOR=1` in your shell script before calling `gext` ## List your extensions By default, the `list` command only display the _enabled_ extensions, using `-a|--all` argument also displays _disabled_ ones. ![gext list](images/list.png) ## Install, update or uninstall extensions The `install` commands allows you to install extensions from their _uuid_ or _pk_. > Note: You can use `search` command to find extensions, `gext` prints _uuids_ in _yellow_ . ```sh # Install extension by its UUID $ gext install dash-to-panel@jderose9.github.com # or use its package number from https://extensions.gnome.org $ gext install 1160 # You can also install multiple extensions at once $ gext install 1160 todo.txt@bart.libert.gmail.com # Uninstall extensions $ gext uninstall todo.txt@bart.libert.gmail.com # You can enable and disable extensions $ gext enable todo.txt@bart.libert.gmail.com $ gext disable todo.txt@bart.libert.gmail.com dash-to-panel@jderose9.github.com ``` ![gext install](images/install.gif) The `update` command without arguments updates all _enabled_ extensions. You can also `update` a specific extension by giving its _uuid_. ![gext update](images/update.gif) > Note: the `--install` argument allow you to _install_ extensions given to `update` command if they are not installed. ## Search for extensions and show details The `search` command searches from [Gnome website](https://extensions.gnome.org) and prints results in your terminal: ![gext search](images/search.png) The `show` command fetches details from _Gnome website_ and prints them: ![gext show](images/show.png) # Under the hood: DBus vs Filesystem `gext` can interact with Gnome Shell using two different implementations, using `dbus` or using a `filesystem` operations. > Note: By default, it uses `dbus` (as it is the official way), but switches to `filesystem` if `dbus` is not available (like with _ssh_ sessions) ## DBus Using `--dbus`, the application uses _dbus_ messages with DBus Python API to communicate with Gnome Shell directly. Installations are interactive, like when you install extensions from your browser on Gnome website, you are prompted with a Gnome _Yes/No_ dialog before installing the extensions Pros: - You are using the exact same way to install extensions as the Firefox addon - Automatically restart the Gnome Shell when needed - Very stable - You can open the extension preference dialog with `gext edit EXTENSION_UUID` Cons: - You need to have a running Gnome session ## Filesystem backend Using `--filesystem`, the application uses unzip packages from [Gnome website](https://extensions.gnome.org) directly in you `~/.local/share/gnome-shell/extensions/` folder, enable/disable them and restarting the Gnome Shell using subprocesses. Pros: - You can install extensions without any Gnome session running (using _ssh_ for example) - Many `gext` alternatives CLI tools use this method Cons: - Some extensions might not install well ================================================ FILE: gnome_extensions_cli/__init__.py ================================================ """ gnome-extensions-cli """ from importlib.metadata import version __version__ = version(__name__) ================================================ FILE: gnome_extensions_cli/cli.py ================================================ """ gnome-extensions-cli """ import sys from argparse import ArgumentParser from os import getenv from colorama import init from . import __version__ from .commands import ( disable, enable, install, list_, preferences, search, show, uninstall, update, ) from .dbus import DbusExtensionManager, test_dbus_available from .filesystem import FilesystemExtensionManager from .icons import Color, Icons from .store import GnomeExtensionStore def run(): """ entry point """ parser = ArgumentParser(description="Gnome Shell extensions manager") parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}" ) parser.add_argument( "--no-color", action="store_true", help="disable colors and style in output text " + "(you can also set NO_COLOR=1 instead of using this option)", ) parser.add_argument( "--no-compile-schemas", action="store_false", help="when using filesystem backend, do not compile schemas with glib-compile-schemas if needed", ) group = parser.add_mutually_exclusive_group() group.add_argument( "-D", "--dbus", action="store_const", dest="backend", const="dbus", help="force DBus backend", ) group.add_argument( "-F", "--filesystem", action="store_const", dest="backend", const="filesystem", help="force filesystem backend", ) subparsers = parser.add_subparsers() list_.configure( subparsers.add_parser("list", aliases=["ls"], help="list installed extensions") ) search.configure( subparsers.add_parser("search", aliases=[], help="search for extensions") ) show.configure( subparsers.add_parser("show", aliases=[], help="show extensions details") ) install.configure( subparsers.add_parser("install", aliases=["i"], help="install extensions") ) uninstall.configure( subparsers.add_parser("uninstall", aliases=[], help="uninstall extensions") ) update.configure( subparsers.add_parser("update", aliases=["u"], help="update extensions") ) enable.configure( subparsers.add_parser("enable", aliases=[], help="enable extensions") ) disable.configure( subparsers.add_parser("disable", aliases=[], help="disable extensions") ) preferences.configure( subparsers.add_parser( "preferences", aliases=["p", "config"], help="edit preferences of extension", ) ) args = parser.parse_args() # handle nocolor in output if args.no_color or getenv("NO_COLOR") is not None: init(strip=True, convert=False) else: init() try: # instantiate store store = GnomeExtensionStore() # instantiate manager manager = None if args.backend == "dbus": manager = DbusExtensionManager() elif args.backend == "filesystem": manager = FilesystemExtensionManager(store) elif test_dbus_available(getenv("DEBUG") == "1"): manager = DbusExtensionManager() else: manager = FilesystemExtensionManager(store) handler = args.handler if "handler" in args else list_.run handler(args, manager, store) except KeyboardInterrupt: print(Icons.ERROR, "Process interrupted") sys.exit(1) except SystemExit: raise except BaseException as error: # pylint: disable=broad-except print(Icons.BOOM, "Error:", Color.RED(error)) raise error ================================================ FILE: gnome_extensions_cli/commands/__init__.py ================================================ ================================================ FILE: gnome_extensions_cli/commands/disable.py ================================================ """ gnome-extensions-cli """ from argparse import ZERO_OR_MORE, ArgumentParser, Namespace from ..icons import Color from ..manager import ExtensionManager from ..store import GnomeExtensionStore def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "--not-installed", action="store_true", help="disable all extensions which are not installed", ) parser.add_argument( "uuids", nargs=ZERO_OR_MORE, metavar="UUID", help="uuid of extensions to disable", ) def run(args: Namespace, manager: ExtensionManager, _store: GnomeExtensionStore): """ Handler for subcommand """ uuids = list(set(args.uuids)) if args.not_installed: installed_uuids = [e.uuid for e in manager.list_installed_extensions()] uuids += [ uuid for uuid in manager.list_enabled_uuids() if uuid not in installed_uuids and uuid not in args.uuids ] print("Disable extension(s):") for uuid in uuids: print(" -", Color.YELLOW(uuid)) manager.disable_uuids(*uuids) ================================================ FILE: gnome_extensions_cli/commands/enable.py ================================================ """ gnome-extensions-cli """ from argparse import ONE_OR_MORE, ArgumentParser, Namespace from ..icons import Color from ..manager import ExtensionManager from ..store import GnomeExtensionStore def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "uuids", nargs=ONE_OR_MORE, metavar="UUID", help="uuid of extensions to enable", ) def run(args: Namespace, manager: ExtensionManager, _store: GnomeExtensionStore): """ Handler for subcommand """ uuids = list(set(args.uuids)) print("Enable extension(s):") for uuid in uuids: print(" -", Color.YELLOW(uuid)) manager.enable_uuids(*uuids) ================================================ FILE: gnome_extensions_cli/commands/install.py ================================================ """ gnome-extensions-cli """ from argparse import ONE_OR_MORE, ArgumentParser, Namespace from tqdm import tqdm from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore from ..utils import version_comparator def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "extensions", nargs=ONE_OR_MORE, metavar="UUID_OR_PK", help="uuid (or pk) of extensions to install", ) def run(args: Namespace, manager: ExtensionManager, store: GnomeExtensionStore): """ Handler for subcommand """ installed_extensions = {e.uuid: e for e in manager.list_installed_extensions()} enabled_uuids = manager.list_enabled_uuids() shell_version = manager.get_current_shell_version() extensions_to_fetch = dict.fromkeys(args.extensions) fetched_extensions = { uuid: ext for uuid, ext in tqdm( store.iter_fetch(extensions_to_fetch, shell_version=shell_version), unit=" extension(s) fetched", total=len(extensions_to_fetch), ) } for motif, available_ext in fetched_extensions.items(): if available_ext is None: print(Icons.ERROR, "Cannot find extension", Color.RED(motif)) elif available_ext.uuid not in installed_extensions: print(Icons.PACKAGE, "Install", Label.available(available_ext)) manager.install_extension(available_ext) elif ( version_comparator( installed_extensions[available_ext.uuid].metadata.version, available_ext.version, ) > 0 ): print(Icons.PACKAGE, "Upgrade", Label.available(available_ext)) manager.install_extension(available_ext) elif available_ext.uuid not in enabled_uuids: print(Icons.HINT, "Enable", Label.available(available_ext)) manager.enable_uuids(available_ext.uuid) else: print( Icons.DRYRUN, "Extension", Label.available(available_ext), "is already installed", ) manager.enable_uuids(available_ext.uuid) ================================================ FILE: gnome_extensions_cli/commands/list_.py ================================================ """ gnome-extensions-cli """ from argparse import ArgumentParser, Namespace from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "-v", "--verbose", action="store_true", help="display more information" ) parser.add_argument( "--only-uuid", action="store_true", help="display only uuid of extensions" ) parser.add_argument( "-a", "--all", action="store_true", help="list all extensions, (by default only enabled are shown)", ) def run(args: Namespace, manager: ExtensionManager, _store: GnomeExtensionStore): """ Handler for subcommand """ verbose = "verbose" in args and args.verbose show_all = "all" in args and args.all only_uuid = "only_uuid" in args and args.only_uuid if verbose: print("Gnome Shell", Label.version(manager.get_current_shell_version())) print() print( "Installed Extensions:", f"(enabled: {Icons.DOT_BLUE}, disabled: {Icons.DOT_WHITE})", ) installed_extensions = sorted( manager.list_installed_extensions(), key=lambda x: x.uuid.lower() ) enabled_uuids = manager.list_enabled_uuids() for installed_ext in installed_extensions: if installed_ext.uuid in enabled_uuids: if only_uuid: print(installed_ext.metadata.uuid) else: print(Icons.DOT_BLUE, Label.installed(installed_ext, enabled=True)) if show_all: for installed_ext in installed_extensions: if installed_ext.uuid not in enabled_uuids: if only_uuid: print(installed_ext.metadata.uuid) else: print( Icons.DOT_WHITE, Label.installed(installed_ext, enabled=False) ) if verbose: print() print( "Enabled uuids:", ", ".join(map(Color.YELLOW, sorted(enabled_uuids, key=str.lower))), ) ================================================ FILE: gnome_extensions_cli/commands/preferences.py ================================================ """ gnome-extensions-cli """ from argparse import ArgumentParser, Namespace from ..manager import ExtensionManager from ..store import GnomeExtensionStore def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "uuid", metavar="UUID", help="uuid of extension to edit preferences", ) def run(args: Namespace, manager: ExtensionManager, _store: GnomeExtensionStore): """ Handler for subcommand """ ext = next( filter(lambda e: e.uuid == args.uuid, manager.list_installed_extensions()), None ) assert ext is not None, f"Extension {args.uuid} is not installed" manager.edit_extension(ext) ================================================ FILE: gnome_extensions_cli/commands/search.py ================================================ """ gnome-extensions-cli """ from argparse import ONE_OR_MORE, ArgumentParser, Namespace from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore from .show import print_key_value def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "-v", "--verbose", action="store_true", help="search for extensions" ) parser.add_argument("-l", "--limit", type=int, default=0, help="limit to N items") parser.add_argument( "motif", nargs=ONE_OR_MORE, help="search motif", ) def run(args: Namespace, manager: ExtensionManager, store: GnomeExtensionStore): """ Handler for subcommand """ installed_extensions = {e.uuid: e for e in manager.list_installed_extensions()} results = list(store.search(" ".join(args.motif), limit=args.limit)) for index, available_ext in enumerate(results, 1): installed_ext = installed_extensions.get(available_ext.uuid) print( Icons.DOT_BLUE if installed_ext is not None else Icons.DOT_WHITE, f"[{index}/{len(results)}]", Color.DEFAULT(available_ext.name, style="bright"), Label.uuid(available_ext.uuid), ) print_key_value("link", Label.url(store.url, available_ext.link), 1) print_key_value("screenshot", Label.url(store.url, available_ext.screenshot), 1) print_key_value("creator", available_ext.creator, 1) print_key_value("recommended version", Label.version(available_ext.version), 1) if installed_ext is not None: print_key_value( "installed version", Label.version(installed_ext.metadata.version), 1 ) if args.verbose: print_key_value("description", available_ext.description, 1) print(("-" * 80) if args.verbose else "") ================================================ FILE: gnome_extensions_cli/commands/show.py ================================================ """ gnome-extensions-cli """ from argparse import ONE_OR_MORE, ArgumentParser, Namespace from typing import Any, Dict, Iterable, List, Optional, Union from gnome_extensions_cli.schema import AvailableExtension from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore INDENT = " " def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "-v", "--verbose", action="store_true", help="display more information" ) parser.add_argument( "extensions", nargs=ONE_OR_MORE, metavar="UUID_OR_PK", help="uuid (or pk) of extensions", ) def print_form(data: Dict[str, Any], indent: str = ""): """ Print a form-like from given data """ maxlen = max(map(len, data.keys())) for key, value in data.items(): if value is not None: # right align key = key.rjust(maxlen) # key style: dim key = Color.DEFAULT(key, style="dim") if isinstance(value, str) and "\n" in value: # multiline string, indent each line value = f"\n{indent}{' ' * (maxlen + 2)}".join(value.splitlines()) elif isinstance(value, (list, set, dict)): # if value is a list-like, indent all items value = f"\n{indent}{' ' * (maxlen + 2)}".join(map(str, value)) print(f"{indent}{key}: {value}") def print_key_value(key: str, value: Optional[Any], indent: int = 0): """ Print a key-value pair, handling indentation, formatting and colors """ if value is not None: if isinstance(value, str) and "\n" in value: prefix = INDENT * (indent + 2) value = f"\n{prefix}" + value.replace("\n", f"\n{prefix}") print( INDENT * indent, Color.DEFAULT(key, style="dim"), ":", value if value is not None else "", ) def build_versions_dict(ext: AvailableExtension) -> Dict[int, List[str]]: """ Build a disctionnary of supported Gnome Shell version per app version """ out = {} for shell_version, app in ext.shell_version_map.items(): app_version = app.version if app.version not in out: out[app_version] = [] out[app_version].append(shell_version) return out def run(args: Namespace, manager: ExtensionManager, store: GnomeExtensionStore): """ Handler for subcommand """ installed_extensions = {e.uuid: e for e in manager.list_installed_extensions()} shell_version = manager.get_current_shell_version() for motif, available_ext in store.iter_fetch( dict.fromkeys(args.extensions), shell_version=shell_version ): if available_ext is not None: installed_ext = installed_extensions.get(available_ext.uuid) print( Icons.DOT_BLUE if installed_ext is not None else Icons.DOT_WHITE, Color.DEFAULT(available_ext.name, style="bright"), Label.uuid(available_ext.uuid), ) print_form( { "link": Label.url(store.url, available_ext.link), "screenshot": Label.url(store.url, available_ext.screenshot), "creator": available_ext.creator, "creator_url": Label.url(store.url, available_ext.creator_url), "description": available_ext.description if args.verbose else None, "tag": available_ext.version_tag, "pk": available_ext.pk, "recommended version": Label.version(available_ext.version), "installed version": Label.version(installed_ext.metadata.version) if installed_ext is not None else None, "versions": list(available_ext.shell_version_map.keys()), "available versions": None, }, indent=" ", ) continue print_key_value("link", Label.url(store.url, available_ext.link), 1) print_key_value( "screenshot", Label.url(store.url, available_ext.screenshot), 1 ) print_key_value("creator", available_ext.creator, 1) print_key_value( "creator_url", Label.url(store.url, available_ext.creator_url), 1 ) if args.verbose: print_key_value("description", available_ext.description, 1) print_key_value("tag", available_ext.version_tag, 1) print_key_value("pk", available_ext.pk, 1) print_key_value( "recommended version", Label.version(available_ext.version), 1 ) if installed_ext is not None: print_key_value( "installed version", Label.version(installed_ext.metadata.version), 1, ) if args.verbose: app_versions_dict = build_versions_dict(available_ext) print_key_value("available versions", "", 1) for app_version in sorted(app_versions_dict, reverse=True): shell_versions = app_versions_dict[app_version] print( INDENT * 2, Label.version(app_version), "for Gnome Shell", Label.version(shell_versions[0]), f" to {Label.version(shell_versions[-1])}" if len(shell_versions) > 1 else "", ) if not args.verbose: break print() else: print(Icons.DOT_RED, f"Cannot find extension {motif}") ================================================ FILE: gnome_extensions_cli/commands/uninstall.py ================================================ """ gnome-extensions-cli """ from argparse import ONE_OR_MORE, ArgumentParser, Namespace from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "uuids", nargs=ONE_OR_MORE, metavar="UUID", help="uuid of extensions to uninstall", ) def run(args: Namespace, manager: ExtensionManager, _store: GnomeExtensionStore): """ Handler for subcommand """ installed_extensions = {e.uuid: e for e in manager.list_installed_extensions()} for uuid in dict.fromkeys(args.uuids): installed_extension = installed_extensions.get(uuid) if installed_extension is None: print( Icons.WARNING, f"Extension {Color.RED(uuid)} is not installed", ) elif installed_extension.read_only: print( Icons.HINT, "Cannot uninstall", installed_extension.metadata.name, Label.uuid(installed_extension.uuid), ": it is a system extension", ) else: print( Icons.TRASH, "Uninstall", installed_extension.metadata.name, Label.uuid(installed_extension.uuid), ) manager.uninstall_extension(installed_extension) ================================================ FILE: gnome_extensions_cli/commands/update.py ================================================ """ gnome-extensions-cli """ from argparse import ZERO_OR_MORE, ArgumentParser, Namespace from tqdm import tqdm from ..icons import Color, Icons, Label from ..manager import ExtensionManager from ..store import GnomeExtensionStore from ..utils import confirm, version_comparator def configure(parser: ArgumentParser): """ Configure parser for subcommand """ parser.set_defaults(handler=run) parser.add_argument( "-i", "--install", action="store_true", help="install extension if not installed", ) group = parser.add_mutually_exclusive_group() group.add_argument( "-y", "--yes", action="store_true", help="do not prompt confirmation for update/install", ) group.add_argument( "-n", "--dry-run", action="store_true", help="do not update nor install anything, " + "a return code 17 is returned if updates are available", ) parser.add_argument( "--user", action="store_true", help="only update /user extensions, ignore /system ones", ) parser.add_argument( "extensions", nargs=ZERO_OR_MORE, metavar="UUID_OR_PK", help="uuid (or pk) of extensions to update (default: all enabled extensions)", ) def run(args: Namespace, manager: ExtensionManager, store: GnomeExtensionStore): """ Handler for subcommand """ installed_extensions = {e.uuid: e for e in manager.list_installed_extensions()} enabled_uuids = manager.list_enabled_uuids() shell_version = manager.get_current_shell_version() extensions_to_fetch = [] if len(args.extensions): # Use extensions list given by the user extensions_to_fetch = args.extensions else: # Update all installed extensions that are enable and that have a version for uuid, ext in installed_extensions.items(): if uuid in enabled_uuids and ext.metadata.version is not None: extensions_to_fetch.append(uuid) # fetch available fetched_extensions = { uuid: ext for uuid, ext in tqdm( store.iter_fetch(extensions_to_fetch, shell_version=shell_version), unit=" extension(s) fetched", total=len(extensions_to_fetch), ) } extensions_to_update = [] extensions_to_install = [] count = 0 for uuid, available_ext in fetched_extensions.items(): count += 1 progress = f"[{count}]" if available_ext is None: # cannot fetch extension print(progress, "Cannot find extension", Color.RED(uuid)) elif available_ext.uuid not in installed_extensions: # extension is not installed print( progress, "Found extension", Label.available(available_ext), ": not installed", ) if args.install: extensions_to_install.append(available_ext) elif args.user and installed_extensions[available_ext.uuid].read_only: # extension is readonly, update only user extensions print( progress, "Found extension", Label.available(available_ext), ": skip system extension", ) elif ( version_comparator( installed_extensions[available_ext.uuid].metadata.version, available_ext.version, ) > 0 ): # extension can be updated print( progress, "Found extension", Label.available(available_ext), ": outdated", ) extensions_to_update.append(available_ext) else: # extension is up to date print( progress, "Found extension", Label.available(available_ext), ": up-to-date", ) print("") if len(extensions_to_update) + len(extensions_to_install) == 0: print(Icons.THUMB_UP, "Nothing to update") else: if len(extensions_to_update) > 0: print(Icons.PACKAGE, "Extensions to update:") for ext in extensions_to_update: print(" ", Color.YELLOW(ext.uuid)) if len(extensions_to_install) > 0: print(Icons.PACKAGE, "Extensions to install:") for ext in extensions_to_install: print(" ", Color.YELLOW(ext.uuid)) if args.dry_run: # in dryrun mode, exit 17 raise SystemExit(17) if args.yes or confirm("Continue?", default=True): for available_extension in extensions_to_update: installed_extension = installed_extensions[available_extension.uuid] print("Update", Label.available(available_extension)) if installed_extension.metadata.version is not None: print(" over", Label.version(installed_extension.metadata.version)) manager.install_extension(available_extension) for available_extension in extensions_to_install: print("Install", Label.available(available_extension)) manager.install_extension(available_extension) ================================================ FILE: gnome_extensions_cli/dbus.py ================================================ """ gnome-extensions-cli """ from operator import itemgetter from pathlib import Path from traceback import print_exc from typing import List from .manager import ExtensionManager from .schema import AvailableExtension, InstalledExtension DBUS_INTERFACE = "org.gnome.Shell" DBUS_PATH = "/org/gnome/Shell" def test_dbus_available(debug: bool = False) -> bool: """ Test if DBus is available """ try: from gi.repository import Gio # pylint: disable=import-outside-toplevel dbus = Gio.bus_get_sync(Gio.BusType.SESSION, None) return dbus is not None except BaseException: # pylint: disable=broad-except if debug: print_exc() return False class DbusExtensionManager(ExtensionManager): """ Handle extensions using DBus messages, just like recommended Firefox extensions on Gnome website dbus schema: /usr/share/dbus-1/interfaces/org.gnome.Shell.Extensions.xml """ def __init__(self): from gi.repository import Gio # pylint: disable=import-outside-toplevel dbus = Gio.bus_get_sync(Gio.BusType.SESSION, None) self.proxy_extensions = Gio.DBusProxy.new_sync( dbus, Gio.DBusProxyFlags.NONE, None, DBUS_INTERFACE, "/org/gnome/Shell", "org.gnome.Shell.Extensions", None, ) self.proxy_properties = Gio.DBusProxy.new_sync( dbus, Gio.DBusProxyFlags.NONE, None, DBUS_INTERFACE, "/org/gnome/Shell", "org.freedesktop.DBus.Properties", None, ) self.settings = Gio.Settings.new("org.gnome.shell") def get_current_shell_version(self) -> str: return self.proxy_properties.Get("(ss)", DBUS_INTERFACE, "ShellVersion") def list_installed_extensions(self) -> List[InstalledExtension]: return list( map( InstalledExtension, filter( Path.exists, map( Path, filter( None, map( itemgetter("path"), self.proxy_extensions.ListExtensions().values(), ), ), ), ), ) ) def install_extension(self, ext: AvailableExtension) -> bool: return ( self.proxy_extensions.InstallRemoteExtension("(s)", ext.uuid) == "successful" ) def uninstall_extension(self, ext: InstalledExtension): self.proxy_extensions.UninstallExtension("(s)", ext.uuid) def edit_extension(self, ext: InstalledExtension): self.proxy_extensions.LaunchExtensionPrefs("(s)", ext.uuid) def list_enabled_uuids(self) -> List[str]: return list(self.settings["enabled-extensions"]) def set_enabled_uuids(self, uuids: List[str]) -> bool: self.settings["enabled-extensions"] = uuids return True ================================================ FILE: gnome_extensions_cli/filesystem.py ================================================ """ gnome-extensions-cli """ import subprocess import sys from dataclasses import dataclass, field from os.path import expanduser from pathlib import Path from re import finditer, fullmatch from shutil import rmtree from subprocess import DEVNULL, run from tempfile import NamedTemporaryFile from typing import List from urllib.request import urlopen from zipfile import ZipFile from .icons import Color, Icons, Label from .manager import ExtensionManager from .schema import AvailableExtension, InstalledExtension from .store import GnomeExtensionStore @dataclass class FilesystemExtensionManager(ExtensionManager): """ Handle extensions with basic filesystem operations """ store: GnomeExtensionStore user_folder: Path = field( default=Path(expanduser("~/.local/share/gnome-shell/extensions")) ) system_folders: List[Path] = field( default_factory=lambda: [ Path("/usr/share/gnome-shell/extensions"), Path("/usr/local/share/gnome-shell/extensions"), ] ) auto_compile_schemas: bool = True def get_current_shell_version(self) -> str: stdout = subprocess.check_output( ["gnome-shell", "--version"], text=True, ) matcher = fullmatch( r"GNOME Shell (?P[0-9]+(?:\.[0-9]+)?)(?:\..+)?", stdout.strip() ) assert matcher is not None, "Cannot retrieve Gnome Shell version" return matcher.group("version") def list_installed_extensions(self) -> List[InstalledExtension]: out = {} for folder in filter(Path.is_dir, self.system_folders + [self.user_folder]): for subfolder in sorted(folder.iterdir()): metadata_file = subfolder / "metadata.json" if metadata_file.is_file(): ext = InstalledExtension(subfolder) out[ext.uuid] = ext return list(out.values()) def install_extension(self, ext: AvailableExtension) -> bool: assert ext.download_url is not None, ( f"Cannot find recommended version for {ext.uuid}" ) if self.disable_uuids(ext.uuid): print("Disable extension", ext.uuid) target_dir = self.user_folder / ext.uuid if target_dir.exists(): print("Remove existing folder:", Label.folder(target_dir)) rmtree(target_dir) target_dir.mkdir(parents=True) try: with NamedTemporaryFile() as tmp: print( "Download extensions from", Label.url(self.store.url, ext.download_url), ) with urlopen(self.store.url + ext.download_url) as stream: tmp.write(stream.read()) tmp.seek(0) print("Extract extension to", Label.folder(target_dir)) with ZipFile(tmp.name) as zipfile: for member in zipfile.namelist(): zipfile.extract(member, path=target_dir) if self.auto_compile_schemas: self.compile_schemas(target_dir) if self.enable_uuids(ext.uuid): print( "Enable extension", Label.installed(InstalledExtension(target_dir)) ) except BaseException as error: # pylint: disable=broad-except print( Icons.BOOM, f"Error while installing {ext.uuid}:", Color.RED(error), file=sys.stderr, ) print("Remove temporary folder:", Label.folder(target_dir)) rmtree(target_dir) return False return True def uninstall_extension(self, ext: InstalledExtension): assert not ext.read_only, f"Cannot uninstall a system extension {ext.uuid}" if self.disable_uuids(ext.uuid): print("Disable", Label.installed(ext)) rmtree(ext.folder) print("Remove folder", Label.folder(ext.folder)) def edit_extension(self, ext: InstalledExtension): self._run(["gnome-extensions-app"]) def list_enabled_uuids(self) -> List[str]: stdout = subprocess.check_output( ["gsettings", "get", "org.gnome.shell", "enabled-extensions"], text=True, ) uuids = [m.group("uuid") for m in finditer(r"'(?P[^']+)'", stdout)] return uuids def set_enabled_uuids(self, uuids: List[str]) -> bool: uuids_text = ",".join((f'"{uuid}"' for uuid in uuids)) command = [ "gsettings", "set", "org.gnome.shell", "enabled-extensions", f"[{uuids_text}]", ] if self._run(command) != 0: print( Icons.WARNING, f"Error while enable extensions with {Color.YELLOW('gesttings')}", file=sys.stderr, ) return False return self.restart_gnome_shell() def restart_gnome_shell(self) -> bool: """ Manually restart Gnome Shell """ command = [ "dbus-send", "--session", "--type=method_call", "--dest=org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell.Eval", 'string:"global.reexec_self();"', ] if self._run(command) != 0: print( Icons.WARNING, "Could not restart Gnome Shell, you have to restart it manually", file=sys.stderr, ) return False return True def _run(self, command: List[str]) -> int: """ Run an external process """ process = subprocess.run( command, check=False, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) return process.returncode def compile_schemas(self, extension_folder: Path) -> bool: if not (schemas_folder := extension_folder / "schemas").exists(): # No schemas to compile return True if (schemas_folder / "gschemas.compiled").exists(): # Schemas already compiled return True if len(list(schemas_folder.glob("*.gschema.xml"))) == 0: # Folder exists, no schemas to compile return True # Check if glib-compile-schemas is available try: run( ["glib-compile-schemas", "--version"], check=True, stdout=DEVNULL, stderr=DEVNULL, ) except BaseException: print( Icons.WARNING, "Cannot compile schemas, you may need to manually compile schemas in", Label.folder(extension_folder), ) return False process = run( ["glib-compile-schemas", "schemas/"], cwd=extension_folder, check=False, stdout=DEVNULL, stderr=DEVNULL, ) if process.returncode != 0: print( Icons.ERROR, "Error while compiling schemas, you may need to manually compile schemas in", Label.folder(extension_folder), ) return False print("Schemas compiled in", Label.folder(extension_folder)) return True ================================================ FILE: gnome_extensions_cli/icons.py ================================================ """ gnome-extensions-cli """ from enum import Enum from pathlib import Path from typing import Any, Optional from colorama import Back, Fore, Style from .schema import AvailableExtension, InstalledExtension class Icons(Enum): """ UTF8 Icons """ OK = "✅" ERROR = "❌" WARNING = "🚨" RED_FLAG = "🚩" BOOM = "💥" QUESTION = "❓" DRYRUN = "🙈" HINT = "💡" DOT_BLACK = "⚫" DOT_WHITE = "⚪" DOT_RED = "🔴" DOT_BLUE = "🔵" PACKAGE = "📦" THUMB_UP = "👍" TRASH = "🗑" def __str__(self): return self.value def color( *message: str, fore: Optional[str] = None, back: Optional[str] = None, style: Optional[str] = None, ) -> str: """ string formatter with color and style """ pre = "" post = "" if isinstance(fore, str): pre += fore post += Fore.RESET if isinstance(back, str): pre += back post += Back.RESET if isinstance(style, str): pre += style post += Style.RESET_ALL return pre + " ".join(map(str, filter(None, message))) + post class Color(Enum): """ Utility tool to use colorama colors """ DEFAULT = None BLACK = Fore.BLACK RED = Fore.RED GREEN = Fore.GREEN YELLOW = Fore.YELLOW BLUE = Fore.BLUE MAGENTA = Fore.MAGENTA CYAN = Fore.CYAN WHITE = Fore.WHITE LIGHTBLACK_EX = Fore.LIGHTBLACK_EX LIGHTRED_EX = Fore.LIGHTRED_EX LIGHTGREEN_EX = Fore.LIGHTGREEN_EX LIGHTYELLOW_EX = Fore.LIGHTYELLOW_EX LIGHTBLUE_EX = Fore.LIGHTBLUE_EX LIGHTMAGENTA_EX = Fore.LIGHTMAGENTA_EX LIGHTCYAN_EX = Fore.LIGHTCYAN_EX LIGHTWHITE_EX = Fore.LIGHTWHITE_EX def __call__(self, *args, style: Optional[str] = None) -> str: style = ( {"dim": Style.DIM, "bright": Style.BRIGHT}.get(style.lower()) or style if style is not None else style ) return color(*args, fore=self.value, style=style) class Label: """ __str__ builder for common types """ @staticmethod def uuid(uuid: str) -> str: return f"({Color.YELLOW(uuid)})" @staticmethod def version(version: Optional[Any]) -> Optional[str]: return f"v{version}" if version is not None else None @staticmethod def url(base: str, path: Optional[str]) -> Optional[str]: return Color.BLUE(base + path) if path is not None else None @staticmethod def available(ext: AvailableExtension) -> str: return " ".join( filter( None, [ Color.DEFAULT(ext.name, style="bright"), Label.uuid(ext.uuid), Label.version(ext.version), ], ) ) @staticmethod def installed(ext: InstalledExtension, enabled: Optional[bool] = None) -> str: name = ext.metadata.name if enabled is True: name = Color.DEFAULT(name, style="bright") elif enabled is False: name = Color.DEFAULT(name, style="dim") return ( f"{name} {Label.uuid(ext.metadata.uuid)} {Label.version(ext.metadata.version) or ''} " + (Color.RED("/system") if ext.read_only else Color.GREEN("/user")) ) @staticmethod def folder(path: Path) -> str: return Color.BLUE(f"{path}/", style="bright") ================================================ FILE: gnome_extensions_cli/manager.py ================================================ """ gnome-extensions-cli """ from abc import ABC, abstractmethod from typing import List from .schema import AvailableExtension, InstalledExtension class ExtensionManager(ABC): """ Abstract class to manipulate extensions """ @abstractmethod def get_current_shell_version(self) -> str: """ Return the current Gnome Shell version """ @abstractmethod def list_installed_extensions(self) -> List[InstalledExtension]: """ List installed extensions """ @abstractmethod def install_extension(self, ext: AvailableExtension) -> bool: """ Install given extension """ @abstractmethod def uninstall_extension(self, ext: InstalledExtension): """ Uninstall given extension """ @abstractmethod def edit_extension(self, ext: InstalledExtension): """ Edit preferences of given extension """ @abstractmethod def list_enabled_uuids(self) -> List[str]: """ List enabled extensions uuids """ @abstractmethod def set_enabled_uuids(self, uuids: List[str]) -> bool: """ Set enabled extensions uuids """ def enable_uuids(self, *uuids: str) -> bool: """ Enable given extensions """ old_uuids = set(self.list_enabled_uuids()) new_uuids = old_uuids | set(uuids) return old_uuids != new_uuids and self.set_enabled_uuids(list(new_uuids)) def disable_uuids(self, *uuids: str) -> bool: """ Disable given extensions """ old_uuids = set(self.list_enabled_uuids()) new_uuids = old_uuids - set(uuids) return old_uuids != new_uuids and self.set_enabled_uuids(list(new_uuids)) ================================================ FILE: gnome_extensions_cli/schema.py ================================================ """ gnome-extensions-cli """ import os from dataclasses import dataclass from functools import cached_property from pathlib import Path from typing import Dict, List, Optional, Union from pydantic import BaseModel, Field class Metadata(BaseModel): uuid: str name: str description: Optional[str] = None extension_id: Optional[str] = Field(alias="extension-id", default=None) shell_version: Optional[List[str]] = Field(alias="shell-version", default=None) url: Optional[str] = None version: Optional[Union[str, int, float]] = None path: Optional[Path] = None class _Version(BaseModel): pk: int version: int class AvailableExtension(BaseModel): uuid: str pk: int name: str description: str creator: str creator_url: Optional[str] = None link: Optional[str] = None icon: Optional[str] = None screenshot: Optional[str] = None shell_version_map: Dict[str, _Version] version: Optional[int] = None version_tag: Optional[int] = None download_url: Optional[str] = None class Search(BaseModel): extensions: List[AvailableExtension] total: int numpages: int @dataclass class InstalledExtension: folder: Path @property def metadata_json(self) -> Path: return self.folder / "metadata.json" @property def read_only(self): return not os.access(str(self.folder), os.W_OK) @cached_property def metadata(self) -> Metadata: return Metadata.model_validate_json(self.metadata_json.read_text()) @property def uuid(self) -> str: return self.metadata.uuid ================================================ FILE: gnome_extensions_cli/store.py ================================================ """ gnome-extensions-cli """ from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from typing import Iterable, Optional, Tuple, Union from requests import Session from requests.adapters import HTTPAdapter from urllib3.util import Retry from .schema import AvailableExtension, Search @dataclass class GnomeExtensionStore: """ Interface to search for extensions on Gnome Website """ url: str = "https://extensions.gnome.org" timeout: int = 20 session: Session = field(init=False) def __post_init__(self): self.session = Session() self.session.mount( "https://", HTTPAdapter( max_retries=Retry( total=2, backoff_factor=1, status_forcelist=[500, 502, 503, 504] ) ), ) def iter_fetch( self, extensions: Iterable[Union[str, int]], shell_version: Optional[str] = None, max_workers: Optional[int] = None, ) -> Iterable[Tuple[Union[str, int], Optional[AvailableExtension]]]: """ Fetch multiple available extensions in parallel and yield when fetched """ with ThreadPoolExecutor(max_workers=max_workers) as executor: jobs = { executor.submit(lambda u: self.find(u, shell_version), ext): ext for ext in extensions } for job in as_completed(jobs.keys()): uuid = jobs[job] yield (uuid, job.result()) def find( self, ext: Union[str, int], shell_version: Optional[str] = None ) -> Optional[AvailableExtension]: """ Find an extension by its uuid or pk """ if isinstance(ext, int): return self.find_by_pk(ext, shell_version=shell_version) if isinstance(ext, str) and ext.isnumeric(): return self.find_by_pk(int(ext), shell_version=shell_version) return self.find_by_uuid(ext, shell_version=shell_version) def find_by_uuid( self, uuid: str, shell_version: Optional[str] = None ) -> Optional[AvailableExtension]: """ Find an extension by its uuid """ params = {"uuid": uuid} if shell_version is not None: params["shell_version"] = str(shell_version) resp = self.session.get( f"{self.url}/extension-info/", params=params, timeout=self.timeout, ) if resp.status_code == 404: return None resp.raise_for_status() return AvailableExtension.model_validate_json(resp.text) def find_by_pk( self, pk: int, shell_version: Optional[str] = None ) -> Optional[AvailableExtension]: """ Find an extension by its pk """ params = {"pk": str(pk)} if shell_version is not None: params["shell_version"] = str(shell_version) resp = self.session.get( f"{self.url}/extension-info/", params=params, timeout=self.timeout, ) if resp.status_code == 404: return None resp.raise_for_status() return AvailableExtension.model_validate_json(resp.text) def search( self, motif: str, shell_version: str = "all", limit: int = 0 ) -> Iterable[AvailableExtension]: """ Search for extensions """ params = {"search": motif, "shell_version": shell_version, "page": 1} found = 0 while True: resp = self.session.get( f"{self.url}/extension-query/", params=params, timeout=self.timeout, ) resp.raise_for_status() data = Search.model_validate_json(resp.text) for ext in data.extensions: yield ext found += 1 if 0 < limit <= found: return if params["page"] >= data.numpages: break params["page"] += 1 ================================================ FILE: gnome_extensions_cli/utils.py ================================================ """ gnome-extensions-cli """ from typing import Any, Optional from packaging.version import Version def version_comparator(left: Any, right: Any) -> int: """ Compare two versions by handling None, integer, float or strings """ if left == right: return 0 if left is None: return 1 if right is None: return -1 vleft, vright = Version(str(left)), Version(str(right)) if vleft < vright: return 1 if vleft > vright: return -1 return 0 def confirm(message: str, default: Optional[bool] = None) -> bool: """ Simple interactive confirmation """ while True: answer = input( f"💬 {message} [" + ("Y" if default is True else "y") + "/" + ("N" if default is False else "n") + "] " ) if answer == "" and default is not None: return default if answer.lower() == "y": return True if answer.lower() == "n": return False ================================================ FILE: poetry.toml ================================================ [virtualenvs] in-project = true [virtualenvs.options] system-site-packages = true ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "gnome-extensions-cli" version = "0.11.0" description = "Command line tool to manage your Gnome Shell extensions" homepage = "https://github.com/essembeh/gnome-extensions-cli" authors = ["Sébastien MB "] license = "Apache-2.0" classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: Apache Software License", "Topic :: Utilities" ] readme = "README.md" [tool.poetry.dependencies] python = "^3.8" colorama = "^0.4.5" pydantic = "^2.3.0" requests = "^2.28.1" packaging = "^25.0" tqdm = "^4.66.1" [tool.poetry.group.dev.dependencies] black = "*" pylint = "*" pytest = "*" pytest-dotenv = "*" pytest-cov = "*" [tool.poetry.scripts] gnome-extensions-cli = "gnome_extensions_cli.cli:run" gext = "gnome_extensions_cli.cli:run" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/samples/available-alt.json ================================================ { "uuid": "todo.txt@bart.libert.gmail.com", "name": "Todo.txt", "creator": "BartL", "creator_url": "/accounts/profile/BartL", "pk": 570, "description": "A Gnome shell interface for todo.txt. \n\nTodo.txt is a future-proof syntax for tasks (not made by me), for more info: http://todotxt.com/\n\nSome examples:\nTask: Basic task\n(A) Task: High priority task\nTask @project +context: Task is part of project and has a certain context\nx 2013-08-22 Task: Task was completed on the 22nd of August\n\nFor more info about the syntax: https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format\n\nQuick start:\nWhen you first enable the extension, chances are high you'll see a [X] in your top panel. If you click the [X], you will be able to choose between creating the necessary files automatically or selecting your own existing files to be used with the extension.\n\nPlease use the issue tracker on the homepage to report bugs and/or file feature requests, this makes tracking easier for me. Thanks!\n\nSee the included CHANGELOG.md for info about changes between different versions, or see it online: https://gitlab.com/todo.txt-gnome-shell-extension/todo-txt-gnome-shell-extension/-/blob/master/CHANGELOG.md", "link": "/extension/570/todotxt/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_570_5X5YkZb.png", "shell_version_map": { "3.4": { "pk": 6238, "version": 21 }, "3.6": { "pk": 6238, "version": 21 }, "3.8": { "pk": 6238, "version": 21 }, "3.10": { "pk": 8141, "version": 25 }, "3.12": { "pk": 8141, "version": 25 }, "3.14": { "pk": 8141, "version": 25 }, "3.16": { "pk": 8141, "version": 25 }, "3.18": { "pk": 8141, "version": 25 }, "3.20": { "pk": 8141, "version": 25 }, "3.22": { "pk": 8141, "version": 25 }, "3.24": { "pk": 8141, "version": 25 }, "3.26": { "pk": 8141, "version": 25 }, "3.28": { "pk": 8141, "version": 25 }, "3.32": { "pk": 11240, "version": 28 }, "3.34": { "pk": 13591, "version": 29 }, "3.36": { "pk": 19473, "version": 33 }, "3.38": { "pk": 19473, "version": 33 }, "40.0": { "pk": 22868, "version": 34 }, "40": { "pk": 26250, "version": 35 }, "41": { "pk": 30207, "version": 37 }, "42": { "pk": 30207, "version": 37 }, "43": { "pk": 36826, "version": 39 } } } ================================================ FILE: tests/samples/available.json ================================================ { "uuid": "todo.txt@bart.libert.gmail.com", "name": "Todo.txt", "creator": "BartL", "creator_url": "/accounts/profile/BartL", "pk": 570, "description": "A Gnome shell interface for todo.txt. \n\nTodo.txt is a future-proof syntax for tasks (not made by me), for more info: http://todotxt.com/\n\nSome examples:\nTask: Basic task\n(A) Task: High priority task\nTask @project +context: Task is part of project and has a certain context\nx 2013-08-22 Task: Task was completed on the 22nd of August\n\nFor more info about the syntax: https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format\n\nQuick start:\nWhen you first enable the extension, chances are high you'll see a [X] in your top panel. If you click the [X], you will be able to choose between creating the necessary files automatically or selecting your own existing files to be used with the extension.\n\nPlease use the issue tracker on the homepage to report bugs and/or file feature requests, this makes tracking easier for me. Thanks!\n\nSee the included CHANGELOG.md for info about changes between different versions, or see it online: https://gitlab.com/todo.txt-gnome-shell-extension/todo-txt-gnome-shell-extension/-/blob/master/CHANGELOG.md", "link": "/extension/570/todotxt/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_570_5X5YkZb.png", "shell_version_map": { "3.4": { "pk": 6238, "version": 21 }, "3.6": { "pk": 6238, "version": 21 }, "3.8": { "pk": 6238, "version": 21 }, "3.10": { "pk": 8141, "version": 25 }, "3.12": { "pk": 8141, "version": 25 }, "3.14": { "pk": 8141, "version": 25 }, "3.16": { "pk": 8141, "version": 25 }, "3.18": { "pk": 8141, "version": 25 }, "3.20": { "pk": 8141, "version": 25 }, "3.22": { "pk": 8141, "version": 25 }, "3.24": { "pk": 8141, "version": 25 }, "3.26": { "pk": 8141, "version": 25 }, "3.28": { "pk": 8141, "version": 25 }, "3.32": { "pk": 11240, "version": 28 }, "3.34": { "pk": 13591, "version": 29 }, "3.36": { "pk": 19473, "version": 33 }, "3.38": { "pk": 19473, "version": 33 }, "40.0": { "pk": 22868, "version": 34 }, "40": { "pk": 26250, "version": 35 }, "41": { "pk": 30207, "version": 37 }, "42": { "pk": 30207, "version": 37 }, "43": { "pk": 36826, "version": 39 } }, "version": 35, "version_tag": 26250, "download_url": "/download-extension/todo.txt@bart.libert.gmail.com.shell-extension.zip?version_tag=26250" } ================================================ FILE: tests/samples/installed.json ================================================ { "_generated": "Generated by SweetTooth, do not edit", "description": "A Gnome shell interface for todo.txt. \n\nTodo.txt is a future-proof syntax for tasks (not made by me), for more info: http://todotxt.com/\n\nSome examples:\nTask: Basic task\n(A) Task: High priority task\nTask @project +context: Task is part of project and has a certain context\nx 2013-08-22 Task: Task was completed on the 22nd of August\n\nFor more info about the syntax: https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format\n\nQuick start:\nWhen you first enable the extension, chances are high you'll see a [X] in your top panel. If you click the [X], you will be able to choose between creating the necessary files automatically or selecting your own existing files to be used with the extension.\n\nPlease use the issue tracker on the homepage to report bugs and/or file feature requests, this makes tracking easier for me. Thanks!\n\nSee the included CHANGELOG.md for info about changes between different versions, or see it online: https://gitlab.com/todo.txt-gnome-shell-extension/todo-txt-gnome-shell-extension/-/blob/master/CHANGELOG.md", "name": "Todo.txt", "shell-version": [ "3.36", "3.38" ], "url": "https://gitlab.com/todo.txt-gnome-shell-extension/todo-txt-gnome-shell-extension", "uuid": "todo.txt@bart.libert.gmail.com", "version": 33 } ================================================ FILE: tests/samples/search.json ================================================ { "extensions": [ { "uuid": "todolist@bsaleil.org", "name": "Todo list", "creator": "bsaleil", "creator_url": "/accounts/profile/bsaleil", "pk": 162, "description": "Simple todo list extension. You can add and remove tasks on your list.", "link": "/extension/162/todo-list/", "icon": "/extension-data/icons/icon_162_1.png", "screenshot": "/extension-data/screenshots/screenshot_162_1.png", "shell_version_map": { "3.2.1": { "pk": 622, "version": 4 }, "3.2": { "pk": 726, "version": 7 }, "3.3.4": { "pk": 726, "version": 7 }, "3.3.5": { "pk": 726, "version": 7 }, "3.4": { "pk": 1319, "version": 9 }, "3.6": { "pk": 2390, "version": 11 }, "3.8": { "pk": 3233, "version": 12 }, "3.10": { "pk": 3569, "version": 13 }, "3.14": { "pk": 4703, "version": 14 }, "3.32": { "pk": 9898, "version": 15 } } }, { "uuid": "todo_mail_UHA@vdebian", "name": "todo mail UHA", "creator": "projet_UHA", "creator_url": "/accounts/profile/projet_UHA", "pk": 778, "description": "You can quickly and easily send a mail via the taskbar GNOME.", "link": "/extension/778/todo-mail-uha/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_778_1.png", "shell_version_map": { "3.8": { "pk": 3641, "version": 1 } } }, { "uuid": "todolist@tomMoral.org", "name": "Section Todo List", "creator": "tomMoral", "creator_url": "/accounts/profile/tomMoral", "pk": 1104, "description": "Manage todo list with an applet\n\n* Add and remove task on your list in different sections.\n* Click an item to rename it.\n* Access the extension using Hot-Key (default: Ctrl+Space)\n\n**Note:** I don't come on this page often so if you need any help, please refer to the github repo and open an issue :) ", "link": "/extension/1104/section-todo-list/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_1104_0EguKVW.png", "shell_version_map": { "3.18": { "pk": 8308, "version": 6 }, "3.20": { "pk": 8308, "version": 6 }, "3.22": { "pk": 8427, "version": 8 }, "3.24": { "pk": 10921, "version": 9 }, "3.26": { "pk": 10921, "version": 9 }, "3.28": { "pk": 10921, "version": 9 }, "3.30": { "pk": 14156, "version": 10 }, "3.32": { "pk": 36189, "version": 13 }, "3.34": { "pk": 36189, "version": 13 }, "3.36": { "pk": 36189, "version": 13 }, "3.38": { "pk": 36189, "version": 13 }, "40": { "pk": 36189, "version": 13 }, "42": { "pk": 36189, "version": 13 }, "43": { "pk": 36189, "version": 13 } } }, { "uuid": "todo.txt@bart.libert.gmail.com", "name": "Todo.txt", "creator": "BartL", "creator_url": "/accounts/profile/BartL", "pk": 570, "description": "A Gnome shell interface for todo.txt. \n\nTodo.txt is a future-proof syntax for tasks (not made by me), for more info: http://todotxt.com/\n\nSome examples:\nTask: Basic task\n(A) Task: High priority task\nTask @project +context: Task is part of project and has a certain context\nx 2013-08-22 Task: Task was completed on the 22nd of August\n\nFor more info about the syntax: https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format\n\nQuick start:\nWhen you first enable the extension, chances are high you'll see a [X] in your top panel. If you click the [X], you will be able to choose between creating the necessary files automatically or selecting your own existing files to be used with the extension.\n\nPlease use the issue tracker on the homepage to report bugs and/or file feature requests, this makes tracking easier for me. Thanks!\n\nSee the included CHANGELOG.md for info about changes between different versions, or see it online: https://gitlab.com/todo.txt-gnome-shell-extension/todo-txt-gnome-shell-extension/-/blob/master/CHANGELOG.md", "link": "/extension/570/todotxt/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_570_5X5YkZb.png", "shell_version_map": { "3.4": { "pk": 6238, "version": 21 }, "3.6": { "pk": 6238, "version": 21 }, "3.8": { "pk": 6238, "version": 21 }, "3.10": { "pk": 8141, "version": 25 }, "3.12": { "pk": 8141, "version": 25 }, "3.14": { "pk": 8141, "version": 25 }, "3.16": { "pk": 8141, "version": 25 }, "3.18": { "pk": 8141, "version": 25 }, "3.20": { "pk": 8141, "version": 25 }, "3.22": { "pk": 8141, "version": 25 }, "3.24": { "pk": 8141, "version": 25 }, "3.26": { "pk": 8141, "version": 25 }, "3.28": { "pk": 8141, "version": 25 }, "3.32": { "pk": 11240, "version": 28 }, "3.34": { "pk": 13591, "version": 29 }, "3.36": { "pk": 19473, "version": 33 }, "3.38": { "pk": 19473, "version": 33 }, "40.0": { "pk": 22868, "version": 34 }, "40": { "pk": 26250, "version": 35 }, "41": { "pk": 30207, "version": 37 }, "42": { "pk": 30207, "version": 37 }, "43": { "pk": 36826, "version": 39 } } }, { "uuid": "timepp@zagortenay333", "name": "Time ++", "creator": "zagortenay33", "creator_url": "/accounts/profile/zagortenay33", "pk": 1238, "description": "A todo.txt manager, time tracker, timer, stopwatch, pomodoro, and alarm clock", "link": "/extension/1238/time/", "icon": "/extension-data/icons/icon_1238_etkhURE.png", "screenshot": "/extension-data/screenshots/screenshot_1238_VF4lJwP.png", "shell_version_map": { "3.24": { "pk": 8671, "version": 145 }, "3.26": { "pk": 8671, "version": 145 }, "3.28": { "pk": 8671, "version": 145 }, "3.30": { "pk": 8671, "version": 145 }, "3.32": { "pk": 9874, "version": 149 }, "3.34": { "pk": 14591, "version": 152 }, "3.36": { "pk": 20574, "version": 155 }, "3.38": { "pk": 20574, "version": 155 }, "40": { "pk": 28972, "version": 163 }, "41": { "pk": 28972, "version": 163 }, "42": { "pk": 33529, "version": 165 } } }, { "uuid": "rayday@jaypy.ir", "name": "Rayday", "creator": "jaypy", "creator_url": "/accounts/profile/jaypy", "pk": 4150, "description": "Rayday is a small CRM to manage your todos , deadlines and etc...", "link": "/extension/4150/rayday/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_4150_1RAoD0D.png", "shell_version_map": { "3.36": { "pk": 23482, "version": 1 } } }, { "uuid": "gnome-shell-Google-search-provider@MrNinso", "name": "Google Search Provider", "creator": "ninso", "creator_url": "/accounts/profile/ninso", "pk": 4132, "description": "Add Google search to Gnome Shell Search \n TODO: \n - Add google suggestions (For now using duckduckGo suggestions)", "link": "/extension/4132/google-search-provider/", "icon": "/static/images/plugin.png", "screenshot": "/extension-data/screenshots/screenshot_4132.png", "shell_version_map": { "3.36": { "pk": 28657, "version": 6 }, "3.38": { "pk": 28657, "version": 6 }, "40.0": { "pk": 23583, "version": 4 }, "40": { "pk": 28657, "version": 6 }, "41": { "pk": 28657, "version": 6 } } }, { "uuid": "battery-power-statistics-shortcut@l300lvl.co.nr", "name": "Battery Power Statistics Shortcut", "creator": "l300lvl", "creator_url": "/accounts/profile/l300lvl", "pk": 175, "description": "Adds a shortcut to open Power Statistics from the battery indicator. Incredibly useful for laptop users. Now compatible with the system-monitor extension http://tinyurl.com/sysmon Depends on: gnome-power-statistics todo:rework placement", "link": "/extension/175/battery-power-statistics-shortcut/", "icon": "/extension-data/icons/icon_175.png", "screenshot": "/extension-data/screenshots/screenshot_175_1.png", "shell_version_map": { "3.2": { "pk": 4746, "version": 9 }, "3.2.0": { "pk": 4746, "version": 9 }, "3.2.1": { "pk": 4746, "version": 9 }, "3.4": { "pk": 4746, "version": 9 }, "3.6": { "pk": 4746, "version": 9 }, "3.5.4": { "pk": 4746, "version": 9 }, "3.8": { "pk": 4746, "version": 9 }, "3.10": { "pk": 4746, "version": 9 }, "3.12": { "pk": 4746, "version": 9 }, "3.14": { "pk": 4746, "version": 9 }, "3.16": { "pk": 4746, "version": 9 }, "3.15.91": { "pk": 4746, "version": 9 }, "3.15.92": { "pk": 4746, "version": 9 } } }, { "uuid": "overview-on-startup@atz3.yahoo.com", "name": "Overview On Startup", "creator": "l300lvl", "creator_url": "/accounts/profile/l300lvl", "pk": 658, "description": "A simple extension with a primary task of launching the Overview at each start of Gnome Shell, e.g on login. Influenced by @gevera, the Applications/Frequent view is default, but has an option to show the Windows view. \n\ntodo: timeout option, eventually show the Overview when no windows are open or none are in focus, regardless of workspace/s and settings", "link": "/extension/658/overview-on-startup/", "icon": "/extension-data/icons/icon_658.png", "screenshot": "/extension-data/screenshots/screenshot_658.png", "shell_version_map": { "3.2": { "pk": 2793, "version": 1 }, "3.2.0": { "pk": 2793, "version": 1 }, "3.2.1": { "pk": 2793, "version": 1 }, "3.4": { "pk": 2873, "version": 6 }, "3.6": { "pk": 2873, "version": 6 }, "3.5.4": { "pk": 2873, "version": 6 }, "3.8": { "pk": 2873, "version": 6 } } }, { "uuid": "Analog_Clock@l300lvl.co.nr", "name": "Analog Neon Clock", "creator": "l300lvl", "creator_url": "/accounts/profile/l300lvl", "pk": 163, "description": "Replace digital clock with Analog Neon Clock for >3.4. Replaces the normal time/date but not the calendar. Placement preference now available(except 3.8). Colors can be changed via css, for now I added color notes in the css, a preference to change themes will come soon.\n\nTodo: placement bug, Allow keeping time/date, add time to menu, color/size/thickness prefs/seconds optional. Major credit on the work of this fork goes to https://extensions.gnome.org/accounts/profile/mathematical.coffee Fork based on original work by \"obneq\" https://github.com/obneq/", "link": "/extension/163/analog-clock/", "icon": "/extension-data/icons/icon_163_1.png", "screenshot": "/extension-data/screenshots/screenshot_163.png", "shell_version_map": { "3.4": { "pk": 2684, "version": 6 }, "3.6": { "pk": 2684, "version": 6 }, "3.6.2": { "pk": 2684, "version": 6 }, "3.8": { "pk": 2808, "version": 7 }, "3.9.1": { "pk": 3165, "version": 8 }, "3.9.3": { "pk": 3165, "version": 8 }, "3.9.5": { "pk": 3165, "version": 8 }, "3.10": { "pk": 3259, "version": 9 } } } ], "total": 10, "numpages": 2 } ================================================ FILE: tests/test_cli.py ================================================ import shlex from typing import List, Tuple from unittest import mock import pytest from gnome_extensions_cli import __version__ as version from gnome_extensions_cli import cli def run(capsys, args: str) -> Tuple[int, List[str], List[str]]: with pytest.raises(SystemExit) as error: with mock.patch("sys.argv", ["gext"] + shlex.split(args)): cli.run() captured = capsys.readouterr() return ( error.value.code, captured.out.splitlines(), captured.err.splitlines(), ) # pyright: reportGeneralTypeIssues=false def assert_no_error( rc: int, out: List[str], err: List[str] ) -> Tuple[int, List[str], List[str]]: assert rc == 0 assert len(out) > 0 assert len(err) == 0 return rc, out, err def test_help(capsys): assert_no_error(*run(capsys, "--help")) assert_no_error(*run(capsys, "disable --help")) assert_no_error(*run(capsys, "enable --help")) assert_no_error(*run(capsys, "install --help")) assert_no_error(*run(capsys, "list --help")) assert_no_error(*run(capsys, "preferences --help")) assert_no_error(*run(capsys, "search --help")) assert_no_error(*run(capsys, "show --help")) assert_no_error(*run(capsys, "uninstall --help")) assert_no_error(*run(capsys, "update --help")) def test_version(capsys): _rc, out, _err = assert_no_error(*run(capsys, "--version")) assert len(out) == 1 assert version in out[0] def test_error(capsys): rc, out, err = run(capsys, "foo bar") assert rc > 0 assert len(out) == 0 assert len(err) > 0 ================================================ FILE: tests/test_dbus.py ================================================ import pytest from gnome_extensions_cli.dbus import DbusExtensionManager, test_dbus_available @pytest.mark.skipif(not test_dbus_available(True), reason="DBus is not available") def test_dbus(): manager = DbusExtensionManager() assert manager.get_current_shell_version() is not None all_extensions = manager.list_installed_extensions() assert len(all_extensions) > 0 enabled_extensions = manager.list_enabled_uuids() assert len(enabled_extensions) > 0 assert len(all_extensions) > len(enabled_extensions) ================================================ FILE: tests/test_filesystem.py ================================================ from gnome_extensions_cli.filesystem import FilesystemExtensionManager from gnome_extensions_cli.store import GnomeExtensionStore def test_filesystem(): manager = FilesystemExtensionManager(store=GnomeExtensionStore()) assert manager.get_current_shell_version() is not None all_extensions = manager.list_installed_extensions() assert len(all_extensions) > 0 enabled_extensions = manager.list_enabled_uuids() assert len(enabled_extensions) >= 0 assert len(all_extensions) >= len(enabled_extensions) ================================================ FILE: tests/test_model.py ================================================ from pathlib import Path from gnome_extensions_cli.schema import AvailableExtension, Metadata, Search from gnome_extensions_cli.utils import version_comparator def test_version(): for a, b, out in ( (1, 2, 1), (2, 10, 1), (None, 1, 1), ("1.2", "1.2.0", 0), (1, "1", 0), (1.2, "1.2", 0), (None, None, 0), ): assert version_comparator(a, b) == out assert version_comparator(b, a) == out * -1 def test_schema(): for folder in filter( Path.is_dir, [ Path("/usr/share/gnome-shell/extensions"), Path("/usr/local/share/gnome-shell/extensions"), ], ): for sub in folder.iterdir(): metadata_file = sub / "metadata.json" if metadata_file.exists(): metadata = Metadata.model_validate_json(metadata_file.read_text()) assert metadata is not None def test_samples(): samples_dir = Path(__file__).parent / "samples" assert samples_dir.is_dir() assert ( AvailableExtension.model_validate_json( (samples_dir / "available.json").read_text() ) is not None ) assert ( AvailableExtension.model_validate_json( (samples_dir / "available-alt.json").read_text() ) is not None ) assert ( Metadata.model_validate_json((samples_dir / "installed.json").read_text()) is not None ) assert ( Search.model_validate_json((samples_dir / "search.json").read_text()) is not None ) ================================================ FILE: tests/test_store.py ================================================ from gnome_extensions_cli.store import GnomeExtensionStore def test_find(): store = GnomeExtensionStore() ext = store.find_by_uuid("todo.txt@bart.libert.gmail.com") assert ext is not None assert ext.pk == 570 assert ext.uuid == "todo.txt@bart.libert.gmail.com" assert ext.download_url is None assert ext == store.find_by_pk(ext.pk) def test_not_found(): store = GnomeExtensionStore() ext = store.find_by_uuid("this-extension-does-not-exists") assert ext is None def test_shell_version(): store = GnomeExtensionStore() ext = store.find_by_uuid("todo.txt@bart.libert.gmail.com", shell_version="40") assert ext is not None assert ext.download_url is not None