├── .cookiecutter.json ├── .darglint ├── .flake8 ├── .gitattributes ├── .github ├── dependabot.yml ├── labels.yml ├── release-drafter.yml └── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── LICENSE.rst ├── README.rst ├── codecov.yml ├── docs ├── codeofconduct.rst ├── conf.py ├── contributing.rst ├── index.rst ├── license.rst ├── reference.rst ├── requirements.txt └── usage.rst ├── noxfile.py ├── poetry.lock ├── pyproject.toml ├── src └── mopup │ ├── __init__.py │ ├── __main__.py │ └── py.typed └── tests ├── __init__.py └── test_main.py /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_template": "gh:cjolowicz/cookiecutter-hypermodern-python", 3 | "author": "Glyph Lefkowitz", 4 | "development_status": "Development Status :: 3 - Alpha", 5 | "email": "glyph@glyph.im", 6 | "friendly_name": "MOPUp", 7 | "github_user": "glyph", 8 | "license": "MIT", 9 | "package_name": "mopup", 10 | "project_name": "MOPUp", 11 | "version": "2022.5.5" 12 | } 13 | -------------------------------------------------------------------------------- /.darglint: -------------------------------------------------------------------------------- 1 | [darglint] 2 | strictness = long 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = B,B9,C,D,DAR,E,F,N,RST,S,W 3 | ignore = E203,E501,RST201,RST203,RST301,W503,D212,D202,D205,D415 4 | max-line-length = 80 5 | max-complexity = 10 6 | docstring-convention = google 7 | per-file-ignores = tests/*:S101 8 | rst-roles = class,const,func,meth,mod,ref 9 | rst-directives = deprecated 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/docs" 13 | schedule: 14 | interval: daily 15 | - package-ecosystem: pip 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==25.1.1 2 | nox==2025.5.1 3 | nox-poetry==1.2.0 4 | poetry==1.7.0 5 | virtualenv==20.30.0 6 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | jobs: 9 | labeler: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repository 13 | uses: actions/checkout@v4.2.2 14 | 15 | - name: Run Labeler 16 | uses: crazy-max/ghaction-github-labeler@v5.3.0 17 | with: 18 | skip-delete: true 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v4.2.2 15 | with: 16 | fetch-depth: 2 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5.6.0 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Upgrade pip 24 | run: | 25 | pip install --constraint=.github/workflows/constraints.txt pip 26 | pip --version 27 | 28 | - name: Install Poetry 29 | run: | 30 | pip install --constraint=.github/workflows/constraints.txt poetry 31 | poetry --version 32 | 33 | - name: Check if there is a parent commit 34 | id: check-parent-commit 35 | run: | 36 | echo "::set-output name=sha::$(git rev-parse --verify --quiet HEAD^)" 37 | 38 | - name: Detect and tag new version 39 | id: check-version 40 | if: steps.check-parent-commit.outputs.sha 41 | uses: salsify/action-detect-and-tag-new-version@v2.0.3 42 | with: 43 | version-command: | 44 | bash -o pipefail -c "poetry version | awk '{ print \$2 }'" 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | - name: Bump version for developmental release 49 | if: "! steps.check-version.outputs.tag" 50 | run: | 51 | poetry version patch && 52 | version=$(poetry version | awk '{ print $2 }') && 53 | poetry version $version.dev.$(date +%s) 54 | 55 | - name: Build package 56 | run: | 57 | poetry build --ansi 58 | 59 | - name: Publish package on PyPI 60 | if: steps.check-version.outputs.tag 61 | uses: pypa/gh-action-pypi-publish@v1.12.4 62 | with: 63 | user: __token__ 64 | password: ${{ secrets.PYPI_TOKEN }} 65 | 66 | - name: Publish package on TestPyPI 67 | if: "! steps.check-version.outputs.tag" 68 | uses: pypa/gh-action-pypi-publish@v1.12.4 69 | with: 70 | user: __token__ 71 | password: ${{ secrets.TEST_PYPI_TOKEN }} 72 | repository_url: https://test.pypi.org/legacy/ 73 | 74 | - name: Publish the release notes 75 | uses: release-drafter/release-drafter@v6.1.0 76 | with: 77 | publish: ${{ steps.check-version.outputs.tag != '' }} 78 | tag: ${{ steps.check-version.outputs.tag }} 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | name: ${{ matrix.session }} ${{ matrix.python }} / ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - { python: "3.13", os: "ubuntu-latest", session: "pre-commit" } 16 | - { python: "3.13", os: "ubuntu-latest", session: "mypy" } 17 | - { python: "3.13", os: "macos-latest", session: "typeguard" } 18 | - { python: "3.13", os: "macos-latest", session: "xdoctest" } 19 | - { python: "3.13", os: "ubuntu-latest", session: "docs-build" } 20 | - { python: "3.13", os: "macos-latest", session: "tests" } 21 | - { python: "3.12", os: "macos-latest", session: "tests" } 22 | - { python: "3.11", os: "macos-latest", session: "tests" } 23 | - { python: "3.10", os: "macos-latest", session: "tests" } 24 | 25 | env: 26 | NOXSESSION: ${{ matrix.session }} 27 | FORCE_COLOR: "1" 28 | PRE_COMMIT_COLOR: "always" 29 | 30 | steps: 31 | - name: Check out the repository 32 | uses: actions/checkout@v4.2.2 33 | 34 | - name: Set up Python ${{ matrix.python }} 35 | uses: actions/setup-python@v5.6.0 36 | with: 37 | python-version: "${{ matrix.python }}" 38 | 39 | - name: Upgrade pip 40 | run: | 41 | pip install --constraint=.github/workflows/constraints.txt pip 42 | pip --version 43 | 44 | - name: Upgrade pip in virtual environments 45 | shell: python 46 | run: | 47 | import os 48 | import pip 49 | 50 | with open(os.environ["GITHUB_ENV"], mode="a") as io: 51 | print(f"VIRTUALENV_PIP={pip.__version__}", file=io) 52 | 53 | - name: Install Poetry 54 | run: | 55 | pipx install --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" poetry 56 | poetry --version 57 | 58 | - name: Install Nox 59 | run: | 60 | pipx install --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" nox 61 | pipx inject --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" nox nox-poetry 62 | nox --version 63 | 64 | - name: Compute pre-commit cache key 65 | if: matrix.session == 'pre-commit' 66 | id: pre-commit-cache 67 | shell: python 68 | run: | 69 | import hashlib 70 | import sys 71 | 72 | python = "py{}.{}".format(*sys.version_info[:2]) 73 | payload = sys.version.encode() + sys.executable.encode() 74 | digest = hashlib.sha256(payload).hexdigest() 75 | result = "${{ runner.os }}-{}-{}-pre-commit".format(python, digest[:8]) 76 | 77 | print("::set-output name=result::{}".format(result)) 78 | 79 | - name: Restore pre-commit cache 80 | uses: actions/cache@v4 81 | if: matrix.session == 'pre-commit' 82 | with: 83 | path: ~/.cache/pre-commit 84 | key: ${{ steps.pre-commit-cache.outputs.result }}-${{ hashFiles('.pre-commit-config.yaml') }} 85 | restore-keys: | 86 | ${{ steps.pre-commit-cache.outputs.result }}- 87 | 88 | - name: Run Nox 89 | run: | 90 | nox --force-color --python=${{ matrix.python }} 91 | 92 | - name: Upload coverage data 93 | if: always() && matrix.session == 'tests' 94 | uses: "actions/upload-artifact@v4" 95 | with: 96 | include-hidden-files: true 97 | name: "coverage-data-${{ matrix.session }}-${{ matrix.python }}" 98 | if-no-files-found: error # 'warn' or 'ignore', defaults to `warn` 99 | path: ".coverage.*" 100 | 101 | - name: Upload documentation 102 | if: matrix.session == 'docs-build' 103 | uses: "actions/upload-artifact@v4" 104 | with: 105 | name: docs 106 | path: docs/_build 107 | 108 | coverage: 109 | runs-on: ubuntu-latest 110 | needs: tests 111 | steps: 112 | - name: Check out the repository 113 | uses: actions/checkout@v4.2.2 114 | 115 | - name: Set up Python 116 | uses: actions/setup-python@v5.6.0 117 | with: 118 | python-version: "3.13" 119 | 120 | - name: Upgrade pip 121 | run: | 122 | pip install --constraint=.github/workflows/constraints.txt pip 123 | pip --version 124 | 125 | - name: Install Poetry 126 | run: | 127 | pipx install --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" poetry 128 | poetry --version 129 | 130 | - name: Install Nox 131 | run: | 132 | pipx install --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" nox 133 | pipx inject --pip-args="--constraint=${PWD}/.github/workflows/constraints.txt" nox nox-poetry 134 | nox --version 135 | 136 | - name: Download coverage data 137 | uses: actions/download-artifact@v4 138 | with: 139 | pattern: "coverage-data-*" 140 | merge-multiple: true 141 | path: . 142 | 143 | - name: Combine coverage data and display human readable report 144 | run: | 145 | nox --force-color --session=coverage 146 | 147 | - name: Create coverage report 148 | run: | 149 | nox --force-color --session=coverage -- xml 150 | 151 | - name: Upload coverage report 152 | uses: codecov/codecov-action@v5.4.2 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | /.coverage 3 | /.coverage.* 4 | /.nox/ 5 | /.python-version 6 | /.pytype/ 7 | /dist/ 8 | /docs/_build/ 9 | /src/*.egg-info/ 10 | __pycache__/ 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black 7 | language: system 8 | types: [python] 9 | require_serial: true 10 | - id: check-toml 11 | name: Check Toml 12 | entry: check-toml 13 | language: system 14 | types: [toml] 15 | - id: flake8 16 | name: flake8 17 | entry: flake8 18 | language: system 19 | types: [python] 20 | exclude: noxfile.py 21 | require_serial: true 22 | - id: pyupgrade 23 | name: pyupgrade 24 | description: Automatically upgrade syntax for newer versions. 25 | entry: pyupgrade 26 | language: system 27 | types: [python] 28 | args: [--py37-plus] 29 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: "3.13" 6 | sphinx: 7 | configuration: docs/conf.py 8 | formats: all 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | - path: . 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This Code of Conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer (see below). All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributor Guide 2 | ================= 3 | 4 | Thank you for your interest in improving this project. 5 | This project is open-source under the `MIT license`_ and 6 | welcomes contributions in the form of bug reports, feature requests, and pull requests. 7 | 8 | Here is a list of important resources for contributors: 9 | 10 | - `Source Code`_ 11 | - `Documentation`_ 12 | - `Issue Tracker`_ 13 | - `Code of Conduct`_ 14 | 15 | .. _MIT license: https://opensource.org/licenses/MIT 16 | .. _Source Code: https://github.com/glyph/MOPUp 17 | .. _Documentation: https://MOPUp.readthedocs.io/ 18 | .. _Issue Tracker: https://github.com/glyph/MOPUp/issues 19 | 20 | How to report a bug 21 | ------------------- 22 | 23 | Report bugs on the `Issue Tracker`_. 24 | 25 | When filing an issue, make sure to answer these questions: 26 | 27 | - Which operating system and Python version are you using? 28 | - Which version of this project are you using? 29 | - What did you do? 30 | - What did you expect to see? 31 | - What did you see instead? 32 | 33 | The best way to get your bug fixed is to provide a test case, 34 | and/or steps to reproduce the issue. 35 | 36 | 37 | How to request a feature 38 | ------------------------ 39 | 40 | Request features on the `Issue Tracker`_. 41 | 42 | 43 | How to set up your development environment 44 | ------------------------------------------ 45 | 46 | You need Python 3.8+ and the following tools: 47 | 48 | - Poetry_ 49 | - Nox_ 50 | - nox-poetry_ 51 | 52 | Install the package with development requirements: 53 | 54 | .. code:: console 55 | 56 | $ poetry install 57 | 58 | You can now run an interactive Python session, 59 | or the command-line interface: 60 | 61 | .. code:: console 62 | 63 | $ poetry run python 64 | $ poetry run MOPUp 65 | 66 | .. _Poetry: https://python-poetry.org/ 67 | .. _Nox: https://nox.thea.codes/ 68 | .. _nox-poetry: https://nox-poetry.readthedocs.io/ 69 | 70 | 71 | How to test the project 72 | ----------------------- 73 | 74 | Run the full test suite: 75 | 76 | .. code:: console 77 | 78 | $ nox 79 | 80 | List the available Nox sessions: 81 | 82 | .. code:: console 83 | 84 | $ nox --list-sessions 85 | 86 | You can also run a specific Nox session. 87 | For example, invoke the unit test suite like this: 88 | 89 | .. code:: console 90 | 91 | $ nox --session=tests 92 | 93 | Unit tests are located in the ``tests`` directory, 94 | and are written using the pytest_ testing framework. 95 | 96 | .. _pytest: https://pytest.readthedocs.io/ 97 | 98 | 99 | How to submit changes 100 | --------------------- 101 | 102 | Open a `pull request`_ to submit changes to this project. 103 | 104 | Your pull request needs to meet the following guidelines for acceptance: 105 | 106 | - The Nox test suite must pass without errors and warnings. 107 | - Include unit tests. This project maintains 100% code coverage. 108 | - If your changes add functionality, update the documentation accordingly. 109 | 110 | Feel free to submit early, though—we can always iterate on this. 111 | 112 | To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: 113 | 114 | .. code:: console 115 | 116 | $ nox --session=pre-commit -- install 117 | 118 | It is recommended to open an issue before starting work on anything. 119 | This will allow a chance to talk it over with the owners and validate your approach. 120 | 121 | .. _pull request: https://github.com/glyph/MOPUp/pulls 122 | .. github-only 123 | .. _Code of Conduct: CODE_OF_CONDUCT.rst 124 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Copyright © 2022 Glyph Lefkowitz 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | **The software is provided "as is", without warranty of any kind, express or 17 | implied, including but not limited to the warranties of merchantability, 18 | fitness for a particular purpose and noninfringement. In no event shall the 19 | authors or copyright holders be liable for any claim, damages or other 20 | liability, whether in an action of contract, tort or otherwise, arising from, 21 | out of or in connection with the software or the use or other dealings in the 22 | software.** 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | MOPUp 2 | ===== 3 | 4 | |PyPI| |Status| |Python Version| |License| 5 | 6 | |Read the Docs| |Tests| |Codecov| 7 | 8 | |pre-commit| |Black| 9 | 10 | .. |PyPI| image:: https://img.shields.io/pypi/v/MOPUp.svg 11 | :target: https://pypi.org/project/MOPUp/ 12 | :alt: PyPI 13 | .. |Status| image:: https://img.shields.io/pypi/status/MOPUp.svg 14 | :target: https://pypi.org/project/MOPUp/ 15 | :alt: Status 16 | .. |Python Version| image:: https://img.shields.io/pypi/pyversions/MOPUp 17 | :target: https://pypi.org/project/MOPUp 18 | :alt: Python Version 19 | .. |License| image:: https://img.shields.io/pypi/l/MOPUp 20 | :target: https://opensource.org/licenses/MIT 21 | :alt: License 22 | .. |Read the Docs| image:: https://img.shields.io/readthedocs/MOPUp/latest.svg?label=Read%20the%20Docs 23 | :target: https://MOPUp.readthedocs.io/ 24 | :alt: Read the documentation at https://MOPUp.readthedocs.io/ 25 | .. |Tests| image:: https://github.com/glyph/MOPUp/workflows/Tests/badge.svg 26 | :target: https://github.com/glyph/MOPUp/actions?workflow=Tests 27 | :alt: Tests 28 | .. |Codecov| image:: https://codecov.io/gh/glyph/MOPUp/branch/main/graph/badge.svg 29 | :target: https://codecov.io/gh/glyph/MOPUp 30 | :alt: Codecov 31 | .. |pre-commit| image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white 32 | :target: https://github.com/pre-commit/pre-commit 33 | :alt: pre-commit 34 | .. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg 35 | :target: https://github.com/psf/black 36 | :alt: Black 37 | 38 | 39 | Features 40 | -------- 41 | 42 | MOPUp is the mac\ **O**\ S **P**\ ython.org **Updater**. 43 | 44 | If you prefer to use the binary installers from python.org, it's easy to forget 45 | to update them. This is a program that does that; it updates them. Just ``pip 46 | install mopup`` into a virtualenv using the Python you are using, run ``mopup`` 47 | and provide your password when prompted. An administrator password is required, 48 | because the python.org binary installers require admin privileges. 49 | 50 | Normally, it does this using a CLI in the background, but if you'd prefer, you 51 | can run it with ``--interactive`` for it to launch the usual macOS GUI 52 | Installer app. 53 | 54 | Installation 55 | ------------ 56 | 57 | You can install *MOPUp* via pip_ from PyPI_: 58 | 59 | .. code:: console 60 | 61 | $ pip install mopup 62 | 63 | 64 | Usage 65 | ----- 66 | 67 | Please see the `Command-line Reference `_ for details. 68 | 69 | 70 | Contributing 71 | ------------ 72 | 73 | Contributions are very welcome. 74 | To learn more, see the `Contributor Guide`_. 75 | 76 | 77 | License 78 | ------- 79 | 80 | Distributed under the terms of the `MIT license`_, 81 | *MOPUp* is free and open source software. 82 | 83 | 84 | Issues 85 | ------ 86 | 87 | If you encounter any problems, 88 | please `file an issue`_ along with a detailed description. 89 | 90 | 91 | Credits 92 | ------- 93 | 94 | This project was generated from `@cjolowicz`_'s `Hypermodern Python Cookiecutter`_ template. 95 | 96 | .. _@cjolowicz: https://github.com/cjolowicz 97 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 98 | .. _MIT license: https://opensource.org/licenses/MIT 99 | .. _PyPI: https://pypi.org/ 100 | .. _Hypermodern Python Cookiecutter: https://github.com/cjolowicz/cookiecutter-hypermodern-python 101 | .. _file an issue: https://github.com/glyph/MOPUp/issues 102 | .. _pip: https://pip.pypa.io/ 103 | .. github-only 104 | .. _Contributor Guide: CONTRIBUTING.rst 105 | .. _Usage: https://MOPUp.readthedocs.io/en/latest/usage.html 106 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: "50" 7 | patch: 8 | default: 9 | target: "50" 10 | -------------------------------------------------------------------------------- /docs/codeofconduct.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CODE_OF_CONDUCT.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration.""" 2 | 3 | from datetime import datetime 4 | 5 | project = "MOPUp" 6 | author = "Glyph Lefkowitz" 7 | copyright = f"{datetime.now().year}, {author}" 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx.ext.napoleon", 11 | "sphinx_click", 12 | ] 13 | autodoc_typehints = "description" 14 | html_theme = "furo" 15 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | :end-before: github-only 3 | 4 | .. _Code of Conduct: codeofconduct.html 5 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :end-before: github-only 3 | 4 | .. _Contributor Guide: contributing.html 5 | .. _Usage: usage.html 6 | 7 | .. toctree:: 8 | :hidden: 9 | :maxdepth: 1 10 | 11 | usage 12 | reference 13 | contributing 14 | Code of Conduct 15 | License 16 | Changelog 17 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../LICENSE.rst 2 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | 5 | mopup 6 | ----- 7 | 8 | .. automodule:: mopup 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo==2024.8.6 2 | sphinx==8.2.3 3 | sphinx-click==6.0.0 4 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | .. click:: mopup.__main__:main 5 | :prog: mopup 6 | :nested: full 7 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | 3 | import os 4 | import shutil 5 | import sys 6 | from pathlib import Path 7 | from textwrap import dedent 8 | 9 | import nox 10 | 11 | try: 12 | from nox_poetry import Session, session 13 | except ImportError: 14 | message = f"""\ 15 | Nox failed to import the 'nox-poetry' package. 16 | 17 | Please install it using the following command: 18 | 19 | {sys.executable} -m pip install nox-poetry""" 20 | raise SystemExit(dedent(message)) from None 21 | 22 | 23 | package = "mopup" 24 | python_versions = ["3.13", "3.12", "3.11", "3.10", "3.9"] 25 | nox.needs_version = ">= 2021.6.6" 26 | nox.options.sessions = ( 27 | "pre-commit", 28 | "safety", 29 | "mypy", 30 | "tests", 31 | "typeguard", 32 | "xdoctest", 33 | "docs-build", 34 | ) 35 | 36 | 37 | def activate_virtualenv_in_precommit_hooks(session: Session) -> None: 38 | """Activate virtualenv in hooks installed by pre-commit. 39 | 40 | This function patches git hooks installed by pre-commit to activate the 41 | session's virtual environment. This allows pre-commit to locate hooks in 42 | that environment when invoked from git. 43 | 44 | Args: 45 | session: The Session object. 46 | """ 47 | assert session.bin is not None # noqa: S101 48 | 49 | virtualenv = session.env.get("VIRTUAL_ENV") 50 | if virtualenv is None: 51 | return 52 | 53 | hookdir = Path(".git") / "hooks" 54 | if not hookdir.is_dir(): 55 | return 56 | 57 | for hook in hookdir.iterdir(): 58 | if hook.name.endswith(".sample") or not hook.is_file(): 59 | continue 60 | 61 | text = hook.read_text() 62 | bindir = repr(session.bin)[1:-1] # strip quotes 63 | if not ( 64 | Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text 65 | ): 66 | continue 67 | 68 | lines = text.splitlines() 69 | if not (lines[0].startswith("#!") and "python" in lines[0].lower()): 70 | continue 71 | 72 | header = dedent( 73 | f"""\ 74 | import os 75 | os.environ["VIRTUAL_ENV"] = {virtualenv!r} 76 | os.environ["PATH"] = os.pathsep.join(( 77 | {session.bin!r}, 78 | os.environ.get("PATH", ""), 79 | )) 80 | """ 81 | ) 82 | 83 | lines.insert(1, header) 84 | hook.write_text("\n".join(lines)) 85 | 86 | 87 | @session(name="pre-commit", python="3.13") 88 | def precommit(session: Session) -> None: 89 | """Lint using pre-commit.""" 90 | args = session.posargs or ["run", "--all-files", "--show-diff-on-failure"] 91 | session.install( 92 | "black", 93 | "darglint", 94 | "flake8", 95 | "flake8-bandit", 96 | "flake8-bugbear", 97 | "flake8-docstrings", 98 | "flake8-rst-docstrings", 99 | "pep8-naming", 100 | "pre-commit", 101 | "pre-commit-hooks", 102 | "pyupgrade", 103 | "reorder-python-imports", 104 | ) 105 | session.run("pre-commit", *args) 106 | if args and args[0] == "install": 107 | activate_virtualenv_in_precommit_hooks(session) 108 | 109 | 110 | @session(python="3.13") 111 | def safety(session: Session) -> None: 112 | """Scan dependencies for insecure packages.""" 113 | requirements = session.poetry.export_requirements() 114 | session.install("safety") 115 | session.run("safety", "check", "--full-report", f"--file={requirements}") 116 | 117 | 118 | @session(python=python_versions) 119 | def mypy(session: Session) -> None: 120 | """Type-check using mypy.""" 121 | args = session.posargs or ["src", "tests", "docs/conf.py"] 122 | session.install(".") 123 | session.install( 124 | "mypy", 125 | "pytest", 126 | # These two should be pulled from dev dependencies somehow... 127 | "types-requests", 128 | "types-html5lib", 129 | ) 130 | session.run("mypy", *args) 131 | if not session.posargs: 132 | session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") 133 | 134 | 135 | @session(python=python_versions) 136 | def tests(session: Session) -> None: 137 | """Run the test suite.""" 138 | session.install(".") 139 | session.install("coverage[toml]", "pytest", "pygments") 140 | try: 141 | session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs) 142 | finally: 143 | if session.interactive: 144 | session.notify("coverage", posargs=[]) 145 | 146 | 147 | @session 148 | def coverage(session: Session) -> None: 149 | """Produce the coverage report.""" 150 | args = session.posargs or ["report"] 151 | 152 | session.install("coverage[toml]") 153 | 154 | if not session.posargs and any(Path().glob(".coverage.*")): 155 | session.run("coverage", "combine") 156 | 157 | session.run("coverage", *args) 158 | 159 | 160 | @session(python=python_versions) 161 | def typeguard(session: Session) -> None: 162 | """Runtime type checking using Typeguard.""" 163 | session.install(".") 164 | session.install("pytest", "typeguard", "pygments") 165 | session.run("pytest", f"--typeguard-packages={package}", *session.posargs) 166 | 167 | 168 | @session(python=python_versions) 169 | def xdoctest(session: Session) -> None: 170 | """Run examples with xdoctest.""" 171 | if session.posargs: 172 | args = [package, *session.posargs] 173 | else: 174 | args = [f"--modname={package}", "--command=all"] 175 | if "FORCE_COLOR" in os.environ: 176 | args.append("--colored=1") 177 | 178 | session.install(".") 179 | session.install("xdoctest[colors]") 180 | session.run("python", "-m", "xdoctest", *args) 181 | 182 | 183 | @session(name="docs-build", python="3.13") 184 | def docs_build(session: Session) -> None: 185 | """Build the documentation.""" 186 | args = session.posargs or ["docs", "docs/_build"] 187 | if not session.posargs and "FORCE_COLOR" in os.environ: 188 | args.insert(0, "--color") 189 | 190 | session.install(".") 191 | session.install("sphinx", "sphinx-click", "furo") 192 | 193 | build_dir = Path("docs", "_build") 194 | if build_dir.exists(): 195 | shutil.rmtree(build_dir) 196 | 197 | session.run("sphinx-build", *args) 198 | 199 | 200 | @session(python="3.13") 201 | def docs(session: Session) -> None: 202 | """Build and serve the documentation with live reloading on file changes.""" 203 | args = session.posargs or ["--open-browser", "docs", "docs/_build"] 204 | session.install(".") 205 | session.install("sphinx", "sphinx-autobuild", "sphinx-click", "furo") 206 | 207 | build_dir = Path("docs", "_build") 208 | if build_dir.exists(): 209 | shutil.rmtree(build_dir) 210 | 211 | session.run("sphinx-autobuild", *args) 212 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "MOPUp" 3 | version = "2024.08.09.1" 4 | description = "MOPUp" 5 | authors = ["Glyph Lefkowitz "] 6 | license = "MIT" 7 | readme = "README.rst" 8 | homepage = "https://github.com/glyph/MOPUp" 9 | repository = "https://github.com/glyph/MOPUp" 10 | documentation = "https://MOPUp.readthedocs.io" 11 | packages = [ 12 | { include = "mopup", from = "src" }, 13 | ] 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | ] 17 | 18 | [tool.poetry.urls] 19 | Changelog = "https://github.com/glyph/MOPUp/releases" 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.8,<4.0" 23 | click = ">=8.0.1" 24 | html5lib = ">=1.1" 25 | requests = ">=2.27.1" 26 | hyperlink = "*" 27 | rich = ">=12.4.4,<15.0.0" 28 | types-click = "^7.1.8" 29 | packaging = ">=24,<26" 30 | certifi = ">=2024.6.2,<2026.0.0" 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | pytest = ">=6.2.5" 34 | coverage = {extras = ["toml"], version = ">=6.1"} 35 | safety = ">=1.10.3" 36 | mypy = ">=0.910" 37 | typeguard = ">=2.13.2" 38 | xdoctest = {extras = ["colors"], version = ">=0.15.10"} 39 | sphinx = ">=4.3.0" 40 | sphinx-autobuild = ">=2021.3.14" 41 | pre-commit = ">=2.15.0" 42 | flake8 = ">=4.0.1" 43 | black = ">=21.10b0" 44 | flake8-bandit = ">=2.1.2" 45 | flake8-bugbear = ">=21.9.2" 46 | flake8-docstrings = ">=1.6.0" 47 | flake8-rst-docstrings = ">=0.2.3" 48 | pep8-naming = ">=0.12.1" 49 | darglint = ">=1.8.1" 50 | reorder-python-imports = ">=2.6.0" 51 | pre-commit-hooks = ">=4.0.1" 52 | sphinx-click = ">=3.0.2" 53 | Pygments = ">=2.10.0" 54 | pyupgrade = ">=2.29.1" 55 | furo = ">=2021.11.12" 56 | types-requests = "*" 57 | types-html5lib = "*" 58 | 59 | [tool.poetry.scripts] 60 | mopup = "mopup.__main__:main" 61 | 62 | [tool.coverage.paths] 63 | source = ["src", "*/site-packages"] 64 | tests = ["tests", "*/tests"] 65 | 66 | [tool.coverage.run] 67 | branch = true 68 | source = ["mopup", "tests"] 69 | 70 | [tool.coverage.report] 71 | show_missing = true 72 | fail_under = 50 73 | 74 | [tool.mypy] 75 | strict = true 76 | warn_unreachable = true 77 | show_column_numbers = true 78 | show_error_codes = true 79 | show_error_context = true 80 | 81 | [build-system] 82 | requires = ["poetry-core>=1.0.0"] 83 | build-backend = "poetry.core.masonry.api" 84 | -------------------------------------------------------------------------------- /src/mopup/__init__.py: -------------------------------------------------------------------------------- 1 | """Auto-updater for official python.org builds of python.""" 2 | 3 | import collections 4 | from os import makedirs, rename, rmdir, unlink 5 | from os.path import expanduser 6 | from os.path import join as pathjoin 7 | from platform import mac_ver 8 | from plistlib import dumps as dumpplist 9 | from plistlib import loads as loadplist 10 | from re import compile as compile_re 11 | from subprocess import PIPE, run # noqa: S404 12 | from sys import version_info 13 | from tempfile import NamedTemporaryFile 14 | from typing import Dict, Iterable, List, Match, Pattern, Tuple 15 | from uuid import uuid4 16 | 17 | import html5lib 18 | import requests 19 | from hyperlink import DecodedURL 20 | from packaging.version import Version, parse 21 | from rich.progress import Progress 22 | 23 | 24 | def alllinksin( 25 | u: DecodedURL, e: Pattern[str] 26 | ) -> Iterable[Tuple[Match[str], DecodedURL]]: 27 | """Get all the links in the given URL whose text matches the given pattern.""" 28 | for a in html5lib.parse( 29 | requests.get(u.to_text(), timeout=30).text, namespaceHTMLElements=False 30 | ).findall(".//a"): 31 | match = e.fullmatch(a.text or "") 32 | if match is not None: 33 | yield match, u.click(a.attrib["href"]) 34 | 35 | 36 | def choicechanges(pkgfile: str) -> str: 37 | """ 38 | Compute the choice-changes XML for a given package based on what is 39 | currently installed. 40 | """ 41 | 42 | all_installed = set( 43 | run( # noqa: S603 44 | [ 45 | "/usr/sbin/pkgutil", 46 | "--pkgs", 47 | ], 48 | stdout=PIPE, 49 | ) 50 | .stdout.decode() 51 | .split("\n") 52 | ) 53 | dicts = loadplist( 54 | run( # noqa: S603 55 | [ 56 | "/usr/sbin/installer", 57 | "-showChoiceChangesXML", 58 | "-pkg", 59 | pkgfile, 60 | ], 61 | stdout=PIPE, 62 | ).stdout 63 | ) 64 | for each in dicts: 65 | if each["choiceAttribute"] == "selected": 66 | choice_id = each["choiceIdentifier"] 67 | setting = int(choice_id in all_installed) 68 | if setting: 69 | print("selecting choice", each["choiceIdentifier"]) 70 | each["attributeSetting"] = setting 71 | return dumpplist(dicts).decode() 72 | 73 | 74 | def main(interactive: bool, force: bool, minor_upgrade: bool, dry_run: bool) -> None: 75 | """Do an update.""" 76 | this_mac_ver = tuple(map(int, mac_ver()[0].split(".")[:2])) 77 | ver = compile_re(r"(\d+)\.(\d+).(\d+)/") 78 | macpkg = compile_re(r"python-(\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?)-macosx?(\d+).pkg") 79 | 80 | thismajor, thisminor, thismicro, releaselevel, serial = version_info 81 | level = { 82 | "alpha": "a", 83 | "beta": "b", 84 | "candidate": "rc", 85 | "final": "", 86 | }[releaselevel] 87 | 88 | thispkgver = Version( 89 | f"{thismajor}.{thisminor}.{thismicro}" + (f".{level}{serial}" if level else "") 90 | ) 91 | 92 | # {macos, major, minor: [(Version, URL)]} 93 | # major, minor, micro, macos: [(version, URL)] 94 | versions: Dict[ 95 | int, Dict[int, Dict[int, Dict[str, List[Tuple[Version, DecodedURL]]]]] 96 | ] = collections.defaultdict( 97 | lambda: collections.defaultdict( 98 | lambda: collections.defaultdict(lambda: collections.defaultdict(list)) 99 | ) 100 | ) 101 | 102 | baseurl = DecodedURL.from_text("https://www.python.org/ftp/python/") 103 | 104 | for eachver, suburl in alllinksin(baseurl, ver): 105 | major, minor, micro = map(int, eachver.groups()) 106 | if major != thismajor: 107 | continue 108 | if minor != thisminor and not minor_upgrade: 109 | continue 110 | for eachmac, pkgdl in alllinksin(suburl, macpkg): 111 | pyver, macver = eachmac.groups() 112 | fullversion = parse(pyver) 113 | if fullversion.pre and not thispkgver.pre: 114 | continue 115 | if ( 116 | fullversion.major, 117 | fullversion.minor, 118 | fullversion.micro, 119 | ) == ( 120 | major, 121 | minor, 122 | micro, 123 | ): 124 | versions[major][minor][micro][macver].append((fullversion, pkgdl)) 125 | 126 | newminor = max(versions[thismajor].keys()) 127 | newmicro = max(versions[thismajor][newminor].keys()) 128 | available_mac_vers = versions[thismajor][newminor][newmicro].keys() 129 | best_available_mac = max( 130 | available_mac_ver 131 | for available_mac_ver in available_mac_vers 132 | if this_mac_ver >= tuple(int(x) for x in available_mac_ver.split(".")) 133 | ) 134 | 135 | download_urls = versions[thismajor][newminor][newmicro][best_available_mac] 136 | 137 | best_ver, download_url = sorted(download_urls, reverse=True)[0] 138 | 139 | # TODO: somehow flake8 in pre-commit thinks that this semicolon is in the 140 | # *code* and not in a string. 141 | print(f"this version: {thispkgver}; new version: {best_ver}") # noqa 142 | update_needed = best_ver > thispkgver 143 | 144 | print( 145 | "update", 146 | "needed" if update_needed else "not needed", 147 | "from", 148 | download_url, 149 | ) 150 | 151 | if dry_run or not (update_needed or force): 152 | return 153 | 154 | finalname = do_download(download_url) 155 | with NamedTemporaryFile(mode="w", suffix=".plist") as tf: 156 | if interactive: 157 | argv = ["/usr/bin/open", "-b", "com.apple.installer", finalname] 158 | else: 159 | tf.write(choicechanges(finalname)) 160 | tf.flush() 161 | print("Enter your administrative password to run the update:") 162 | argv = [ 163 | "/usr/bin/sudo", 164 | "/usr/sbin/installer", 165 | "-applyChoiceChangesXML", 166 | tf.name, 167 | "-pkg", 168 | finalname, 169 | "-target", 170 | "/", 171 | ] 172 | run(argv) # noqa: S603 173 | print("Complete.") 174 | 175 | 176 | def do_download(download_url: DecodedURL) -> str: 177 | """ 178 | Download the given URL into the downloads directory. 179 | 180 | Returning the path when successful. 181 | """ 182 | basename = download_url.path[-1] 183 | partial = basename + ".mopup-partial" 184 | downloads_dir = expanduser("~/Downloads/") 185 | partialdir = pathjoin(downloads_dir, partial) 186 | contentname = pathjoin(partialdir, f"{uuid4()}.content") 187 | finalname = pathjoin(downloads_dir, basename) 188 | 189 | with requests.get( 190 | download_url.to_uri().to_text(), stream=True, timeout=30 191 | ) as response: 192 | response.raise_for_status() 193 | try: 194 | makedirs(partialdir, exist_ok=True) 195 | total_size = int(response.headers["content-length"]) 196 | with open(contentname, "wb") as f: 197 | with Progress() as progress: 198 | task = progress.add_task( 199 | f"Downloading {basename}...", total=total_size 200 | ) 201 | for chunk in response.iter_content(chunk_size=8192): 202 | progress.update(task, advance=len(chunk)) 203 | f.write(chunk) 204 | print(".") 205 | rename(contentname, finalname) 206 | except BaseException: 207 | unlink(contentname) 208 | rmdir(partialdir) 209 | raise 210 | else: 211 | rmdir(partialdir) 212 | return finalname 213 | -------------------------------------------------------------------------------- /src/mopup/__main__.py: -------------------------------------------------------------------------------- 1 | """Command-line interface.""" 2 | 3 | import click 4 | 5 | from mopup import main as libmain 6 | 7 | 8 | @click.command( 9 | help=""" 10 | MOPUp - the (m)ac(O)S (P)ython.org (Up)dater 11 | 12 | Run this program and enter your administrator password to install the 13 | most recent version from Python.org that matches your major/minor 14 | version. 15 | """ 16 | ) 17 | @click.option("--interactive", default=False, help="use the installer GUI", type=bool) 18 | @click.option( 19 | "--force", default=False, help="reinstall python even if it's up to date", type=bool 20 | ) 21 | @click.option( 22 | "--minor", 23 | default=False, 24 | help="do a minor version upgrade rather than the default (a micro-version)", 25 | ) 26 | @click.option( 27 | "--dry-run", 28 | default=False, 29 | help="don't actually download or install anything even if we're not up to date", 30 | ) 31 | def main(interactive: bool, force: bool, minor: bool, dry_run: bool) -> None: 32 | """MOPUp.""" 33 | libmain(interactive=interactive, force=force, minor_upgrade=minor, dry_run=dry_run) 34 | 35 | 36 | if __name__ == "__main__": 37 | main(prog_name="mopup") # pragma: no cover 38 | -------------------------------------------------------------------------------- /src/mopup/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/MOPUp/ff3827a25e500416aabebeebafcca4500913d8e1/src/mopup/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for the mopup package.""" 2 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test cases for the __main__ module.""" 2 | 3 | import pytest 4 | from click.testing import CliRunner 5 | 6 | from mopup import __main__ 7 | 8 | 9 | @pytest.fixture 10 | def runner() -> CliRunner: 11 | """Fixture for invoking command-line interfaces.""" 12 | return CliRunner() 13 | 14 | 15 | def test_main_succeeds(runner: CliRunner) -> None: 16 | """It exits with a status code of zero.""" 17 | result = runner.invoke(__main__.main) 18 | assert result.exit_code == 0 19 | --------------------------------------------------------------------------------