├── .github └── workflows │ ├── codeql-analysis.yml │ ├── port-to-py35.yml │ ├── release.yml │ ├── test-pypi.yml │ └── tests.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── mplug ├── noxfile.py ├── poetry.lock ├── pyproject.toml ├── run.py ├── src └── mplug │ ├── __init__.py │ ├── download.py │ ├── interaction.py │ ├── mplug.py │ └── util.py └── test ├── __init__.py ├── test_cli.py ├── test_interaction.py ├── test_mplug.py └── test_util.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 12 * * 6' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/port-to-py35.yml: -------------------------------------------------------------------------------- 1 | name: Port to python 3.5 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | fstrings: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.5'] 12 | name: Python ${{ matrix.python-version }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | ref: python35 17 | fetch-depth: 0 18 | - uses: actions/setup-python@v1 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | architecture: x64 22 | - run: sudo apt-get install moreutils 23 | - run: pip install future-fstrings==1.2.0 24 | - name: Merge changes from master, overwrite all changes to python code 25 | run: | 26 | git config user.name github-actions 27 | git config user.email github-actions@github.com 28 | git merge origin/master 29 | git checkout --theirs src/mplug/*.py 30 | git add src/mplug/*.py 31 | - name: Replace f-strings 32 | working-directory: ./src/mplug 33 | run: | 34 | future-fstrings-show __init__.py | sponge __init__.py 35 | future-fstrings-show mplug.py | sponge mplug.py 36 | future-fstrings-show interaction.py | sponge interaction.py 37 | future-fstrings-show util.py | sponge util.py 38 | future-fstrings-show download.py | sponge download.py 39 | shell: bash 40 | - name: Commit changes 41 | run: | 42 | date > generated.txt 43 | git add generated.txt 44 | git add src/mplug/*.py 45 | git commit -m "Generate python 3.5 compatible code from current master" 46 | git push 47 | 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | ref: python35 12 | - uses: actions/setup-python@v1 13 | with: 14 | python-version: '3.8' 15 | architecture: x64 16 | - run: pip install nox==2020.8.22 17 | - run: pip install poetry==1.0.10 18 | - run: nox 19 | - run: poetry build 20 | - run: poetry publish --username=__token__ --password=${{ secrets.PYPI_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: TestPyPI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test_pypi: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v1 12 | with: 13 | python-version: '3.8' 14 | architecture: x64 15 | - run: pip install poetry==1.0.10 16 | - run: >- 17 | poetry version patch && 18 | version=$(poetry version | awk '{print $2}') && 19 | poetry version $version.dev.$(date +%s) 20 | - run: poetry build 21 | - uses: pypa/gh-action-pypi-publish@v1.0.0a0 22 | with: 23 | user: __token__ 24 | password: ${{ secrets.TEST_PYPI_TOKEN }} 25 | repository_url: https://test.pypi.org/legacy/ 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ['3.6', '3.7', '3.8'] 9 | name: Python ${{ matrix.python-version }} 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v1 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | architecture: x64 16 | - run: pip install nox==2020.8.22 17 | - run: pip install poetry==1.0.10 18 | - run: nox 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = C0330, C0326, C0199, C0411, W1203, R1705 3 | 4 | [DESIGN] 5 | max-attributes=8 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MPlug – a Plugin Manager for MPV 2 | ================================ 3 | 4 | A plugin manager for [mpv](https://mpv.io/) to easy install and uninstall mpv scripts and more. 5 | 6 | Motivation 7 | ---------- 8 | Mpv is a great, free and open source video player. It has interfaces to extend 9 | it with different types of scripts and filters. There is a large number of 10 | awesome plugins: Watch [Youtube](https://youtube-dl.org/), [remove black bars](https://github.com/mpv-player/mpv/blob/master/TOOLS/lua/autocrop.lua), [improve the quality of Anime](https://github.com/bloc97/Anime4K), 11 | [remove noise from lecture recordings](https://github.com/werman/noise-suppression-for-voice), [skip adds](https://github.com/po5/mpv_sponsorblock)… The possibilities are endless. 12 | 13 | MPlug tries to make finding, installing and updating plugins as easy as possible. 14 | 15 | Note: The [underlying repository](https://github.com/Nudin/mpv-script-directory) of plugins is not (yet) complete, therefore not 16 | all plugins can be installed automatically so far. Please help [filling it](https://github.com/Nudin/mpv-script-directory/blob/master/HOWTO_ADD_INSTALL_INSTRUCTIONS.md). 17 | 18 | Installation 19 | ------------ 20 | You can install it via pip: 21 | ``` 22 | $ pip3 install mplug 23 | ``` 24 | 25 | Alternatively you can run it from the source: 26 | - Install dependencies: python3, [GitPython](https://pypi.org/project/GitPython/) 27 | - Clone this repository 28 | - Run with `run.py` 29 | 30 | Usage 31 | ----- 32 | - You can find plugins in the WebUI of the [mpv script directory](https://nudin.github.io/mpv-script-directory/) 33 | - To install a plugin `mplug install plugin_name` 34 | - To update all plugins: `mplug upgrade` 35 | - To upgrade database: `mplug update` 36 | - To uninstall a plugin: `mplug uninstall plugin_id` 37 | - To disable a plugin without uninstalling it: `mplug disable plugin_id` 38 | - To search for a plugin `mplug search term` 39 | - To list all installed plugins `mplug list-installed` 40 | 41 | Status & Todo 42 | ------------- 43 | - [X] Populate mpv script directory, by scraping wiki 44 | - [X] First version of plugin manager 45 | - [X] Write a Webinterface to browse plugins 46 | - [ ] Add install instructions for **all** plugins to the [mpv script directory](https://github.com/Nudin/mpv-script-directory) 47 | - [ ] Write a TUI? 48 | - [ ] Write a GUI? 49 | -------------------------------------------------------------------------------- /mplug: -------------------------------------------------------------------------------- 1 | src/mplug/ -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | nox.options.sessions = "lint", "tests", "mypy" 4 | 5 | locations = ["src/mplug/"] 6 | 7 | 8 | @nox.session(python=["3.8", "3.7", "3.6"]) 9 | def tests(session): 10 | args = session.posargs or ["--cov", *locations] 11 | session.run("poetry", "install", external=True) 12 | session.run("pytest", *args) 13 | 14 | 15 | @nox.session(python="3.8") 16 | def lint(session): 17 | args = session.posargs or locations 18 | session.run("poetry", "install", external=True) 19 | session.install("pylint") 20 | session.run("pylint", *args) 21 | 22 | 23 | @nox.session(python="3.8") 24 | def black(session): 25 | args = session.posargs or locations 26 | session.install("black") 27 | session.run("black", *args) 28 | 29 | 30 | @nox.session(python=["3.8", "3.7"]) 31 | def mypy(session): 32 | args = session.posargs or locations 33 | session.install("mypy") 34 | session.run("mypy", *args) 35 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "astroid" 3 | version = "2.4.2" 4 | description = "An abstract syntax tree for Python with inference support." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.5" 8 | 9 | [package.dependencies] 10 | lazy-object-proxy = ">=1.4.0,<1.5.0" 11 | six = ">=1.12,<2.0" 12 | wrapt = ">=1.11,<2.0" 13 | 14 | [package.dependencies.typed-ast] 15 | version = ">=1.4.0,<1.5" 16 | python = "<3.8" 17 | 18 | [[package]] 19 | name = "atomicwrites" 20 | version = "1.4.0" 21 | description = "Atomic file writes." 22 | category = "dev" 23 | optional = false 24 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 25 | marker = "sys_platform == \"win32\"" 26 | 27 | [[package]] 28 | name = "attrs" 29 | version = "20.1.0" 30 | description = "Classes Without Boilerplate" 31 | category = "dev" 32 | optional = false 33 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 34 | 35 | [package.extras] 36 | dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] 37 | docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] 38 | tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 39 | 40 | [[package]] 41 | name = "certifi" 42 | version = "2020.6.20" 43 | description = "Python package for providing Mozilla's CA Bundle." 44 | category = "main" 45 | optional = false 46 | python-versions = "*" 47 | 48 | [[package]] 49 | name = "chardet" 50 | version = "3.0.4" 51 | description = "Universal encoding detector for Python 2 and 3" 52 | category = "main" 53 | optional = false 54 | python-versions = "*" 55 | 56 | [[package]] 57 | name = "colorama" 58 | version = "0.4.3" 59 | description = "Cross-platform colored terminal text." 60 | category = "dev" 61 | optional = false 62 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 63 | marker = "sys_platform == \"win32\"" 64 | 65 | [[package]] 66 | name = "coverage" 67 | version = "5.2.1" 68 | description = "Code coverage measurement for Python" 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 72 | 73 | [package.extras] 74 | toml = ["toml"] 75 | 76 | [package.dependencies] 77 | [package.dependencies.toml] 78 | version = "*" 79 | optional = true 80 | 81 | [[package]] 82 | name = "gitdb" 83 | version = "4.0.5" 84 | description = "Git Object Database" 85 | category = "main" 86 | optional = false 87 | python-versions = ">=3.4" 88 | 89 | [package.dependencies] 90 | smmap = ">=3.0.1,<4" 91 | 92 | [[package]] 93 | name = "gitpython" 94 | version = "3.1.7" 95 | description = "Python Git Library" 96 | category = "main" 97 | optional = false 98 | python-versions = ">=3.4" 99 | 100 | [package.dependencies] 101 | gitdb = ">=4.0.1,<5" 102 | 103 | [[package]] 104 | name = "idna" 105 | version = "2.10" 106 | description = "Internationalized Domain Names in Applications (IDNA)" 107 | category = "main" 108 | optional = false 109 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 110 | 111 | [[package]] 112 | name = "importlib-metadata" 113 | version = "1.7.0" 114 | description = "Read metadata from Python packages" 115 | category = "main" 116 | optional = false 117 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 118 | marker = "python_version < \"3.8\"" 119 | 120 | [package.extras] 121 | docs = ["sphinx", "rst.linker"] 122 | testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] 123 | 124 | [package.dependencies] 125 | zipp = ">=0.5" 126 | 127 | [[package]] 128 | name = "iniconfig" 129 | version = "1.0.1" 130 | description = "iniconfig: brain-dead simple config-ini parsing" 131 | category = "dev" 132 | optional = false 133 | python-versions = "*" 134 | 135 | [[package]] 136 | name = "isort" 137 | version = "5.5.2" 138 | description = "A Python utility / library to sort Python imports." 139 | category = "dev" 140 | optional = false 141 | python-versions = ">=3.6,<4.0" 142 | 143 | [package.extras] 144 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 145 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 146 | colors = ["colorama (>=0.4.3,<0.5.0)"] 147 | 148 | [[package]] 149 | name = "lazy-object-proxy" 150 | version = "1.4.3" 151 | description = "A fast and thorough lazy object proxy." 152 | category = "dev" 153 | optional = false 154 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 155 | 156 | [[package]] 157 | name = "mccabe" 158 | version = "0.6.1" 159 | description = "McCabe checker, plugin for flake8" 160 | category = "dev" 161 | optional = false 162 | python-versions = "*" 163 | 164 | [[package]] 165 | name = "more-itertools" 166 | version = "8.4.0" 167 | description = "More routines for operating on iterables, beyond itertools" 168 | category = "dev" 169 | optional = false 170 | python-versions = ">=3.5" 171 | 172 | [[package]] 173 | name = "mypy" 174 | version = "0.782" 175 | description = "Optional static typing for Python" 176 | category = "dev" 177 | optional = false 178 | python-versions = ">=3.5" 179 | 180 | [package.extras] 181 | dmypy = ["psutil (>=4.0)"] 182 | 183 | [package.dependencies] 184 | mypy-extensions = ">=0.4.3,<0.5.0" 185 | typed-ast = ">=1.4.0,<1.5.0" 186 | typing-extensions = ">=3.7.4" 187 | 188 | [[package]] 189 | name = "mypy-extensions" 190 | version = "0.4.3" 191 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 192 | category = "dev" 193 | optional = false 194 | python-versions = "*" 195 | 196 | [[package]] 197 | name = "packaging" 198 | version = "20.4" 199 | description = "Core utilities for Python packages" 200 | category = "dev" 201 | optional = false 202 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 203 | 204 | [package.dependencies] 205 | pyparsing = ">=2.0.2" 206 | six = "*" 207 | 208 | [[package]] 209 | name = "pluggy" 210 | version = "0.13.1" 211 | description = "plugin and hook calling mechanisms for python" 212 | category = "dev" 213 | optional = false 214 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 215 | 216 | [package.extras] 217 | dev = ["pre-commit", "tox"] 218 | 219 | [package.dependencies] 220 | [package.dependencies.importlib-metadata] 221 | version = ">=0.12" 222 | python = "<3.8" 223 | 224 | [[package]] 225 | name = "py" 226 | version = "1.9.0" 227 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 228 | category = "dev" 229 | optional = false 230 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 231 | 232 | [[package]] 233 | name = "pylint" 234 | version = "2.6.0" 235 | description = "python code static checker" 236 | category = "dev" 237 | optional = false 238 | python-versions = ">=3.5.*" 239 | 240 | [package.dependencies] 241 | astroid = ">=2.4.0,<=2.5" 242 | colorama = "*" 243 | isort = ">=4.2.5,<6" 244 | mccabe = ">=0.6,<0.7" 245 | toml = ">=0.7.1" 246 | 247 | [[package]] 248 | name = "pyparsing" 249 | version = "2.4.7" 250 | description = "Python parsing module" 251 | category = "dev" 252 | optional = false 253 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 254 | 255 | [[package]] 256 | name = "pytest" 257 | version = "6.0.1" 258 | description = "pytest: simple powerful testing with Python" 259 | category = "dev" 260 | optional = false 261 | python-versions = ">=3.5" 262 | 263 | [package.extras] 264 | checkqa_mypy = ["mypy (0.780)"] 265 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 266 | 267 | [package.dependencies] 268 | atomicwrites = ">=1.0" 269 | attrs = ">=17.4.0" 270 | colorama = "*" 271 | iniconfig = "*" 272 | more-itertools = ">=4.0.0" 273 | packaging = "*" 274 | pluggy = ">=0.12,<1.0" 275 | py = ">=1.8.2" 276 | toml = "*" 277 | 278 | [package.dependencies.importlib-metadata] 279 | version = ">=0.12" 280 | python = "<3.8" 281 | 282 | [[package]] 283 | name = "pytest-cov" 284 | version = "2.10.1" 285 | description = "Pytest plugin for measuring coverage." 286 | category = "dev" 287 | optional = false 288 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 289 | 290 | [package.extras] 291 | testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] 292 | 293 | [package.dependencies] 294 | coverage = ">=4.4" 295 | pytest = ">=4.6" 296 | 297 | [[package]] 298 | name = "pytest-mock" 299 | version = "3.3.0" 300 | description = "Thin-wrapper around the mock package for easier use with pytest" 301 | category = "dev" 302 | optional = false 303 | python-versions = ">=3.5" 304 | 305 | [package.extras] 306 | dev = ["pre-commit", "tox", "pytest-asyncio"] 307 | 308 | [package.dependencies] 309 | pytest = ">=5.0" 310 | 311 | [[package]] 312 | name = "requests" 313 | version = "2.24.0" 314 | description = "Python HTTP for Humans." 315 | category = "main" 316 | optional = false 317 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 318 | 319 | [package.extras] 320 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] 321 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] 322 | 323 | [package.dependencies] 324 | certifi = ">=2017.4.17" 325 | chardet = ">=3.0.2,<4" 326 | idna = ">=2.5,<3" 327 | urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" 328 | 329 | [[package]] 330 | name = "six" 331 | version = "1.15.0" 332 | description = "Python 2 and 3 compatibility utilities" 333 | category = "dev" 334 | optional = false 335 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 336 | 337 | [[package]] 338 | name = "smmap" 339 | version = "3.0.4" 340 | description = "A pure Python implementation of a sliding window memory map manager" 341 | category = "main" 342 | optional = false 343 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 344 | 345 | [[package]] 346 | name = "toml" 347 | version = "0.10.1" 348 | description = "Python Library for Tom's Obvious, Minimal Language" 349 | category = "dev" 350 | optional = false 351 | python-versions = "*" 352 | 353 | [[package]] 354 | name = "typed-ast" 355 | version = "1.4.1" 356 | description = "a fork of Python 2 and 3 ast modules with type comment support" 357 | category = "dev" 358 | optional = false 359 | python-versions = "*" 360 | 361 | [[package]] 362 | name = "typing-extensions" 363 | version = "3.7.4.3" 364 | description = "Backported and Experimental Type Hints for Python 3.5+" 365 | category = "dev" 366 | optional = false 367 | python-versions = "*" 368 | 369 | [[package]] 370 | name = "urllib3" 371 | version = "1.25.10" 372 | description = "HTTP library with thread-safe connection pooling, file post, and more." 373 | category = "main" 374 | optional = false 375 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 376 | 377 | [package.extras] 378 | brotli = ["brotlipy (>=0.6.0)"] 379 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] 380 | socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] 381 | 382 | [[package]] 383 | name = "wrapt" 384 | version = "1.12.1" 385 | description = "Module for decorators, wrappers and monkey patching." 386 | category = "dev" 387 | optional = false 388 | python-versions = "*" 389 | 390 | [[package]] 391 | name = "zipp" 392 | version = "3.1.0" 393 | description = "Backport of pathlib-compatible object wrapper for zip files" 394 | category = "main" 395 | optional = false 396 | python-versions = ">=3.6" 397 | marker = "python_version < \"3.8\"" 398 | 399 | [package.extras] 400 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 401 | testing = ["jaraco.itertools", "func-timeout"] 402 | 403 | [metadata] 404 | lock-version = "1.0" 405 | python-versions = "^3.6" 406 | content-hash = "140696d3f33c30aa9ad9b073a84db166c91b1df93e1cefdced3c1f55f3802fdb" 407 | 408 | [metadata.files] 409 | astroid = [ 410 | {file = "astroid-2.4.2-py3-none-any.whl", hash = "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"}, 411 | {file = "astroid-2.4.2.tar.gz", hash = "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703"}, 412 | ] 413 | atomicwrites = [ 414 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 415 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 416 | ] 417 | attrs = [ 418 | {file = "attrs-20.1.0-py2.py3-none-any.whl", hash = "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"}, 419 | {file = "attrs-20.1.0.tar.gz", hash = "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a"}, 420 | ] 421 | certifi = [ 422 | {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, 423 | {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, 424 | ] 425 | chardet = [ 426 | {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, 427 | {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, 428 | ] 429 | colorama = [ 430 | {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, 431 | {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, 432 | ] 433 | coverage = [ 434 | {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"}, 435 | {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"}, 436 | {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"}, 437 | {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"}, 438 | {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"}, 439 | {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"}, 440 | {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"}, 441 | {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"}, 442 | {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"}, 443 | {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"}, 444 | {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"}, 445 | {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"}, 446 | {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"}, 447 | {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"}, 448 | {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"}, 449 | {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"}, 450 | {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"}, 451 | {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"}, 452 | {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"}, 453 | {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"}, 454 | {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"}, 455 | {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"}, 456 | {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"}, 457 | {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"}, 458 | {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"}, 459 | {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"}, 460 | {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"}, 461 | {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"}, 462 | {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"}, 463 | {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"}, 464 | {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"}, 465 | {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"}, 466 | {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"}, 467 | {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"}, 468 | ] 469 | gitdb = [ 470 | {file = "gitdb-4.0.5-py3-none-any.whl", hash = "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac"}, 471 | {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, 472 | ] 473 | gitpython = [ 474 | {file = "GitPython-3.1.7-py3-none-any.whl", hash = "sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5"}, 475 | {file = "GitPython-3.1.7.tar.gz", hash = "sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858"}, 476 | ] 477 | idna = [ 478 | {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, 479 | {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, 480 | ] 481 | importlib-metadata = [ 482 | {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, 483 | {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, 484 | ] 485 | iniconfig = [ 486 | {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"}, 487 | {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"}, 488 | ] 489 | isort = [ 490 | {file = "isort-5.5.2-py3-none-any.whl", hash = "sha256:ba91218eee31f1e300ecc079ef0c524cea3fc41bfbb979cbdf5fd3a889e3cfed"}, 491 | {file = "isort-5.5.2.tar.gz", hash = "sha256:171c5f365791073426b5ed3a156c2081a47f88c329161fd28228ff2da4c97ddb"}, 492 | ] 493 | lazy-object-proxy = [ 494 | {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, 495 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, 496 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, 497 | {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, 498 | {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, 499 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, 500 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, 501 | {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, 502 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, 503 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, 504 | {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, 505 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, 506 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, 507 | {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, 508 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, 509 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, 510 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, 511 | {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, 512 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, 513 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, 514 | {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, 515 | ] 516 | mccabe = [ 517 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 518 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 519 | ] 520 | more-itertools = [ 521 | {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, 522 | {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, 523 | ] 524 | mypy = [ 525 | {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"}, 526 | {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"}, 527 | {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"}, 528 | {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"}, 529 | {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"}, 530 | {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"}, 531 | {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"}, 532 | {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"}, 533 | {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"}, 534 | {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"}, 535 | {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"}, 536 | {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"}, 537 | {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"}, 538 | {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"}, 539 | ] 540 | mypy-extensions = [ 541 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 542 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 543 | ] 544 | packaging = [ 545 | {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, 546 | {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, 547 | ] 548 | pluggy = [ 549 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 550 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 551 | ] 552 | py = [ 553 | {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, 554 | {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, 555 | ] 556 | pylint = [ 557 | {file = "pylint-2.6.0-py3-none-any.whl", hash = "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f"}, 558 | {file = "pylint-2.6.0.tar.gz", hash = "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210"}, 559 | ] 560 | pyparsing = [ 561 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 562 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 563 | ] 564 | pytest = [ 565 | {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"}, 566 | {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"}, 567 | ] 568 | pytest-cov = [ 569 | {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, 570 | {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, 571 | ] 572 | pytest-mock = [ 573 | {file = "pytest-mock-3.3.0.tar.gz", hash = "sha256:1d146a6e798b9e6322825e207b4e0544635e679b69253e6e01a221f45945d2f6"}, 574 | {file = "pytest_mock-3.3.0-py3-none-any.whl", hash = "sha256:0061f9e8f14b77d0f3915a00f18b1b71f07da3c8bd66994e42ee91537681a76e"}, 575 | ] 576 | requests = [ 577 | {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, 578 | {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, 579 | ] 580 | six = [ 581 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 582 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 583 | ] 584 | smmap = [ 585 | {file = "smmap-3.0.4-py2.py3-none-any.whl", hash = "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4"}, 586 | {file = "smmap-3.0.4.tar.gz", hash = "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"}, 587 | ] 588 | toml = [ 589 | {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, 590 | {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, 591 | ] 592 | typed-ast = [ 593 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 594 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 595 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 596 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 597 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 598 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 599 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 600 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 601 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 602 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 603 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 604 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 605 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 606 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 607 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 608 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 609 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 610 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 611 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 612 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 613 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 614 | ] 615 | typing-extensions = [ 616 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 617 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 618 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 619 | ] 620 | urllib3 = [ 621 | {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, 622 | {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, 623 | ] 624 | wrapt = [ 625 | {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, 626 | ] 627 | zipp = [ 628 | {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, 629 | {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, 630 | ] 631 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mplug" 3 | version = "0.2.1" 4 | description = "A plugin manager for mpv" 5 | authors = ["Michael F. Schönitzer "] 6 | license = "MPL-2.0" 7 | readme = "README.md" 8 | homepage = "https://github.com/nudin/mplug" 9 | repository = "https://github.com/nudin/mplug" 10 | keywords = ["mpv"] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.6" 14 | GitPython = "^3.1.7" 15 | requests = "^2.24.0" 16 | importlib_metadata = {version = "^1.7.0", python = "<3.8"} 17 | 18 | [tool.poetry.dev-dependencies] 19 | pytest = "^6.0.1" 20 | pytest-mock = "^3.3.0" 21 | coverage = {extras = ["toml"], version = "^5.2.1"} 22 | pytest-cov = "^2.10.1" 23 | mypy = "^0.782" 24 | pylint = "^2.6.0" 25 | 26 | [build-system] 27 | requires = ["poetry>=0.12"] 28 | build-backend = "poetry.masonry.api" 29 | 30 | [tool.poetry.scripts] 31 | mplug = "mplug:run" 32 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import mplug 4 | 5 | mplug.run() 6 | -------------------------------------------------------------------------------- /src/mplug/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (C) Michael F. Schönitzer, 2020 7 | 8 | """ 9 | MPlug – a plugin manager for mpv. 10 | 11 | See here for details: 12 | https://github.com/Nudin/mplug 13 | """ 14 | 15 | import logging 16 | import sys 17 | import textwrap 18 | from typing import Optional 19 | 20 | from .mplug import MPlug 21 | 22 | try: 23 | from importlib.metadata import PackageNotFoundError, version # type: ignore 24 | except ImportError: # pragma: no cover 25 | from importlib_metadata import PackageNotFoundError, version # type: ignore 26 | 27 | 28 | NAME = "mplug" 29 | try: 30 | VERSION = version(__name__) 31 | except PackageNotFoundError: # pragma: no cover 32 | VERSION = "unknown" 33 | 34 | 35 | def print_help(): 36 | """Print help. 37 | 38 | This is so far done by hand, until it's worth to use a proper argument parser.""" 39 | help_text = f"""\ 40 | {NAME} {VERSION} 41 | 42 | Usage: {NAME} [-v] command 43 | 44 | Available commands: 45 | - install NAME|ID Install a plugin by name or plugin-id 46 | - uninstall NAME|ID Remove a plugin from the system 47 | - disable NAME|ID Disable a plugin without deleting it from the system 48 | - search TEXT Search for a plugin by name and description 49 | - update Update the list of available plugins 50 | - upgrade Update all plugins 51 | - list-installed List all plugins installed with {NAME} 52 | """ 53 | print(textwrap.dedent(help_text)) 54 | 55 | 56 | logging.basicConfig(level="INFO", format="%(message)s") 57 | 58 | 59 | def main(operation: str, name: Optional[str] = None, verbose: bool = False): 60 | """Load mplug and call the desired operation.""" 61 | # Initialize mplug and load script directory 62 | plug = MPlug(verbose) 63 | 64 | if operation == "install": 65 | assert name is not None 66 | plug.install_by_name(name) 67 | elif operation == "uninstall": 68 | assert name is not None 69 | plug.uninstall_by_name(name) 70 | elif operation == "search": 71 | assert name is not None 72 | plug.search(name) 73 | elif operation == "disable": 74 | assert name is not None 75 | plug.uninstall_by_name(name, remove=False) 76 | elif operation == "update": 77 | plug.update() 78 | elif operation == "upgrade": 79 | plug.upgrade() 80 | elif operation == "list-installed": 81 | plug.list_installed() 82 | 83 | plug.save_state_to_disk() 84 | 85 | 86 | def arg_parse(argv): 87 | """Parse the command line arguments.""" 88 | if len(argv) > 1 and argv[1] == "-v": 89 | verbose = True 90 | logging.getLogger().setLevel("DEBUG") 91 | del argv[1] 92 | else: 93 | verbose = False 94 | if len(argv) < 2: 95 | print_help() 96 | sys.exit(0) 97 | operation = argv[1] 98 | 99 | if operation == "help": 100 | print_help() 101 | sys.exit(0) 102 | 103 | if operation not in [ 104 | "install", 105 | "upgrade", 106 | "uninstall", 107 | "update", 108 | "disable", 109 | "search", 110 | "list-installed", 111 | ]: 112 | print_help() 113 | sys.exit(1) 114 | 115 | if operation in ["install", "uninstall", "search", "disable"]: 116 | if len(argv) < 3: 117 | print_help() 118 | sys.exit(2) 119 | else: 120 | name = argv[2] 121 | else: 122 | name = None 123 | return operation, name, verbose 124 | 125 | 126 | def run(): 127 | """Main entry point""" 128 | main(*arg_parse(sys.argv)) 129 | 130 | 131 | if __name__ == "__main__": 132 | run() 133 | -------------------------------------------------------------------------------- /src/mplug/download.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | 7 | """ 8 | Functions to download plugins from an url. 9 | 10 | Functions: download_file, download_tar, git_clone_or_pull, git_pull 11 | """ 12 | 13 | import logging 14 | import tarfile 15 | import tempfile 16 | from pathlib import Path 17 | 18 | import requests 19 | from git import Repo # type: ignore 20 | 21 | 22 | def download_file(url: str, filename: Path): 23 | """Dowload file and save it to disk.""" 24 | result = requests.get(url) 25 | with open(filename, "wb") as output_file: 26 | output_file.write(result.content) 27 | 28 | 29 | def download_tar(url: str, directory: Path): 30 | """Download and extract a tarbar to the give directory.""" 31 | result = requests.get(url) 32 | with tempfile.TemporaryFile("rb+") as tmp: 33 | tmp.write(result.content) 34 | tmp.seek(0) 35 | tar = tarfile.open(fileobj=tmp) 36 | tar.extractall(directory) 37 | 38 | 39 | def git_clone_or_pull(repourl: str, gitdir: Path) -> Repo: 40 | """Clone or update a repository into a given folder.""" 41 | if gitdir.exists(): 42 | repo = Repo(gitdir) 43 | logging.debug("Repo already cloned, pull latest changes instead.") 44 | repo.remote().pull() 45 | else: 46 | repo = Repo.clone_from(repourl, gitdir, multi_options=["--depth 1"]) 47 | return repo 48 | 49 | 50 | def git_pull(gitdir: Path) -> Repo: 51 | """Update the git repository at the given location.""" 52 | repo = Repo(gitdir) 53 | repo.remote().pull() 54 | return repo 55 | -------------------------------------------------------------------------------- /src/mplug/interaction.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | 7 | """ 8 | Functions that interact with the user on the command line, by promoting for input. 9 | """ 10 | 11 | import platform 12 | from itertools import zip_longest 13 | from pathlib import Path 14 | from typing import List, Optional 15 | 16 | from .util import wrap 17 | 18 | 19 | def ask_num( 20 | question: str, options: List[str], descriptions: Optional[List[str]] = None 21 | ) -> Optional[str]: 22 | """Ask to choose from a number of options. 23 | 24 | The user is given a list of numbered options and should pick one, by 25 | entering the corresponding number. 26 | 27 | question: The message that is shown to the user 28 | options: The options from which the user should pick 29 | descriptions: Array of strings, same length as options. Description for the 30 | options that will be printed together with the options. 31 | returns: the choice 32 | """ 33 | print(wrap(question)) 34 | for i, (opt, desc) in enumerate(zip_longest(options, descriptions or [])): 35 | print(f"[{i}] {opt}") 36 | if desc is not None: 37 | print(wrap(desc, indent=1)) 38 | try: 39 | answer = input("> ") 40 | num = int(answer) 41 | assert num >= 0 42 | assert num < len(options) 43 | return options[num] 44 | except ValueError: 45 | return None 46 | except AssertionError: 47 | return None 48 | except KeyboardInterrupt: 49 | print() 50 | return None 51 | except EOFError: 52 | print() 53 | return None 54 | 55 | 56 | def ask_yes_no(question: str) -> bool: 57 | """Ask a yes-no-question.""" 58 | answer = input(f"{question} [y/N] ") 59 | if answer in ["y", "Y"]: 60 | return True 61 | return False 62 | 63 | 64 | def ask_path(question: str, default: Path) -> Path: 65 | """Ask the user for a file path, with a fallback if the user does not enter 66 | anything. 67 | 68 | question: Text to display on promt 69 | default: Default path, returned if user gives no input""" 70 | pathstr = input(f"{question} [{default}]\n> ").strip() 71 | if pathstr == "": 72 | path = default 73 | else: 74 | path = Path(pathstr) 75 | return path.expanduser().absolute() 76 | 77 | 78 | def check_os(supported: List[str]) -> bool: 79 | """Check if the operating system is supported, if not promt the user to 80 | decide if the installation should be continued anyway.""" 81 | if supported == []: 82 | return True 83 | current_os = platform.system().title() 84 | if current_os in supported: 85 | return True 86 | os_string = ", ".join(supported) 87 | return ask_yes_no("Warning: This plugin works only on: %s. Continue?" % os_string) 88 | -------------------------------------------------------------------------------- /src/mplug/mplug.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | """ 7 | Main module containing the main class MPlug. 8 | 9 | The MPlug class is the plugin manager that has functions that can be called to 10 | install, uninstall or query plugins. 11 | MPlug works using the `mpv script directory`, see here for details: 12 | https://github.com/Nudin/mpv-script-directory 13 | """ 14 | 15 | import json 16 | import logging 17 | import os 18 | import shutil 19 | import sys 20 | from datetime import datetime 21 | from pathlib import Path 22 | from typing import List, Optional 23 | 24 | from .download import download_file, download_tar, git_clone_or_pull, git_pull 25 | from .interaction import ask_num, ask_path, ask_yes_no, check_os 26 | from .util import make_files_executable, resolve_templates, wrap 27 | 28 | NAME = "mplug" 29 | 30 | 31 | class MPlug: 32 | """Plugin Manager for mpv. 33 | 34 | MPlug can install, update and uninstall plugins, shaders, or other tools to 35 | enhance the video player mpv. It is based on the `mpv script directory`, 36 | that ideally contains all known plugins with machine readable metadata to 37 | install them. The git repository of the plugin directory is cloned into a 38 | subdirectory of the working directory and updated by hand (update()) or 39 | after 30 days. 40 | 41 | When installing a plugin it is downloaded (usually via git) into a 42 | subdirectory of the working directory. Symbolic Links to the scrip files 43 | are then created in the folders that mpv expects them to be. This way 44 | plugins can be easily disabled or updated and the directories stay clear 45 | and uncluttered. A file is used to keep a list of all installed plugins. 46 | """ 47 | 48 | directory_foldername = "mpv_script_dir" 49 | directory_filename = "mpv_script_directory.json" 50 | directory_remoteurl = "https://github.com/Nudin/mpv-script-directory.git" 51 | 52 | def __init__(self, verbose=False): 53 | """Initialise Plugin Manager. 54 | 55 | Clone the script directory if not already available, update it if it 56 | hasn't been updated since more then 30 days. Then read the directory. 57 | """ 58 | self.verbose = verbose 59 | self.__get_dirs__() 60 | if not self.workdir.exists(): 61 | logging.debug("Create workdir %s", self.workdir) 62 | os.makedirs(self.workdir) 63 | script_dir_file = self.directory_folder / self.directory_filename 64 | if not self.directory_folder.exists(): 65 | self.update() 66 | else: 67 | age = datetime.now().timestamp() - script_dir_file.stat().st_mtime 68 | if age > 60 * 60 * 24 * 30: 69 | logging.debug("Update mpv_script_directory due to it's age") 70 | self.update() 71 | with open(script_dir_file) as file: 72 | self.script_directory = json.load(file) 73 | self.statefile = self.workdir / "installed_plugins" 74 | try: 75 | with open(self.statefile) as statefile: 76 | self.installed_plugins = json.load(statefile) 77 | except json.JSONDecodeError: 78 | logging.error( 79 | "Failed to load mplug file %s:", self.statefile, exc_info=True 80 | ) 81 | sys.exit(11) 82 | except FileNotFoundError: 83 | logging.debug("No packages installed yet.") 84 | self.installed_plugins = {} 85 | 86 | def save_state_to_disk(self): 87 | """Write installed plugins on exit.""" 88 | with open(self.statefile, "w") as statefile: 89 | json.dump(self.installed_plugins, statefile) 90 | logging.debug("Saving list of installed plugins") 91 | 92 | def update(self): 93 | """Get or update the 'mpv script directory'.""" 94 | logging.info("Updating %s", self.directory_filename) 95 | git_clone_or_pull(self.directory_remoteurl, self.directory_folder) 96 | 97 | def uninstall(self, plugin_id: str, remove: bool = True): 98 | """Remove or disable a plugin. 99 | 100 | remove: if True the tools folder will be deleted from disc. If False 101 | only remove the symlinks to the files. 102 | """ 103 | plugin = self.installed_plugins[plugin_id] 104 | logging.debug("Remove links of {plugin_id}") 105 | install_dir = self.workdir / plugin["install_dir"] 106 | for filetype, directory in self.installation_dirs.items(): 107 | filelist = plugin.get(filetype, []) 108 | self.__uninstall_files__(filelist, directory) 109 | exefiles = plugin.get("exefiles", []) 110 | if exefiles: 111 | exedir = plugin.get("exedir") 112 | if exedir: 113 | logging.debug("Remove link to executables in %s", exedir) 114 | self.__uninstall_files__(exefiles, Path(exedir)) 115 | else: 116 | logging.error("Can't uninstall files %s: unknown location.", exefiles) 117 | if remove: 118 | logging.info(f"Remove directory {install_dir}") 119 | shutil.rmtree(install_dir) 120 | if remove: 121 | del self.installed_plugins[plugin_id] 122 | else: 123 | plugin["state"] = "disabled" 124 | 125 | def uninstall_by_name(self, pluginname: str, remove: bool = True): 126 | """Uninstall a plugin with the given name or id.""" 127 | if pluginname in self.script_directory: 128 | return self.uninstall(pluginname) 129 | else: 130 | potential_plugins = self.__plugin_id_by_name__(pluginname) 131 | if len(potential_plugins) == 0: 132 | logging.error("Not installed: %s", pluginname) 133 | sys.exit(10) 134 | elif len(potential_plugins) > 1: 135 | logging.error( 136 | "Multiple matching plugins found, please specify plugin id." 137 | ) 138 | sys.exit(10) 139 | elif ask_yes_no(f"Uninstall plugin {potential_plugins[0]}"): 140 | return self.uninstall(potential_plugins[0], remove) 141 | else: 142 | sys.exit(0) 143 | 144 | def install_by_name(self, pluginname: str): 145 | """Install a plugin with the given name or id. 146 | 147 | If there are multiple plugins with the same name the user is asked to 148 | choose.""" 149 | if pluginname in self.script_directory: 150 | return self.install(pluginname) 151 | else: 152 | plugins = self.__plugin_id_by_name__(pluginname) 153 | return self.__install_from_list__(plugins) 154 | 155 | def search(self, seach_string: str): 156 | """Search names and descriptions of plugins.""" 157 | plugins = [] 158 | descriptions = [] 159 | seach_string = seach_string.lower() 160 | for key, value in self.script_directory.items(): 161 | if seach_string in value.get("name", ""): 162 | plugins.append(key) 163 | descriptions.append(value.get("desc", "")) 164 | elif seach_string in value.get("desc", "").lower(): 165 | plugins.append(key) 166 | descriptions.append(value.get("desc", "")) 167 | self.__install_from_list__(plugins, descriptions) 168 | 169 | def __install_from_list__( 170 | self, plugins: List[str], descriptions: Optional[List[str]] = None 171 | ): 172 | """Ask the user which of the plugins should be installed.""" 173 | logging.debug("Found %i potential plugins", len(plugins)) 174 | if len(plugins) == 0: 175 | logging.error("No matching plugins found.") 176 | sys.exit(3) 177 | elif len(plugins) == 1: 178 | if ask_yes_no(f"Install {plugins[0]}?"): 179 | self.install(plugins[0]) 180 | else: 181 | sys.exit(0) 182 | else: 183 | choise = ask_num("Found multiple plugins:", plugins, descriptions) 184 | if choise: 185 | self.install(choise) 186 | else: 187 | sys.exit(0) 188 | 189 | def install(self, plugin_id: str): 190 | """Install the plugin with the given plugin id.""" 191 | plugin = self.script_directory[plugin_id].copy() 192 | 193 | if "install" not in plugin: 194 | errormsg = f"No installation method for {plugin_id}" 195 | explanation = """\ 196 | This means, so far no one added the installation method to the mpv 197 | script directory. Doing so is most likely possible with just a few 198 | lines of JSON. Please add them and create a PR. You can find an 199 | introduction here: 200 | """ 201 | url = "https://github.com/Nudin/mpv-script-directory/" 202 | url += "blob/master/HOWTO_ADD_INSTALL_INSTRUCTIONS.md" 203 | logging.error(errormsg) 204 | logging.error(wrap(explanation, indent=1, dedent=True)) 205 | logging.error(url) 206 | sys.exit(4) 207 | 208 | if not check_os(plugin.get("os", [])): 209 | sys.exit(0) 210 | try: 211 | install_dir = self.workdir / plugin["install_dir"] 212 | url = resolve_templates(plugin["receiving_url"]) 213 | except KeyError as keyerror: 214 | logging.error("Missing field %s", keyerror.args[0]) 215 | sys.exit(13) 216 | if plugin["install"] == "git": 217 | logging.debug("Clone git repo %s to %s", url, install_dir) 218 | git_clone_or_pull(url, install_dir) 219 | elif plugin["install"] == "url": 220 | filename = plugin["filename"] 221 | logging.debug("Downloading %s to %s", url, install_dir) 222 | download_file(url, install_dir / filename) 223 | elif plugin["install"] == "tar": 224 | logging.debug("Downloading %s to %s", url, install_dir) 225 | download_tar(url, install_dir) 226 | else: 227 | logging.error( 228 | f"Can't install {plugin_id}: unknown installation method: {plugin['install']}" 229 | ) 230 | sys.exit(5) 231 | for filetype, directory in self.installation_dirs.items(): 232 | filelist = plugin.get(filetype, []) 233 | self.__install_files__( 234 | srcdir=install_dir, filelist=filelist, dstdir=directory 235 | ) 236 | if "ladspafiles" in plugin and os.getenv("LADSPA_PATH") is None: 237 | logging.warning( 238 | "Set the environment variable LADSPA_PATH to '%s'.", 239 | self.installation_dirs["ladspafiles"], 240 | ) 241 | if "exefiles" in plugin: 242 | exedir = ask_path("Where to put executable files?", Path("~/bin")) 243 | logging.info("Placing executables in %s", str(exedir)) 244 | installed = self.__install_files__( 245 | srcdir=install_dir, filelist=plugin["exefiles"], dstdir=exedir 246 | ) 247 | make_files_executable(installed) 248 | plugin["exedir"] = str(exedir) 249 | if "install-notes" in plugin: 250 | print(wrap(plugin["install-notes"])) 251 | plugin["install_date"] = datetime.now().isoformat() 252 | plugin["state"] = "active" 253 | self.installed_plugins[plugin_id] = plugin 254 | 255 | def upgrade(self): 256 | """Upgrade all repositories in the working directory.""" 257 | self.update() 258 | for plugin in self.installed_plugins.values(): 259 | logging.info("Updating plugin %s", plugin["name"]) 260 | install_dir = self.workdir / plugin["install_dir"] 261 | url = resolve_templates(plugin["receiving_url"]) 262 | if plugin["install"] == "git": 263 | logging.debug("Updating repo in %s", install_dir) 264 | git_pull(install_dir) 265 | elif plugin["install"] == "tar": 266 | logging.debug("Downloading %s to %s", url, install_dir) 267 | download_tar(url, install_dir) 268 | elif plugin["install"] == "url": 269 | filename = resolve_templates(plugin["filename"]) 270 | logging.debug("Downloading %s to %s", url, install_dir) 271 | download_file(url, install_dir / filename) 272 | else: 273 | logging.error("Cannot upgrade %s – installation directory.") 274 | 275 | def list_installed(self): 276 | """List all installed plugins""" 277 | logging.debug("%i installed plugins", len(self.installed_plugins)) 278 | for plugin_id, plugin in self.installed_plugins.items(): 279 | text = plugin_id 280 | if plugin.get("state") == "disabled": 281 | text += " [DISABLED]" 282 | print(wrap(text, indent=int(self.verbose))) 283 | if self.verbose: 284 | print(wrap(plugin["desc"], indent=2)) 285 | 286 | def __get_dirs__(self): 287 | """Find the directory paths by using environment variables or 288 | hardcoded fallbacks.""" 289 | xdg_data = os.getenv("XDG_DATA_HOME") 290 | xdg_conf = os.getenv("XDG_CONFIG_HOME") 291 | appdata = os.getenv("APPDATA") 292 | mpv_home = os.getenv("MPV_HOME") 293 | # Directory for MPlug this is where all plugin files will be stored 294 | if xdg_data: 295 | self.workdir = Path(xdg_data) / "mplug" 296 | elif appdata: 297 | self.workdir = Path(appdata) / "mplug" 298 | else: 299 | self.workdir = Path.home() / ".mplug" 300 | # MPV directory usually ~/.config/mpv on Linux/Mac 301 | if mpv_home: 302 | self.mpvdir = Path(mpv_home) 303 | elif xdg_conf: 304 | self.mpvdir = Path(xdg_conf) / "mpv" 305 | elif appdata: 306 | self.mpvdir = Path(appdata) / "mpv" 307 | else: 308 | self.mpvdir = Path.home() / ".mpv" 309 | logging.info( 310 | "No environment variable found, guessing %s as mpv config folder.", 311 | self.mpvdir, 312 | ) 313 | logging.debug("mpvdir: %s", self.mpvdir) 314 | # directory for ladspa filters, fallback according to: 315 | # https://www.ladspa.org/ladspa_sdk/shared_plugins.html 316 | ladspa_path = os.getenv("LADSPA_PATH") 317 | if ladspa_path: 318 | ladspa_dir = Path(ladspa_path.split(":")[0]) 319 | else: 320 | ladspa_dir = Path.home() / ".ladspa" 321 | self.installation_dirs = { 322 | "scriptfiles": self.mpvdir / "scripts", 323 | "shaderfiles": self.mpvdir / "shaders", 324 | "fontfiles": self.mpvdir / "fonts", 325 | "scriptoptfiles": self.mpvdir / "script-opts", 326 | "ladspafiles": ladspa_dir, 327 | } 328 | # Directory for MPlug this is where all plugin files will be stored 329 | self.directory_folder = self.workdir / self.directory_foldername 330 | 331 | def __plugin_id_by_name__(self, pluginname: str) -> List[str]: 332 | """Get the ids of all plugins with the give name.""" 333 | plugins = [] 334 | for key, value in self.script_directory.items(): 335 | if value["name"] == pluginname: 336 | plugins.append(key) 337 | return plugins 338 | 339 | @staticmethod 340 | def __install_files__( 341 | srcdir: Path, filelist: List[str], dstdir: Path 342 | ) -> List[Path]: 343 | """Install selected files as symlinks into the corresponding folder.""" 344 | if not dstdir.exists(): 345 | logging.debug("Create directory %s", dstdir) 346 | os.makedirs(dstdir) 347 | installed_files = [] 348 | for file in filelist: 349 | file = resolve_templates(file) 350 | src = srcdir / file 351 | filename = Path(file).name 352 | if not src.exists(): 353 | # pylint: disable=W1201 354 | logging.error( 355 | "File %s does not exsist. " 356 | + "Check information in mpv script directory for correctness.", 357 | src, 358 | ) 359 | sys.exit(14) 360 | dst = dstdir / filename 361 | if dst.exists() and not dst.is_symlink(): 362 | logging.error( 363 | "File already exists and is not a symlink: %s Aborting.", dst 364 | ) 365 | sys.exit(15) 366 | if dst.is_symlink() and dst.resolve() != src.resolve(): 367 | logging.info( 368 | "File already exists and points to wrong target: %s -> %s", 369 | dst, 370 | dst.resolve(), 371 | ) 372 | if ask_yes_no("Overwrite file?"): 373 | os.remove(dst) 374 | else: 375 | sys.exit(15) 376 | if dst.is_symlink() and dst.resolve() == src.resolve(): 377 | logging.info("File already exists: %s", dst) 378 | continue 379 | logging.debug("Copying file %s to %s", filename, dst) 380 | os.symlink(src, dst) 381 | installed_files.append(dst) 382 | return installed_files 383 | 384 | @staticmethod 385 | def __uninstall_files__(filelist: List[str], folder: Path): 386 | """Remove symlinks.""" 387 | for file in filelist: 388 | file = resolve_templates(file) 389 | filename = Path(file).name 390 | dst = folder / filename 391 | logging.info(f"Removing {dst}") 392 | if not dst.exists(): 393 | continue 394 | if not dst.is_symlink(): 395 | logging.critical( 396 | "File %s is not a symlink! It apparently was not installed by %s. Aborting.", 397 | dst, 398 | NAME, 399 | ) 400 | sys.exit(12) 401 | os.remove(dst) 402 | -------------------------------------------------------------------------------- /src/mplug/util.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | 7 | """ 8 | Different simpler functions. 9 | 10 | Functions: wrap, resolve_templates 11 | """ 12 | 13 | import os 14 | import platform 15 | import re 16 | import shutil 17 | import stat 18 | import textwrap 19 | from pathlib import Path 20 | from typing import List 21 | 22 | 23 | def wrap(text, indent=0, dedent=False): 24 | """Wrap lines of text and optionally indent it for prettier printing.""" 25 | term_width = shutil.get_terminal_size((80, 20)).columns 26 | if indent: 27 | indents = { 28 | "initial_indent": " " * indent, 29 | "subsequent_indent": " " * indent, 30 | } 31 | else: 32 | indents = {} 33 | wrapper = textwrap.TextWrapper(width=min(80, term_width), **indents) 34 | if dedent: 35 | text = textwrap.dedent(text) 36 | lines = [wrapper.fill(line) for line in text.splitlines()] 37 | return "\n".join(lines) 38 | 39 | 40 | def resolve_templates(text: str) -> str: 41 | """Replace placeholders in the given url/filename. 42 | 43 | Supported placeholders: 44 | - {{os}} -> linux, windows, … 45 | - {{arch}} -> x86_64, x86_32, … 46 | - {{arch-short}} -> x64, x32 47 | - {{shared-lib-ext}} -> .so, .dll 48 | - {{executable-ext}} -> .exe or nothing, if later remove dot 49 | """ 50 | # pylint: disable=C0103 51 | os_name = platform.system().lower() 52 | arch = platform.machine() 53 | arch_short = arch.replace("x86_", "x") 54 | if os_name == "windows": 55 | shared_lib_ext = r"\1dll" 56 | executable_ext = r"\1exe" 57 | else: 58 | shared_lib_ext = r"\1so" 59 | executable_ext = "" 60 | text = re.sub(r"(\.?){{shared-lib-ext}}", shared_lib_ext, text) 61 | text = re.sub(r"(\.?){{executable-ext}}", executable_ext, text) 62 | text = text.replace("{{os}}", os_name) 63 | text = text.replace("{{arch}}", arch) 64 | text = text.replace("{{arch-short}}", arch_short) 65 | return text 66 | 67 | 68 | def make_files_executable(filelist: List[Path]): 69 | """On *nix based operating systems, mark the file as executable.""" 70 | os_name = platform.system().lower() 71 | if "windows" in os_name: 72 | return 73 | for file in filelist: 74 | st = os.stat(file) 75 | os.chmod(file, st.st_mode | stat.S_IEXEC) 76 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nudin/mplug/e18d8c4ac035f56c222ba0f5011c9aeb9f2af377/test/__init__.py -------------------------------------------------------------------------------- /test/test_cli.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | 7 | import mplug 8 | import pytest 9 | 10 | 11 | def test_print_help(): 12 | mplug.print_help() 13 | 14 | 15 | def test_arg_parse_help(): 16 | """Print help and exit.""" 17 | with pytest.raises(SystemExit) as pytest_wrapped_e: 18 | mplug.arg_parse(["mplug", "help"]) 19 | assert pytest_wrapped_e.type == SystemExit 20 | assert pytest_wrapped_e.value.code == 0 21 | with pytest.raises(SystemExit) as pytest_wrapped_e: 22 | mplug.arg_parse(["mplug"]) 23 | assert pytest_wrapped_e.type == SystemExit 24 | assert pytest_wrapped_e.value.code == 0 25 | 26 | 27 | def test_arg_parse_valid(): 28 | """Parse all valid input combinations.""" 29 | op_term = ["install", "uninstall", "search", "disable"] 30 | op_no_term = ["upgrade", "update", "list-installed"] 31 | verbosity = [None, "-v"] 32 | searchterm = "searchterm" 33 | for flag in verbosity: 34 | argv = ["mplug"] 35 | if flag: 36 | argv.append(flag) 37 | for op in op_no_term: 38 | result = mplug.arg_parse([*argv, op]) 39 | assert isinstance(result, tuple) 40 | assert len(result) == 3 41 | assert result[1] is None 42 | for op in op_term: 43 | result = mplug.arg_parse([*argv, op, searchterm]) 44 | assert isinstance(result, tuple) 45 | assert len(result) == 3 46 | assert result[1] == searchterm 47 | 48 | 49 | def test_arg_parse_invalid_op(): 50 | """Unknown operation: Print help and exit.""" 51 | invalid_operations = ["invalid", "", None] 52 | for operation in invalid_operations: 53 | with pytest.raises(SystemExit) as pytest_wrapped_e: 54 | mplug.arg_parse(["mplug", operation]) 55 | assert pytest_wrapped_e.type == SystemExit 56 | assert pytest_wrapped_e.value.code != 0 57 | 58 | 59 | def test_arg_parse_missing_name(): 60 | """Missing searchterm: Print help and exit.""" 61 | operations = ["install", "uninstall", "search", "disable"] 62 | for operation in operations: 63 | with pytest.raises(SystemExit) as pytest_wrapped_e: 64 | mplug.arg_parse(["mplug", operation]) 65 | assert pytest_wrapped_e.type == SystemExit 66 | assert pytest_wrapped_e.value.code != 0 67 | 68 | 69 | def test_main(mocker): 70 | """Call all operations and make sure that only the right function is 71 | called.""" 72 | mock_save_state = mocker.patch("mplug.MPlug.save_state_to_disk") 73 | mock_install_by_name = mocker.patch("mplug.MPlug.install_by_name") 74 | mock_search = mocker.patch("mplug.MPlug.search") 75 | mock_uninstall_by_name = mocker.patch("mplug.MPlug.uninstall_by_name") 76 | mock_list_installed = mocker.patch("mplug.MPlug.list_installed") 77 | mock_update = mocker.patch("mplug.MPlug.update") 78 | mock_upgrade = mocker.patch("mplug.MPlug.upgrade") 79 | mocker.patch("mplug.MPlug.__init__", return_value=None) 80 | argmap = { 81 | "install": mock_install_by_name, 82 | "search": mock_search, 83 | "uninstall": mock_uninstall_by_name, 84 | "disable": mock_uninstall_by_name, 85 | "update": mock_update, 86 | "upgrade": mock_upgrade, 87 | "list-installed": mock_list_installed, 88 | } 89 | ops_mock_list = set(argmap.values()) 90 | for arg, mock in argmap.items(): 91 | mplug.main(arg, "searchterm") 92 | 93 | for mock_op in ops_mock_list: 94 | if mock_op is not mock: 95 | mock_op.assert_not_called() 96 | else: 97 | mock.assert_called_once() 98 | mock_op.reset_mock() 99 | mock_save_state.assert_called_once_with() 100 | mock_save_state.reset_mock() 101 | -------------------------------------------------------------------------------- /test/test_interaction.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | # 5 | # Copyright (C) Michael F. Schönitzer, 2020 6 | 7 | from pathlib import Path 8 | 9 | from mplug.interaction import ask_num, ask_path, ask_yes_no 10 | 11 | 12 | def test_ask_num_valid(mocker): 13 | """The user chooses one of the given options""" 14 | choices = ["foo", "bar", "baz"] 15 | for n, choice in enumerate(choices): 16 | mocker.patch("mplug.interaction.input", return_value=str(n)) 17 | assert ask_num("Q?", choices) == choice 18 | 19 | 20 | def test_ask_num_invalid(mocker): 21 | """The user enters an invalid input""" 22 | choices = ["foo", "bar", "baz"] 23 | invalid_strings = ["x", str(len(choices)), ""] 24 | for invalid in invalid_strings: 25 | mocker.patch("mplug.interaction.input", return_value=invalid) 26 | assert ask_num("Q?", choices) is None 27 | 28 | 29 | def test_ask_yes_no_yes(mocker): 30 | """The user answers yes""" 31 | answers = ["y", "Y"] 32 | for input_str in answers: 33 | mocker.patch("mplug.interaction.input", return_value=input_str) 34 | assert ask_yes_no("Q?") 35 | 36 | 37 | def test_ask_yes_no_no(mocker): 38 | """The user answers no or an invalid input""" 39 | answers = ["n", "N", "", "invalid_input"] 40 | for input_str in answers: 41 | mocker.patch("mplug.interaction.input", return_value=input_str) 42 | assert not ask_yes_no("Q?") 43 | 44 | 45 | def test_ask_path(mocker): 46 | """The user enters a directory path""" 47 | mocker.patch("mplug.interaction.input", return_value="") 48 | default = Path("~/foo") 49 | assert ask_path("Q?", default) == default.expanduser() 50 | mocker.patch("mplug.interaction.input", return_value="~/bar") 51 | assert ask_path("Q?", default) == (Path.home() / "bar").expanduser() 52 | mocker.patch("mplug.interaction.input", return_value="/bar") 53 | assert ask_path("Q?", default) == (Path("/bar")) 54 | -------------------------------------------------------------------------------- /test/test_mplug.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright (C) Michael F. Schönitzer, 2020 7 | 8 | # pylint: disable=redefined-outer-name,unused-argument 9 | import collections 10 | import json 11 | from datetime import datetime 12 | from pathlib import Path 13 | from unittest.mock import call 14 | 15 | import mplug 16 | import pytest 17 | 18 | 19 | @pytest.fixture(name="mock_files") 20 | def fixture_mock_files(mocker): 21 | """Mock all used functions that do disc IO. 22 | 23 | These unit tests should never actually read from or write to disc, or 24 | depend on the state of files in the file system. This fixture replaces all 25 | calls to such functions by mocks. The mocks are returned as namedtuple, so 26 | that their return value can be adjusted in tests and their calls can be 27 | asserted. 28 | """ 29 | IO_Mocks = collections.namedtuple( 30 | "IO_Mocks", 31 | [ 32 | "open", 33 | "json_load", 34 | "json_dump", 35 | "os_makedirs", 36 | "os_symlink", 37 | "os_remove", 38 | "os_stat", 39 | "os_chmod", 40 | "shutil_rmtree", 41 | "Repo_clone_from", 42 | "Repo_init", 43 | "Repo_remote", 44 | "Path_exists", 45 | "Path_glob", 46 | "Path_is_symlink", 47 | "Path_stat", 48 | "Path_resolve", 49 | ], 50 | ) 51 | current_timestamp = datetime.now().timestamp() 52 | stat_mock = mocker.Mock(st_mtime=current_timestamp, st_mode=0) 53 | return IO_Mocks( 54 | mocker.patch("mplug.mplug.open", mocker.mock_open()), 55 | mocker.patch("json.load", return_value={}), 56 | mocker.patch("json.dump", return_value=None), 57 | mocker.patch("os.makedirs", return_value=None), 58 | mocker.patch("os.symlink", return_value=None), 59 | mocker.patch("os.remove", return_value=None), 60 | mocker.patch("os.stat", return_value=stat_mock), 61 | mocker.patch("os.chmod", return_value=None), 62 | mocker.patch("shutil.rmtree", return_value=None), 63 | mocker.patch("mplug.download.Repo.clone_from", return_value=None), 64 | mocker.patch("mplug.download.Repo.__init__", return_value=None), 65 | mocker.patch("mplug.download.Repo.remote", return_value=mocker.Mock()), 66 | mocker.patch("mplug.mplug.Path.exists", return_value=True), 67 | mocker.patch("mplug.mplug.Path.glob", return_value=[]), 68 | mocker.patch("mplug.mplug.Path.is_symlink", return_value=True), 69 | mocker.patch( 70 | "mplug.mplug.Path.stat", 71 | return_value=stat_mock, 72 | ), 73 | mocker.patch("mplug.mplug.Path.resolve", return_value=Path(".")), 74 | ) 75 | 76 | 77 | @pytest.fixture(name="mpl") 78 | def fixture_init_mplug(mock_files): 79 | """Return initialised MPlug. 80 | 81 | This fixture is basically the same as test_mplug_init_up2date.""" 82 | mpl = mplug.MPlug() 83 | status_file = mpl.statefile 84 | assert mpl.script_directory == {} 85 | assert mpl.installed_plugins == {} 86 | yield mpl 87 | mock_files.open.reset_mock() 88 | mpl.save_state_to_disk() 89 | mock_files.json_dump.assert_called_once() 90 | mock_files.open.assert_called_once_with(status_file, "w") 91 | 92 | 93 | def test_mplug_init_up2date(mock_files): 94 | """Initialise MPlug. 95 | 96 | Case: script directory exists and is up to date 97 | """ 98 | mpl = mplug.MPlug() 99 | script_dir_file = mpl.directory_folder / mpl.directory_filename 100 | status_file = mpl.statefile 101 | assert mpl.script_directory == {} 102 | assert mpl.installed_plugins == {} 103 | mock_files.json_load.assert_called() 104 | mock_files.open.assert_has_calls( 105 | [call(script_dir_file), call(status_file)], any_order=True 106 | ) 107 | 108 | 109 | def test_mplug_init_outdated(mock_files): 110 | """Initialise MPlug. 111 | 112 | Case: script directory exists but is outdated 113 | """ 114 | mock_files.Path_stat().st_mtime = 0 115 | mpl = mplug.MPlug() 116 | script_dir_file = mpl.directory_folder / mpl.directory_filename 117 | status_file = mpl.statefile 118 | assert mpl.script_directory == {} 119 | assert mpl.installed_plugins == {} 120 | mock_files.json_load.assert_called() 121 | mock_files.open.assert_has_calls( 122 | [call(script_dir_file), call(status_file)], any_order=True 123 | ) 124 | 125 | 126 | def test_mplug_init_new(mock_files): 127 | """Initialise MPlug. 128 | 129 | Case: script directory does not yet exists 130 | """ 131 | mock_files.Path_exists.return_value = False 132 | mpl = mplug.MPlug() 133 | script_dir_file = mpl.directory_folder / mpl.directory_filename 134 | status_file = mpl.statefile 135 | assert mpl.script_directory == {} 136 | assert mpl.installed_plugins == {} 137 | mock_files.json_load.assert_called() 138 | mock_files.open.assert_has_calls( 139 | [call(script_dir_file), call(status_file)], any_order=True 140 | ) 141 | 142 | 143 | def test_mplug_init_no_state_file(mock_files): 144 | """Test initialisation when the state-file is missing.""" 145 | mock_files.json_load.side_effect = [None, FileNotFoundError] 146 | mpl = mplug.MPlug() 147 | assert mpl.installed_plugins == {} 148 | 149 | 150 | def test_mplug_init_invalid_state(mock_files): 151 | """Test initialisation when the state-file is corrupt.""" 152 | mock_files.json_load.side_effect = [None, json.JSONDecodeError("", "", 0)] 153 | with pytest.raises(SystemExit) as pytest_wrapped_e: 154 | mplug.MPlug() 155 | assert pytest_wrapped_e.type == SystemExit 156 | assert pytest_wrapped_e.value.code != 0 157 | 158 | 159 | def test_mplug_init_getmpvdir(mock_files, monkeypatch): 160 | """Check that the mplug working directory is set correctly 161 | based on the existence of different environment variables.""" 162 | mpv_home_dir = "some_mpv_home_dir" 163 | wrong_dir = "wrong directory" 164 | # MPV_HOME set, others unset -> use MPV_HOME 165 | monkeypatch.setenv("MPV_HOME", mpv_home_dir) 166 | monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 167 | monkeypatch.delenv("APPDATA", raising=False) 168 | monkeypatch.delenv("XDG_DATA_HOME", raising=False) 169 | mpl = mplug.MPlug() 170 | assert mpl.mpvdir == Path(mpv_home_dir) 171 | 172 | # MPV_HOME set, others also set -> use MPV_HOME 173 | monkeypatch.setenv("MPV_HOME", mpv_home_dir) 174 | monkeypatch.setenv("XDG_CONFIG_HOME", wrong_dir) 175 | monkeypatch.setenv("APPDATA", wrong_dir) 176 | monkeypatch.setenv("XDG_DATA_HOME", wrong_dir) 177 | mpl = mplug.MPlug() 178 | assert mpl.mpvdir == Path(mpv_home_dir) 179 | 180 | # MPV_HOME unset, XDG_CONFIG_HOME set -> use XDG_CONFIG_HOME 181 | monkeypatch.delenv("MPV_HOME", raising=False) 182 | monkeypatch.setenv("XDG_CONFIG_HOME", mpv_home_dir) 183 | monkeypatch.setenv("APPDATA", wrong_dir) 184 | mpl = mplug.MPlug() 185 | assert mpl.mpvdir == Path(mpv_home_dir) / "mpv" 186 | 187 | # MPV_HOME unset, APPDATA set -> use APPDATA 188 | monkeypatch.delenv("MPV_HOME", raising=False) 189 | monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 190 | monkeypatch.setenv("APPDATA", mpv_home_dir) 191 | mpl = mplug.MPlug() 192 | assert mpl.mpvdir == Path(mpv_home_dir) / "mpv" 193 | 194 | # nothing set -> fallback ~/.mpv 195 | monkeypatch.delenv("MPV_HOME", raising=False) 196 | monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 197 | monkeypatch.delenv("APPDATA", raising=False) 198 | mpl = mplug.MPlug() 199 | assert mpl.mpvdir == Path.home() / ".mpv" 200 | 201 | 202 | def test_mplug_init_getworkdir(mock_files, monkeypatch): 203 | """Check that the mpv configuration directory is detected correctly 204 | based on the existence of different environment variables.""" 205 | datadir = "some datadir" 206 | wrong_dir = "wrong directory" 207 | # MPV_HOME and XDG_CONFIG_HOME should not matter for this 208 | monkeypatch.setenv("MPV_HOME", wrong_dir) 209 | monkeypatch.setenv("XDG_CONFIG_HOME", wrong_dir) 210 | # Use XDG_DATA_HOME if present 211 | monkeypatch.setenv("XDG_DATA_HOME", datadir) 212 | monkeypatch.delenv("APPDATA", raising=False) 213 | mpl = mplug.MPlug() 214 | assert mpl.workdir == Path(datadir) / "mplug" 215 | 216 | # Use APPDATA if present 217 | monkeypatch.setenv("APPDATA", datadir) 218 | monkeypatch.delenv("XDG_DATA_HOME", raising=False) 219 | mpl = mplug.MPlug() 220 | assert mpl.workdir == Path(datadir) / "mplug" 221 | 222 | # Fallback to ~/.mplug 223 | monkeypatch.delenv("XDG_DATA_HOME", raising=False) 224 | monkeypatch.delenv("APPDATA", raising=False) 225 | mpl = mplug.MPlug() 226 | assert mpl.workdir == Path.home() / ".mplug" 227 | 228 | 229 | def test_mplug_install_by_name_nomatch(mpl): 230 | """Try to install an unknown plugin.""" 231 | searchterm_without_matches = "searchterm_without_matches" 232 | with pytest.raises(SystemExit) as pytest_wrapped_e: 233 | mpl.install_by_name(searchterm_without_matches) 234 | assert pytest_wrapped_e.type == SystemExit 235 | assert pytest_wrapped_e.value.code != 0 236 | 237 | 238 | def test_mplug_install_by_name_onematch_decline(mpl, mocker): 239 | """Try to install a plugin, but then cancel the installation.""" 240 | searchterm = "searchterm" 241 | mpl.script_directory["uniq-id"] = {"name": searchterm} 242 | mock_yes_no = mocker.patch("mplug.mplug.ask_yes_no", return_value=False) 243 | with pytest.raises(SystemExit) as pytest_wrapped_e: 244 | mpl.install_by_name(searchterm) 245 | assert pytest_wrapped_e.type == SystemExit 246 | assert pytest_wrapped_e.value.code == 0 247 | mock_yes_no.assert_called_once() 248 | 249 | 250 | def test_mplug_search_onematch_decline(mpl, mocker): 251 | """Search for a plugin, but then cancel the installation.""" 252 | searchterm = "searchterm" 253 | mpl.script_directory["uniq-id"] = { 254 | "name": "name", 255 | "desc": f"something {searchterm} something", 256 | } 257 | mock_yes_no = mocker.patch("mplug.mplug.ask_yes_no", return_value=False) 258 | with pytest.raises(SystemExit) as pytest_wrapped_e: 259 | mpl.search(searchterm) 260 | assert pytest_wrapped_e.type == SystemExit 261 | assert pytest_wrapped_e.value.code == 0 262 | mock_yes_no.assert_called_once() 263 | 264 | 265 | def test_mplug_search_onematche_choose(mpl, mocker): 266 | """Search for a plugin, and choose the only match.""" 267 | searchterm = "searchterm" 268 | mpl.script_directory["uniq-id"] = { 269 | "name": "name", 270 | "desc": f"something {searchterm} something", 271 | } 272 | mock_input = mocker.patch("mplug.mplug.ask_yes_no", return_value=True) 273 | with pytest.raises(SystemExit) as pytest_wrapped_e: 274 | mpl.search(searchterm) 275 | assert pytest_wrapped_e.type == SystemExit 276 | assert pytest_wrapped_e.value.code != 0 277 | mock_input.assert_called_once() 278 | 279 | 280 | def test_mplug_search_multiplematches_decline(mpl, mocker): 281 | """Search for a plugin (multiple results), but then cancel the 282 | installation.""" 283 | searchterm = "searchterm" 284 | mpl.script_directory["uniq-id"] = { 285 | "name": "name", 286 | "desc": f"something {searchterm} something", 287 | } 288 | mpl.script_directory["uniq-id2"] = { 289 | "name": f"something {searchterm} something", 290 | "desc": "description", 291 | } 292 | mock_ask_num = mocker.patch("mplug.mplug.ask_num", return_value=None) 293 | with pytest.raises(SystemExit) as pytest_wrapped_e: 294 | mpl.search(searchterm) 295 | assert pytest_wrapped_e.type == SystemExit 296 | assert pytest_wrapped_e.value.code == 0 297 | mock_ask_num.assert_called_once() 298 | 299 | 300 | def test_mplug_search_multiplematches_choose(mpl, mocker): 301 | """Search for a plugin (multiple results), and choose the first match.""" 302 | searchterm = "searchterm" 303 | mpl.script_directory["uniq-id"] = { 304 | "name": "name", 305 | "desc": f"something {searchterm} something", 306 | } 307 | mpl.script_directory["uniq-id2"] = { 308 | "name": f"something {searchterm} something", 309 | "desc": "description", 310 | } 311 | mock_input = mocker.patch("mplug.interaction.input", return_value="1") 312 | with pytest.raises(SystemExit) as pytest_wrapped_e: 313 | mpl.search(searchterm) 314 | assert pytest_wrapped_e.type == SystemExit 315 | assert pytest_wrapped_e.value.code != 0 316 | mock_input.assert_called_once() 317 | 318 | 319 | def test_mplug_install_by_id_no_method(mpl): 320 | """Try to install a plugin, that has no installation method.""" 321 | searchterm = "searchterm" 322 | mpl.script_directory[searchterm] = {"name": searchterm} 323 | with pytest.raises(SystemExit) as pytest_wrapped_e: 324 | mpl.install_by_name(searchterm) 325 | assert pytest_wrapped_e.type == SystemExit 326 | assert pytest_wrapped_e.value.code != 0 327 | 328 | 329 | def test_mplug_install_by_id_unknown_method(mpl): 330 | """Try to install a plugin, that has an invalid/unknown installation method.""" 331 | searchterm = "searchterm" 332 | mpl.script_directory[searchterm] = {"name": searchterm, "install": "unknown_method"} 333 | with pytest.raises(SystemExit) as pytest_wrapped_e: 334 | mpl.install_by_name(searchterm) 335 | assert pytest_wrapped_e.type == SystemExit 336 | assert pytest_wrapped_e.value.code != 0 337 | 338 | 339 | def test_mplug_install_by_id_git(mpl, mock_files, mocker): 340 | """Successfully install a plugin by it's id via git.""" 341 | mock_files.Path_exists.return_value = False 342 | install = mocker.patch("mplug.mplug.MPlug.__install_files__", return_value=None) 343 | plugin_id = "plugin_id" 344 | repo_url = " git_url" 345 | mpl.script_directory[plugin_id] = { 346 | "name": plugin_id, 347 | "install": "git", 348 | "receiving_url": repo_url, 349 | "install_dir": "install_dir", 350 | "scriptfiles": ["file1"], 351 | "install-notes": "Message to be shown after the install.", 352 | } 353 | install_dir = mpl.workdir / "install_dir" 354 | mpl.install_by_name(plugin_id) 355 | mock_files.Repo_clone_from.assert_called_with( 356 | repo_url, install_dir, multi_options=["--depth 1"] 357 | ) 358 | install.assert_called() 359 | assert mpl.installed_plugins != {} 360 | 361 | 362 | @pytest.fixture() 363 | def fixture_installed_plugin(mpl, mock_files, mocker): 364 | """Successfully install a plugin by it's id via git. 365 | 366 | This fixture is used to test uninstallments, it is basically identical to 367 | test_mplug_install_by_id_git.""" 368 | mock_files.Path_is_symlink.return_value = False 369 | mock_files.Path_exists.return_value = False 370 | install = mocker.patch("mplug.mplug.MPlug.__install_files__", return_value=None) 371 | plugin_id = "plugin_id" 372 | repo_url = " git_url" 373 | mpl.script_directory[plugin_id] = { 374 | "name": plugin_id, 375 | "install": "git", 376 | "receiving_url": repo_url, 377 | "install_dir": "install_dir", 378 | "scriptfiles": ["file1"], 379 | "install-notes": "Message to be shown after the install.", 380 | } 381 | install_dir = mpl.workdir / "install_dir" 382 | mpl.install_by_name(plugin_id) 383 | mock_files.Repo_clone_from.assert_called_with( 384 | repo_url, install_dir, multi_options=["--depth 1"] 385 | ) 386 | install.assert_called() 387 | assert mpl.installed_plugins != {} 388 | return mpl 389 | 390 | 391 | def test_mplug_install_by_id_git_filepresent(mpl, mock_files, mocker): 392 | """Successfully install a plugin by it's id via git. The symlink already 393 | exists.""" 394 | mock_files.Path_is_symlink.return_value = True 395 | mock_files.Path_exists.return_value = True 396 | mock_repo_clone = mocker.spy(mplug.mplug, "git_clone_or_pull") 397 | searchterm = "searchterm" 398 | repo_url = " git_url" 399 | mpl.script_directory[searchterm] = { 400 | "name": searchterm, 401 | "install": "git", 402 | "receiving_url": repo_url, 403 | "install_dir": "install_dir", 404 | "scriptfiles": ["file1"], 405 | } 406 | mpl.install_by_name(searchterm) 407 | mock_repo_clone.assert_called() 408 | mock_files.os_symlink.assert_not_called() 409 | assert len(mpl.installed_plugins) == 1 410 | 411 | 412 | def test_mplug_install_by_id_git_repopresent(mpl, mock_files): 413 | """Successfully install a plugin by it's id via git. The repo is already 414 | present.""" 415 | mock_files.Path_exists.return_value = True 416 | searchterm = "searchterm" 417 | repo_url = " git_url" 418 | mpl.script_directory[searchterm] = { 419 | "name": searchterm, 420 | "install": "git", 421 | "receiving_url": repo_url, 422 | "install_dir": "install_dir", 423 | "scriptfiles": ["file1"], 424 | } 425 | mpl.install_by_name(searchterm) 426 | mock_files.Repo_init.assert_called() 427 | mock_files.Repo_remote.assert_called_with() 428 | mock_files.Repo_remote().pull.assert_called_with() 429 | mock_files.os_symlink.assert_not_called() 430 | assert len(mpl.installed_plugins) == 1 431 | 432 | 433 | def test_mplug_install_by_id_git_withexe(mpl, mocker, mock_files): 434 | """Successfully install and uninstall a plugin containing an executable.""" 435 | plugin_id = "plugin_id" 436 | repo_url = " git_url" 437 | filename = "executable_file" 438 | exedir = "path_of_executables" 439 | mock_files.Path_exists.return_value = False 440 | install = mocker.patch( 441 | "mplug.mplug.MPlug.__install_files__", return_value=[filename] 442 | ) 443 | mocker.patch("mplug.mplug.ask_path", return_value=Path(exedir)) 444 | mpl.script_directory[plugin_id] = { 445 | "name": plugin_id, 446 | "install": "git", 447 | "receiving_url": repo_url, 448 | "install_dir": "install_dir", 449 | "exefiles": [filename], 450 | } 451 | plugin_dir = mpl.workdir / "install_dir" 452 | dst_file = Path(exedir) / filename 453 | mpl.install_by_name(plugin_id) 454 | mock_files.Repo_clone_from.assert_called_with( 455 | repo_url, plugin_dir, multi_options=["--depth 1"] 456 | ) 457 | assert mpl.installed_plugins != {} 458 | assert plugin_id in mpl.installed_plugins 459 | assert mpl.installed_plugins[plugin_id]["exedir"] == exedir 460 | install.assert_called() 461 | mock_files.Path_exists.return_value = True 462 | mpl.uninstall(plugin_id) 463 | mock_files.shutil_rmtree.assert_called_once_with(plugin_dir) 464 | mock_files.os_remove.assert_called_once_with(dst_file) 465 | assert mpl.installed_plugins == {} 466 | 467 | 468 | def test_mplug_upgrade(mpl, mock_files): 469 | """Upgrade multiple plugins.""" 470 | mpl.installed_plugins = { 471 | "foo": { 472 | "install": "git", 473 | "receiving_url": "url", 474 | "name": "foo", 475 | "install_dir": "foodir", 476 | }, 477 | "bar": { 478 | "install": "git", 479 | "receiving_url": "url", 480 | "name": "bar", 481 | "install_dir": "bardir", 482 | }, 483 | } 484 | call_list = [ 485 | call(mpl.workdir / plugin["install_dir"]) 486 | for plugin in mpl.installed_plugins.values() 487 | ] 488 | mpl.upgrade() 489 | mock_files.Repo_init.assert_has_calls(call_list) 490 | mock_files.Repo_remote.assert_called_with() 491 | mock_files.Repo_remote().pull.assert_called_with() 492 | 493 | 494 | def test_mplug_list_installed(mpl): 495 | """List all installed plugins.""" 496 | mpl.list_installed() 497 | 498 | 499 | def test_mplug_uninstall(fixture_installed_plugin, mock_files): 500 | """Uninstall a previously installed plugin.""" 501 | mpl = fixture_installed_plugin 502 | mock_files.Path_is_symlink.return_value = True 503 | plugin_id = "plugin_id" 504 | plugin = mpl.installed_plugins[plugin_id] 505 | scriptdir = mpl.installation_dirs["scriptfiles"] 506 | file_calls = [call(scriptdir / file) for file in plugin["scriptfiles"]] 507 | plugin_dir = mpl.workdir / plugin["install_dir"] 508 | mock_files.Path_exists.return_value = True 509 | mpl.uninstall(plugin_id) 510 | mock_files.shutil_rmtree.assert_called_with(plugin_dir) 511 | mock_files.os_remove.assert_has_calls(file_calls) 512 | assert mpl.installed_plugins == {} 513 | 514 | 515 | def test_mplug_uninstall_missing(fixture_installed_plugin): 516 | """Try to uninstall a plugin that is not installed.""" 517 | mpl = fixture_installed_plugin 518 | prev_installed_plugins = mpl.installed_plugins.copy() 519 | plugin_id = "not_existent_plugin_id" 520 | with pytest.raises(SystemExit) as pytest_wrapped_e: 521 | mpl.uninstall_by_name(plugin_id) 522 | assert pytest_wrapped_e.type == SystemExit 523 | assert pytest_wrapped_e.value.code != 0 524 | assert mpl.installed_plugins == prev_installed_plugins 525 | 526 | 527 | def test_mplug_uninstall_wrong_file(fixture_installed_plugin, mock_files): 528 | """Try to uninstall a plugin that has a file that is not a symlink, meaning 529 | not (correctly) created by MPlug.""" 530 | mpl = fixture_installed_plugin 531 | prev_installed_plugins = mpl.installed_plugins.copy() 532 | mock_files.Path_is_symlink.return_value = False 533 | plugin_id = "plugin_id" 534 | mock_files.Path_exists.return_value = True 535 | with pytest.raises(SystemExit) as pytest_wrapped_e: 536 | mpl.uninstall(plugin_id) 537 | assert pytest_wrapped_e.type == SystemExit 538 | assert pytest_wrapped_e.value.code != 0 539 | assert mpl.installed_plugins == prev_installed_plugins 540 | -------------------------------------------------------------------------------- /test/test_util.py: -------------------------------------------------------------------------------- 1 | import mplug.util 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize("os", ["Windows", "Linux", "Darwin"]) 6 | @pytest.mark.parametrize("arch,short_arch", [("x86_64", "x64"), ("x86_32", "x32")]) 7 | def test_resolve_templates_windows(os, arch, short_arch, mocker): 8 | mocker.patch("platform.system", return_value=os) 9 | mocker.patch("platform.machine", return_value=arch) 10 | os = os.lower() 11 | tests = { 12 | "": "", 13 | "filename": "filename", 14 | "{{os}}-filename": f"{os}-filename", 15 | "{{os}}-{{os}}-filename": f"{os}-{os}-filename", 16 | "{{arch}}-filename": f"{arch}-filename", 17 | "{{arch-short}}-filename": f"{short_arch}-filename", 18 | "download/{{arch}}/{{os}}/test-{{os}}-{{arch-short}}-filename": f"download/{arch}/{os}/test-{os}-{short_arch}-filename", 19 | } 20 | if os == "windows": 21 | tests.update( 22 | { 23 | "filename.{{shared-lib-ext}}": "filename.dll", 24 | "filename.{{executable-ext}}": "filename.exe", 25 | "filename-{{executable-ext}}.{{executable-ext}}": "filename-exe.exe", 26 | "filename-{{executable-ext}}": "filename-exe", 27 | } 28 | ) 29 | else: 30 | tests.update( 31 | { 32 | "filename.{{shared-lib-ext}}": "filename.so", 33 | "filename.{{executable-ext}}": "filename", 34 | "filename.{{executable-ext}}.{{executable-ext}}": "filename", 35 | "filename-{{executable-ext}}": "filename-", 36 | } 37 | ) 38 | for test_input, tests_output in tests.items(): 39 | assert mplug.util.resolve_templates(test_input) == tests_output 40 | --------------------------------------------------------------------------------