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