├── .codeclimate.yml ├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── default.yml │ ├── pre-release.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pyup.yml ├── .readthedocs.yml ├── .travis.yml ├── AUTHORS ├── CODE_OF_CONDUCT.rst ├── HISTORY ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── _static │ └── pyAppLogo.svg ├── change-history.rst ├── conf.py ├── developers.rst ├── extensions.rst ├── getting-started.rst ├── index.rst ├── recipes │ ├── async-commands.rst │ ├── index.rst │ ├── integration-with-django.rst │ └── single-script.rst ├── reference │ ├── app.rst │ ├── checks.rst │ ├── conf.rst │ ├── conf │ │ ├── base-settings.rst │ │ ├── helpers.rst │ │ └── typed-settings.rst │ ├── documentation │ │ └── sphinx.rst │ ├── events.rst │ ├── extensions.rst │ ├── feature-flags.rst │ ├── index.rst │ ├── injection.rst │ └── multiprocessing.rst └── sys-admin-playbook.rst ├── noxfile.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── samples ├── sample-single.py ├── sample │ ├── __init__.py │ ├── __main__.py │ ├── alt_checks.py │ ├── checks.py │ ├── cli.py │ ├── default_settings.py │ ├── events.py │ ├── more-settings.json │ ├── more-settings.toml │ └── more-settings.yaml └── sample_extension │ ├── __init__.py │ ├── checks.py │ └── default_settings.py ├── sonar-project.properties ├── src └── pyapp │ ├── __init__.py │ ├── app │ ├── __init__.py │ ├── argument_actions.py │ ├── argument_types.py │ ├── arguments.py │ ├── builtin_handlers.py │ ├── init_logger.py │ └── logging_formatter.py │ ├── checks │ ├── __init__.py │ ├── built_in.py │ ├── messages.py │ ├── registry.py │ └── report.py │ ├── compatability.py │ ├── conf │ ├── __init__.py │ ├── base_settings.py │ ├── helpers │ │ ├── __init__.py │ │ ├── bases.py │ │ ├── plugins.py │ │ └── providers.py │ ├── loaders │ │ ├── __init__.py │ │ ├── base.py │ │ ├── content_types.py │ │ ├── file_loader.py │ │ └── http_loader.py │ └── report.py │ ├── documentation │ ├── __init__.py │ ├── settings.py │ └── sphinx.py │ ├── events.py │ ├── exceptions.py │ ├── extensions │ ├── __init__.py │ ├── registry.py │ └── report.py │ ├── feature_flags.py │ ├── injection.py │ ├── multiprocessing.py │ ├── py.typed │ ├── testing │ ├── __init__.py │ └── pytest_plugin.py │ ├── typed_settings.py │ ├── utils │ ├── __init__.py │ ├── compatibility.py │ └── inspect.py │ └── versioning.py └── tests ├── __init__.py ├── conftest.py ├── fixtures ├── settings-invalid-container.json ├── settings-invalid-file.json ├── settings.json └── settings │ └── default_settings.py ├── functional ├── __init__.py └── test_multiprocessing.py ├── runtime_settings.py ├── runtime_settings_with_imports.py ├── settings.py └── unit ├── __init__.py ├── app ├── __init__.py ├── test_app.py ├── test_argument_actions.py ├── test_argument_types.py ├── test_arguments.py ├── test_arguments_signatures.py ├── test_init_logger.py └── test_logging_formatter.py ├── checks ├── test_built_in.py ├── test_checks_registry.py ├── test_messages.py └── test_report.py ├── conf ├── __init__.py ├── helpers │ ├── __init__.py │ ├── test_.py │ ├── test_plugin_factories.py │ ├── test_providers_factories.py │ └── test_singleton_factories.py ├── loaders │ ├── __init__.py │ ├── test_.py │ ├── test_content_types.py │ ├── test_file_loader.py │ └── test_http_loader.py ├── test_.py └── test_report.py ├── documentation ├── __init__.py ├── test_settings.py └── test_sphinx.py ├── extensions ├── __init__.py ├── test_registry.py └── test_report.py ├── factory └── __init__.py ├── mock.py ├── sample_app ├── __init__.py ├── __main__.py ├── alt_checks.py ├── checks.py ├── default_settings.py └── logging_settings.py ├── sample_app_simple.py ├── sample_ext ├── __init__.py ├── checks.py └── default_settings.py ├── sample_ext_simple └── __init__.py ├── test_compatability.py ├── test_events.py ├── test_feature_flags.py ├── test_injection.py ├── test_multiprocessing.py ├── test_pyapp.py ├── test_typed_settings.py ├── test_versioning.py ├── testing ├── __init__.py ├── test_.py └── test_pytest_plugin.py └── utils ├── __init__.py ├── test_.py ├── test_compatibility.py └── test_inspect.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | plugins: 4 | bandit: 5 | enabled: true 6 | 7 | radon: 8 | enabled: true 9 | 10 | SonarPython: 11 | enabled: true 12 | 13 | 14 | exclude_patterns: 15 | # Ignore tests 16 | - "tests/" 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pyapp 4 | 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: timsavage 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | labels: 9 | - "\U0001F38B dependencies" 10 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [development, master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [development] 9 | schedule: 10 | - cron: '0 15 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: ['python'] 21 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v2 26 | with: 27 | # We must fetch at least the immediate parents so that if this is 28 | # a pull request then we can checkout the head. 29 | fetch-depth: 2 30 | 31 | # If this run was triggered by a pull request event, then checkout 32 | # the head of the pull request instead of the merge commit. 33 | - run: git checkout HEAD^2 34 | if: ${{ github.event_name == 'pull_request' }} 35 | 36 | # Initializes the CodeQL tools for scanning. 37 | - name: Initialize CodeQL 38 | uses: github/codeql-action/init@v1 39 | with: 40 | languages: ${{ matrix.language }} 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Cases 5 | 6 | on: [push] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ['3.10', '3.11', '3.12', '3.13'] 14 | 15 | name: Test Python ${{ matrix.python-version }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 20 | 21 | - name: Setup Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@master 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install -U poetry coverage 29 | poetry install 30 | 31 | - name: Test with pytest 32 | run: | 33 | poetry run pytest --junit-xml=reports/xunit-result-${{ matrix.python-version }}.xml --cov-report=xml:reports/coverage-${{ matrix.python-version }}.xml tests 34 | 35 | - name: Analyze with SonarCloud 36 | uses: sonarsource/sonarcloud-github-action@master 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | # Match pre release beta tags on release branches. 2 | name: Pre-release 3 | 4 | on: 5 | push: 6 | branches: 7 | - release* 8 | tags: 9 | # Matches 1.2b3 and 1.2.3b4 10 | - "[0-9]+.[0-9]+[ab][0-9]+" 11 | - "[0-9]+.[0-9]+.[0-9]+[ab][0-9]+" 12 | 13 | jobs: 14 | pre-release: 15 | runs-on: ubuntu-latest 16 | name: Build pre-release package 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@master 22 | name: Setup Python 3.8 23 | with: 24 | python-version: 3.8 25 | 26 | - name: Install dependencies 27 | run: python -m pip install -U poetry 28 | 29 | - name: Build Package 30 | id: build_package 31 | run: | 32 | rm -rf dist 33 | poetry build 34 | cd dist 35 | echo "::set-output name=source::$(ls *.tar.gz)" 36 | echo "::set-output name=wheel::$(ls *.whl)" 37 | 38 | - name: Create a Release 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | tag_name: ${{ github.ref }} 45 | release_name: Pre-release ${{ github.ref }} 46 | prerelease: true 47 | 48 | - name: Upload Release Wheel 49 | uses: actions/upload-release-asset@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | upload_url: ${{ steps.create_release.outputs.upload_url }} 54 | asset_path: ./dist/${{ steps.build_package.outputs.wheel }} 55 | asset_name: ${{ steps.build_package.outputs.wheel }} 56 | asset_content_type: application/x-pywheel+zip 57 | 58 | - name: Upload Release Source 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./dist/${{ steps.build_package.outputs.source }} 65 | asset_name: ${{ steps.build_package.outputs.source }} 66 | asset_content_type: application/x-tar+gzip 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Match release tags on master branch 2 | name: Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | # Matches 1.2.3 and 1.2.3.4 10 | - "[0-9]+.[0-9]+.[0-9]+" 11 | - "[0-9]+.[0-9]+.[0-9].+[0-9]+" 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | name: Build release package 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@master 22 | name: Setup Python 3.8 23 | with: 24 | python-version: 3.8 25 | 26 | - name: Install dependencies 27 | run: python -m pip install -U poetry 28 | 29 | - name: Build Package 30 | id: build_package 31 | run: | 32 | rm -rf dist 33 | poetry build 34 | cd dist 35 | echo "::set-output name=source::$(ls *.tar.gz)" 36 | echo "::set-output name=wheel::$(ls *.whl)" 37 | echo "::set-output name=version::$(poetry version -s)" 38 | 39 | - name: Create a Release 40 | id: create_release 41 | uses: actions/create-release@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | tag_name: ${{ steps.build_package.outputs.version }} 46 | release_name: Release ${{ steps.build_package.outputs.version }} 47 | prerelease: false 48 | 49 | - name: Upload Release Wheel 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ./dist/${{ steps.build_package.outputs.wheel }} 56 | asset_name: ${{ steps.build_package.outputs.wheel }} 57 | asset_content_type: application/x-pywheel+zip 58 | 59 | - name: Upload Release Source 60 | uses: actions/upload-release-asset@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | upload_url: ${{ steps.create_release.outputs.upload_url }} 65 | asset_path: ./dist/${{ steps.build_package.outputs.source }} 66 | asset_name: ${{ steps.build_package.outputs.source }} 67 | asset_content_type: application/x-tar+gzip 68 | 69 | - name: Publish to PyPI 70 | uses: pypa/gh-action-pypi-publish@v1.5.0 71 | with: 72 | user: __token__ 73 | password: ${{ secrets.POETRY_PYPI_TOKEN_PYPI }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python ignores 2 | *.py[o|c] 3 | *.coverage 4 | .cache 5 | 6 | # UNIX ignores 7 | *~ 8 | 9 | # Project artifacts 10 | dist 11 | *.egg-info 12 | build 13 | reports 14 | _build 15 | .tox 16 | .eggs 17 | .nox 18 | 19 | # IDE cruft 20 | .idea 21 | .ropeproject 22 | .vscode 23 | .pytest_cache 24 | .python-version 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.2.1 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | # Run the formatter. 9 | - id: ruff-format 10 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | Update: insecure 2 | Search: True 3 | Pin: False 4 | Default Branch: development 5 | Branch pre-fix: pyup- 6 | Close PRs: True 7 | Label PRs: False 8 | Update hashes: True 9 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: . 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | 4 | python: 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "3.8-dev" 9 | - "3.9-dev" 10 | - "nightly" 11 | - "pypy3" 12 | 13 | matrix: 14 | fast_finish: true 15 | allow_failures: 16 | - python: "3.8-dev" 17 | - python: "3.9-dev" 18 | - python: "nightly" 19 | - python: "pypy3" 20 | 21 | env: 22 | global: 23 | - CC_TEST_REPORTER_ID=767be575271677c6435197b16f142b6ded3332f4f5112ed30fcc760d02a7d762 24 | 25 | install: 26 | - travis_retry pip install bandit coverage pylint poetry 27 | 28 | before_script: 29 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 30 | - chmod +x ./cc-test-reporter 31 | - ./cc-test-reporter before-build 32 | 33 | script: 34 | - pylint pyapp --disable=import-error 35 | - bandit -ll -r pyapp 36 | - poetry install 37 | - poetry run pytest 38 | 39 | after_script: 40 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT -t coverage.py 41 | 42 | after_success: 43 | - bash <(curl -s https://codecov.io/bash) 44 | 45 | deploy: 46 | provider: pypi 47 | user: "timsavage" 48 | password: 49 | secure: "" 50 | distributions: "sdist bdist_wheel" 51 | skip_existing: true 52 | on: 53 | branch: master 54 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Tim Savage 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | #################################### 2 | Contributor Covenant Code of Conduct 3 | #################################### 4 | 5 | Our Pledge 6 | ========== 7 | 8 | In the interest of fostering an open and welcoming environment, we as 9 | contributors and maintainers pledge to making participation in our project and 10 | our community a harassment-free experience for everyone, regardless of age, body 11 | size, disability, ethnicity, sex/genders, gender identity and expression, 12 | level of experience, education, socio-economic status, nationality, personal 13 | appearance, race, religion. 14 | 15 | Our Standards 16 | ============= 17 | 18 | Examples of behavior that contributes to creating a positive environment 19 | include: 20 | 21 | * Using welcoming and inclusive language 22 | * Being respectful of differing viewpoints and experiences 23 | * Gracefully accepting constructive criticism 24 | * Focusing on what is best for the community 25 | * Showing empathy towards other community members 26 | 27 | 28 | Examples of unacceptable behavior by participants include: 29 | 30 | * The use of sexualized language or imagery and unwelcome sexual attention or 31 | advances 32 | * Trolling, insulting/derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others’ private information, such as a physical or electronic 35 | address, without explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | 40 | Our Responsibilities 41 | ==================== 42 | 43 | Project maintainers are responsible for clarifying the standards of acceptable 44 | behavior and are expected to take appropriate and fair corrective action in 45 | response to any instances of unacceptable behavior. 46 | 47 | Project maintainers have the right and responsibility to remove, edit, or 48 | reject comments, commits, code, wiki edits, issues, and other contributions 49 | that are not aligned to this Code of Conduct, or to ban temporarily or 50 | permanently any contributor for other behaviors that they deem inappropriate, 51 | threatening, offensive, or harmful. 52 | 53 | Scope 54 | ===== 55 | 56 | This Code of Conduct applies within all project spaces, and it also applies when 57 | an individual is representing the project or its community in public spaces. 58 | Examples of representing a project or community include using an official 59 | project e-mail address, posting via an official social media account, or acting 60 | as an appointed representative at an online or offline event. Representation of 61 | a project may be further defined and clarified by project maintainers. 62 | 63 | Enforcement 64 | =========== 65 | 66 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 67 | reported by contacting the project team at team@pyapp.info. All complaints will 68 | be reviewed and investigated and will result in a response that is deemed 69 | necessary and appropriate to the circumstances. The project team is obligated 70 | to maintain confidentiality with regard to the reporter of an incident. 71 | Further details of specific enforcement policies may be posted separately. 72 | 73 | Project maintainers who do not follow or enforce the Code of Conduct in good 74 | faith may face temporary or permanent repercussions as determined by other 75 | members of the project’s leadership. 76 | 77 | Attribution 78 | =========== 79 | 80 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 81 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 82 | 83 | For answers to common questions about this code of conduct, see 84 | https://www.contributor-covenant.org/faq 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2019, Tim Savage 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, this 13 | list of conditions and the following disclaimer in the documentation and/or 14 | other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its contributors may 17 | be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 23 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 24 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 25 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 27 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 28 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 29 | OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /docs/change-history.rst: -------------------------------------------------------------------------------- 1 | ############## 2 | Change History 3 | ############## 4 | 5 | .. include:: ../HISTORY 6 | -------------------------------------------------------------------------------- /docs/developers.rst: -------------------------------------------------------------------------------- 1 | Contribution Guide 2 | ================== 3 | 4 | We welcome contributions to the pyApp project (and sub projects). 5 | 6 | To get our PR accepted quickly please ensure the following requirements are 7 | met: 8 | 9 | - Install the `pre-commit `_ hooks to 10 | ensure you code is formatted by `black `_. 11 | 12 | - Ensure your code has unit test coverage (using pyTest). Unittests should be 13 | designed to be as fast as possible. 14 | 15 | - Ensure your code passes the pyLint checks (this is part of the automated build). 16 | 17 | - Update the docs with the details if required. 18 | 19 | - The API matters, ensure any features provide a nice API for both developers and 20 | end-users. 21 | 22 | The core pyApp package is intended to be light and mainly made up of plumbing 23 | code. If you want to add support for a particular service or server an extension 24 | is the way to do this. 25 | 26 | See the *Developing an Extension* section of the extensions doc for guidance on 27 | building your own extension. 28 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | ############### 2 | Getting Started 3 | ############### 4 | 5 | In this section we will run through the processes of building an application with 6 | ``pyApp``. 7 | 8 | Application Layout 9 | ================== 10 | 11 | We'll start with a simple application that accepts input from the command line 12 | and combines it with a value from configuration. 13 | 14 | The default ``pyApp`` configuration makes a number of assumptions as to how your 15 | application is laid out, with each module within your application package 16 | fulfilling a specific purpose. The easiest way to setup a basic app is to use 17 | the ``pyApp`` Cookiecutter to generate an application. 18 | 19 | Install cookie-cutter from pip and use the template to start your initial 20 | project:: 21 | 22 | > pip install cookiecutter 23 | > cookiecutter gh:pyapp-org/pyapp.cookiecutter 24 | 25 | 26 | Project Structure 27 | ----------------- 28 | 29 | The basic structure of a ``pyApp`` application package consists of the following:: 30 | 31 | 📁 myapp 32 | ├📄 __init__.py Python package initialisation 33 | ├📄 __main__.py Python main entry point 34 | ├📄 cli.py The CLI (referenced from __main__) 35 | ├📄 default_settings.py Definition of default runtime configuration 36 | └📄 checks.py Application specific checks 37 | 38 | 39 | ``__init__.py`` 40 | --------------- 41 | 42 | The presence of an ``__init__.py`` file in a folder makes Python use the folder 43 | as a package and contains initialisation code for the package. ``pyApp`` also 44 | uses this file to find information for the application. 45 | 46 | ``__version__`` 47 | The version number of the application, it is recommended that this be a 48 | `semantic version`_. ``pyApp`` also provides tools for this to be fetched from 49 | the installed package list. 50 | 51 | .. _semantic version: https://semver.org/ 52 | 53 | 54 | ``__main__.py`` 55 | --------------- 56 | 57 | The main entry point for a Python application when referring to a package using 58 | ``python -m myapp``. The default cookiecutter application triggers the main 59 | function in the *cli* module. 60 | 61 | ``cli`` 62 | ------- 63 | 64 | This is where the ``pyApp`` ``CliApplication`` instance is defined. The 65 | ``CliApplication`` class handles initialisation of application services before 66 | handing over execution to the command handler of the command requested by the 67 | CLI. 68 | 69 | 70 | Setting up the CLI 71 | ~~~~~~~~~~~~~~~~~~ 72 | 73 | Adding a CLI consists of two steps, defining a ``CliApplication`` instance and 74 | using the ``CliApplication.command`` decorator to functions to add commands to 75 | your application. 76 | 77 | First create a ``CliApplication`` instance: 78 | 79 | .. code-block:: python 80 | 81 | from pyapp.app import CliApplication 82 | 83 | # Define our application instance 84 | app = CliApplication( 85 | description="My Application" 86 | ) 87 | 88 | # Define our main entry point 89 | main = app.dispatch 90 | 91 | ``CliApplication`` has many options to customise how it works, these are all 92 | provided with defaults but can be customised to change the behaviour, see the 93 | API documentation for more information on other options. 94 | 95 | Next CLI commands are created by applying the ``CliApplication.command`` argument 96 | to a python function. 97 | 98 | .. code-block:: python 99 | 100 | @app.command 101 | def greeting(name: str, *, greeting: str = "Hello"): 102 | """ 103 | Provide a greeting 104 | """ 105 | print(f"{greeting} {name}") 106 | 107 | This example adds the `greeting` command to the CLI that requires a name and 108 | allows for an optional greeting to be provided. It can be called using:: 109 | 110 | > my_app greeting Guido --greeting Hallo 111 | Hallo Guido 112 | 113 | 114 | Application Settings 115 | ~~~~~~~~~~~~~~~~~~~~ 116 | 117 | All of your applications settings are defined in the ``default_settings.py`` 118 | file that is located in your applications main package. These settings are 119 | loaded by the CliApplication on startup and combine with settings from pyApp 120 | extensions as well as runtime settings. 121 | 122 | The default settings file is a normal python module allows for complex 123 | behaviours to be defined. Any variable that consists of all upper-case 124 | characters (including `_`) is considered a setting and is imported into the 125 | settings container. 126 | 127 | Once an application has been initialised the final set of runtime settings is 128 | available from the settings container ``pyapp.conf.settings``: 129 | 130 | .. code-block:: python 131 | 132 | from pyapp.conf import settings 133 | 134 | print(settings.MY_SETTING) 135 | 136 | 137 | Runtime Checks 138 | ~~~~~~~~~~~~~~ 139 | 140 | These are functions that are called by the checks report to perform a check 141 | against the current settings to assert that the current runtime environment is 142 | correct. This can include: 143 | 144 | - Checking settings are valid 145 | - Confirming connectivity/access to a database 146 | - Checking free space on the filesystem 147 | 148 | These checks can help to diagnose issues, perform basic validation of the 149 | runtime environment and provide application monitoring. 150 | 151 | A check is defined by: 152 | 153 | .. code-block:: python 154 | 155 | from pyapp import checks 156 | 157 | @checks.register 158 | def debug_is_enabled(settings, **_): 159 | if settings.DEBUG: 160 | return checks.Warning("Debug mode is enabled") 161 | 162 | In this example a check returns a warning if DEBUG is ``True`` in settings. 163 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to PyApp's documentation! 2 | ================================= 3 | 4 | A simple python application framework *let pyApp handle the boring stuff!* 5 | 6 | As of pyApp 4.3, Python < 3.8 is no longer supported. 7 | 8 | +---------+------------------------------------------------------------------------------------------------------------+ 9 | | Code | .. image:: https://img.shields.io/badge/GitHub-code-brightgreen | 10 | | | :target: https://github.com/pyapp-org/pyapp | 11 | | | :alt: GitHub | 12 | | | .. image:: https://readthedocs.org/projects/pyapp/badge/?version=latest | 13 | | | :target: https://docs.pyapp.info/ | 14 | | | :alt: ReadTheDocs | 15 | +---------+------------------------------------------------------------------------------------------------------------+ 16 | | Quality | .. image:: https://sonarcloud.io/api/project_badges/measure?project=pyapp-org_pyapp&metric=sqale_rating | 17 | | | :target: https://sonarcloud.io/dashboard?id=pyapp-org_pyapp | 18 | | | :alt: Maintainability | 19 | | | .. image:: https://sonarcloud.io/api/project_badges/measure?project=pyapp-org_pyapp&metric=security_rating | 20 | | | :target: https://sonarcloud.io/project/security_hotspots | 21 | | | :alt: Security | 22 | | | .. image:: https://sonarcloud.io/api/project_badges/measure?project=pyapp-org_pyapp&metric=coverage | 23 | | | :target: https://sonarcloud.io/code?id=pyapp-org_pyapp | 24 | | | :alt: Test Coverage | 25 | | | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg | 26 | | | :target: https://github.com/ambv/black | 27 | | | :alt: Once you go Black... | 28 | +---------+------------------------------------------------------------------------------------------------------------+ 29 | | Package | .. image:: https://img.shields.io/pypi/v/pyapp | 30 | | | :target: https://pypi.io/pypi/pyapp/ | 31 | | | :alt: Latest Version | 32 | | | .. image:: https://img.shields.io/pypi/pyversions/pyapp | 33 | | | :target: https://pypi.io/pypi/pyapp/ | 34 | | | .. image:: https://img.shields.io/pypi/l/pyapp | 35 | | | :target: https://pypi.io/pypi/pyapp/ | 36 | | | .. image:: https://img.shields.io/pypi/wheel/pyapp | 37 | | | :alt: PyPI - Wheel | 38 | | | :target: https://pypi.io/pypi/pyapp/ | 39 | +---------+------------------------------------------------------------------------------------------------------------+ 40 | 41 | pyApp takes care of the application framework code, managing settings and much 42 | more so you can focus on your business logic. 43 | 44 | 45 | Features 46 | ======== 47 | 48 | * Configuration - Loading, merging your settings from different sources 49 | 50 | * Python modules 51 | * File and HTTP(S) endpoints for JSON and YAML files. 52 | 53 | * Instance Factories - Configuration of plugins, database connections, or just 54 | implementations of an ``ABC``. Leveraging settings to make setup of your 55 | application easy and reduce coupling. 56 | 57 | * Dependency Injection - Easy to use dependency injection without complicated setup. 58 | 59 | * Feature Flags - Simple methods to enable and disable features in your application 60 | at runtime. 61 | 62 | * Checks - A framework for checking settings are correct and environment is 63 | operating correctly (your ops team will love you)? 64 | 65 | * Extensions - Extend the basic framework with extensions. Provides deterministic 66 | startup, extension of the CLI and the ability to register checks and extension 67 | specific default settings. 68 | 69 | * Application - Provides a extensible and simple CLI interface for running 70 | commands (including async), comes with built-in commands to execute check, setting 71 | and extension reports. 72 | 73 | * Logging - Initialise and apply sane logging defaults. 74 | 75 | * Highly tested and ready for production use. 76 | 77 | 78 | Installation 79 | ============ 80 | 81 | Installation with pip:: 82 | 83 | pip install pyapp 84 | 85 | 86 | Table of Contents 87 | ================= 88 | 89 | .. toctree:: 90 | :maxdepth: 2 91 | 92 | getting-started 93 | sys-admin-playbook 94 | recipes/index 95 | reference/index 96 | extensions 97 | developers 98 | change-history 99 | 100 | Indices and tables 101 | ================== 102 | 103 | * :ref:`genindex` 104 | * :ref:`modindex` 105 | * :ref:`search` 106 | -------------------------------------------------------------------------------- /docs/recipes/async-commands.rst: -------------------------------------------------------------------------------- 1 | Using Async Commands 2 | ==================== 3 | 4 | As of pyApp 4.3 command handlers can be defined as Async functions and pyApp will 5 | take care of setting up an event loop call the handler. 6 | 7 | .. code-block:: python 8 | 9 | app = CliApplication() 10 | 11 | @app.command 12 | async def my_command(args): 13 | pass 14 | 15 | 16 | This feature is also available to the default handler. 17 | -------------------------------------------------------------------------------- /docs/recipes/index.rst: -------------------------------------------------------------------------------- 1 | Recipes 2 | ======= 3 | 4 | Recipes are code snippets that show how to use the library in specific use cases. 5 | 6 | .. tip:: Found a good use case that is not covered by the recipes? Suggest a new 7 | recipy or request one by opening an issue on the `pyApp issue tracker`_. 8 | 9 | .. _pyApp issue tracker: https://github.com/pyapp-org/pyapp/issues/new 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | async-commands 15 | single-script 16 | integration-with-django 17 | -------------------------------------------------------------------------------- /docs/recipes/integration-with-django.rst: -------------------------------------------------------------------------------- 1 | Integration with Django 2 | ======================= 3 | 4 | PyApp factories and injection can be used in Django projects by initialising the 5 | pyApp settings object. As this system is largely inspired by the Django config 6 | integration is fairly straight forward. 7 | 8 | The safest time to mirror the configuration is after the ready event is raised by 9 | Django. There are two options here: 10 | 11 | 1. Create a new Django App 12 | 2. Use an appropriate existing app. 13 | 14 | Add to the ``ready`` method of your *App* class: 15 | 16 | .. code-block:: python 17 | 18 | class MyApp: 19 | def ready(self): 20 | # Merge Django config into pyApp config. 21 | from django.conf import settings as django_settings 22 | from pyapp.conf import settings as pyapp_settings 23 | from pyapp.conf.loaders import ObjectLoader 24 | pyapp_settings.load(ObjectLoader(django_settings)) 25 | 26 | ``ObjectLoader`` is a special loader that reads configuration variable from an 27 | existing object; in this case the Django settings object. 28 | -------------------------------------------------------------------------------- /docs/recipes/single-script.rst: -------------------------------------------------------------------------------- 1 | Single Script 2 | ============= 3 | 4 | As of pyApp 4.4 the CLI application object can be used with a single file (module) 5 | script. This allows quick scripts to make use pyApp functionality and extensions. 6 | 7 | Example: 8 | 9 | .. code-block:: python 10 | 11 | from pyapp.app import CliApplication 12 | 13 | app = CliApplication() 14 | 15 | @app.command 16 | def adduser(username: str, *, email: str = None, is_admin: bool = False): 17 | """Add a user""" 18 | ... 19 | 20 | if __name__ == "__main__": 21 | app.dispatch() 22 | -------------------------------------------------------------------------------- /docs/reference/app.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.app 2 | -------------------------------------------------------------------------------- /docs/reference/checks.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.checks 2 | -------------------------------------------------------------------------------- /docs/reference/conf.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.conf 2 | -------------------------------------------------------------------------------- /docs/reference/conf/base-settings.rst: -------------------------------------------------------------------------------- 1 | Base Settings 2 | ============= 3 | 4 | .. autopyapp-settings:: pyapp.conf.base_settings 5 | :noindex: 6 | -------------------------------------------------------------------------------- /docs/reference/conf/helpers.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.conf.helpers 2 | -------------------------------------------------------------------------------- /docs/reference/conf/typed-settings.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.typed_settings 2 | -------------------------------------------------------------------------------- /docs/reference/documentation/sphinx.rst: -------------------------------------------------------------------------------- 1 | #################### 2 | Sphinx Documentation 3 | #################### 4 | 5 | Integration with `Sphinx`_ to enable automatic generation of documentation from 6 | your application. 7 | 8 | Built on top of the `sphinx.ext.autodoc` extension. 9 | 10 | This extension is used to generate the `base-settings <../conf/base-settings.html>`_ documentation for pyApp. 11 | 12 | .. _Sphinx: http://sphinx-doc.org/ 13 | 14 | Installation 15 | ============ 16 | 17 | To install the Sphinx extension, add: 18 | 19 | Add ``sphinx.ext.autodoc`` and ``pyapp.documentation.sphinx`` to the ``extensions`` 20 | list in your Sphinx `conf.py` file. 21 | 22 | .. code-block:: python 23 | 24 | # Add any Sphinx extension module names here, as strings. They can be 25 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 26 | # ones. 27 | extensions = [ 28 | "sphinx.ext.autodoc", 29 | "pyapp.documentation.sphinx", 30 | ] 31 | 32 | 33 | Documenting Settings 34 | ==================== 35 | 36 | **\.. autopyapp-settings:: name** 37 | 38 | This directive will generate documentation of the settings defined in the specified module. 39 | 40 | **options** 41 | 42 | \:noindex: 43 | If specified, the settings will not be indexed in the table of contents. 44 | 45 | \:grouped: 46 | If specified, the settings keys will be grouped by :py:class:`pyapp.typed_settings.SettingsDef`. 47 | 48 | \:sorted: 49 | If specified, the settings keys will be sorted. 50 | 51 | Example: 52 | 53 | .. code-block:: 54 | 55 | .. autopyapp-settings:: myapp.default_settings 56 | 57 | -------------------------------------------------------------------------------- /docs/reference/events.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.events 2 | -------------------------------------------------------------------------------- /docs/reference/extensions.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.extensions 2 | -------------------------------------------------------------------------------- /docs/reference/feature-flags.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.feature_flags 2 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | ############### 2 | PyApp Reference 3 | ############### 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | app 11 | checks 12 | conf 13 | conf/typed-settings 14 | conf/helpers 15 | conf/base-settings 16 | events 17 | injection 18 | feature-flags 19 | multiprocessing 20 | extensions 21 | documentation/sphinx 22 | -------------------------------------------------------------------------------- /docs/reference/injection.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.injection 2 | -------------------------------------------------------------------------------- /docs/reference/multiprocessing.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyapp.multiprocessing 2 | -------------------------------------------------------------------------------- /docs/sys-admin-playbook.rst: -------------------------------------------------------------------------------- 1 | System Admin Playbook 2 | ##################### 3 | 4 | While pyApp offers a lot of features to help developers be more productive, it 5 | also provides a suite of tools for Sysadmins to help identify application issues 6 | and simplify operation of an application developed with pyApp. 7 | 8 | .. tip:: 9 | pyApp provides a CLI that provides a ``--help`` option at most locations, this 10 | is a great way to find out more about a command or what commands are available. 11 | 12 | All builtin commands include help using this method. 13 | 14 | 15 | Deployment 16 | ========== 17 | 18 | The recommended approach to deploy a pyApp application is using a virtual environment 19 | (virtualenv or venv) or within a container (eg Docker). This keeps your application 20 | self contained with only the expected dependencies installed. 21 | 22 | pyApp uses automatic extension loading to get the default configuration loaded and 23 | by using a container only the expected extensions will be available and loaded. 24 | 25 | 26 | Output 27 | ------ 28 | 29 | By default pyApp configures logging to be directed to ``stderr`` and any report 30 | output (of builtin tools) to be directed to ``stdout``, this allows for application 31 | output to be piped to other tools or files. 32 | 33 | 34 | Exit Codes 35 | ---------- 36 | 37 | pyApp has a top level exception handler that will put a stacktrace of any unhandled 38 | exceptions into the log before passing the exception onto Pythons standard mechanisms. 39 | 40 | Keyboard Interruptions (Ctrl+C) are caught and will return -2. 41 | 42 | A normal exit will generate the standard 0 exit code. 43 | 44 | 45 | Run-time Settings 46 | ----------------- 47 | 48 | Settings can be supplied at run time to provide environmental specific configuration 49 | or to just change the behaviour of an application for testing. This can be done 50 | via two methods: 51 | 52 | 1. Using the ``--settings`` command line argument eg:: 53 | 54 | > my_application --settings production_settings 55 | 56 | 2. Using an Environment variable. The environment variable defaults to 57 | ``PYAPP_SETTINGS``, however, this can be changed by the development team to 58 | prevent naming clashes, the correct value is always reported when using the 59 | ``--help`` option. 60 | 61 | Both of these options can be used at the same time, environment variables is 62 | particularly useful in a Docker container. 63 | 64 | Runtime settings can be defined in a number of ways: 65 | 66 | - A python module 67 | 68 | - A local JSON or YAML file 69 | 70 | - A remote JSON or YAML file via HTTP(S) 71 | 72 | See the `Conf.Loaders` section of the Reference guide for information on loading 73 | different settings files. 74 | 75 | 76 | Run-Time Checks 77 | --------------- 78 | 79 | pyApp includes a checks framework designed for running environmental checks to 80 | confirm that configuration is valid, services can be accessed (eg Databases, 81 | External Hosts, SSH). This lets the application be verified quickly without 82 | running any business processes. 83 | 84 | To run the checks use:: 85 | 86 | > my_application checks 87 | 88 | # With more detail 89 | 90 | > my_application checks --verbose 91 | 92 | 93 | How do I 94 | ======== 95 | 96 | Common operations that need to performed by an operations team and how to do them. 97 | 98 | How do I change the log level? 99 | ------------------------------ 100 | 101 | The log level can be changed by either: 102 | 103 | 1. Using the ``--log-level`` option on the command line 104 | 105 | 2. Setting the ``PYAPP_LOGLEVEL`` environment variable 106 | 107 | .. note:: 108 | The log-level environment variable can be renamed, use the ``--help`` option 109 | to get the current name. 110 | 111 | 112 | What are the current settings? 113 | ------------------------------ 114 | 115 | What is the applications current configuration and what values are being used:: 116 | 117 | > my_application settings 118 | 119 | This command will list all of the current application settings, the log will 120 | indicate what settings files are being loaded. 121 | 122 | 123 | Where are the settings being loaded from? 124 | ----------------------------------------- 125 | 126 | Using the settings command combined with DEBUG level logging reports not only 127 | what settings file are being loaded, but each individual value from each file:: 128 | 129 | > my_application --log-level DEBUG settings 130 | 131 | 132 | What extensions are being loaded with the application? 133 | ------------------------------------------------------ 134 | 135 | All extensions that are available (without actually loading them) along with 136 | their version can be obtained using the builtin ``extensions`` command:: 137 | 138 | > my_application extensions 139 | 140 | 141 | How do I perform a PIV (Post Implementation Verification) test? 142 | --------------------------------------------------------------- 143 | 144 | The checks framework can provide the basis of a PIV test prior to running any 145 | processes, this ensures the environment and any connectivity problems can be 146 | identified before longer more complex processes are started. 147 | 148 | How do I perform regular monitoring checks? 149 | ------------------------------------------- 150 | 151 | The checks framework can be used to perform regular monitoring of the application 152 | via a cron job. To simplify making use of this data the checks report has a tabular 153 | output that can be processed as a Tab Separated output for easy parsing:: 154 | 155 | > my_application checks --table 156 | 157 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nox 4 | from nox.sessions import Session 5 | 6 | HERE = Path(__file__).parent 7 | 8 | 9 | @nox.session( 10 | python=("python3.10", "python3.11", "python3.12", "python3.13", "pypy3.10"), 11 | venv_backend=None, 12 | ) 13 | def tests(session: Session): 14 | print(f"🪄 Creating poetry environment for {session.python}") 15 | session.run("poetry", "env", "use", session.python, external=True) 16 | 17 | print("📦 Install dependencies...") 18 | session.run("poetry", "install", "--with=dev", external=True) 19 | 20 | print("▶️ Run tests") 21 | session.run("poetry", "run", "pytest", "--config-file=pytest.ini", external=True) 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "pyapp" 7 | version = "4.17rc1" 8 | description = "A Python application framework - Let us handle the boring stuff!" 9 | authors = ["Tim Savage "] 10 | license = "BSD-3-Clause" 11 | readme = "README.rst" 12 | repository = "https://github.com/pyapp-org/pyapp" 13 | documentation = "https://docs.pyapp.info" 14 | keywords = ["framework", "application"] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: BSD License", 19 | "Operating System :: OS Independent", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | "Topic :: Software Development :: Libraries :: Application Frameworks", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | "Programming Language :: Python :: Implementation :: PyPy", 29 | ] 30 | packages = [ 31 | { include = "pyapp", from = "src" }, 32 | { include = "tests", format = "sdist" }, 33 | ] 34 | include = ["HISTORY"] 35 | 36 | [tool.poetry.dependencies] 37 | python = "^3.10" 38 | argcomplete = "^3.2" 39 | colorama = "*" 40 | yarl = "*" 41 | importlib_metadata = {version = "*", python = "<=3.9"} 42 | typing_extensions = "*" 43 | 44 | pyyaml = {version = "*", optional = true } 45 | toml = {version = "*", optional = true } 46 | 47 | [tool.poetry.dev-dependencies] 48 | pytest = "^8.0" 49 | pytest-cov = "^6.0" 50 | pytest-asyncio = "^0.24" 51 | sphinx = "^8.1" 52 | 53 | [tool.poetry.extras] 54 | yaml = ["pyyaml"] 55 | toml = ["toml"] 56 | 57 | [tool.poetry.urls] 58 | "Bug Tracker" = "https://github.com/pyapp-org/pyapp/issues" 59 | 60 | [tool.poetry.plugins."pytest11"] 61 | "pyapp-pytest" = "pyapp.testing.pytest_plugin" 62 | 63 | [tool.ruff] 64 | # Same as Black. 65 | line-length = 88 66 | indent-width = 4 67 | 68 | # Assume Python 3.10 69 | target-version = "py310" 70 | 71 | [tool.ruff.lint] 72 | select = ["N", "F", "I", "UP", "PL", "A", "G", "S", "E", "SIM", "B"] 73 | ignore = [ 74 | "N818", # Exception name should be named with an Error suffix 75 | ] 76 | 77 | [tool.ruff.lint.per-file-ignores] 78 | "tests/**.py" = [ 79 | "S101", # asserts allowed in tests... 80 | "ARG", # Unused function args -> fixtures nevertheless are functionally relevant... 81 | "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() 82 | # The below are debateable 83 | "PLR2004", # Magic value used in comparison, ... 84 | "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes 85 | ] 86 | 87 | [tool.ruff.lint.pycodestyle] 88 | max-line-length = 117 89 | 90 | [tool.ruff.format] 91 | # Like Black, use double quotes for strings. 92 | quote-style = "double" 93 | # Like Black, indent with spaces, rather than tabs. 94 | indent-style = "space" 95 | # Like Black, respect magic trailing commas. 96 | skip-magic-trailing-comma = false 97 | # Like Black, automatically detect the appropriate line ending. 98 | line-ending = "auto" 99 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --cov=pyapp --cov-branch 4 | asyncio_mode=auto 5 | asyncio_default_fixture_loop_scope="function" 6 | -------------------------------------------------------------------------------- /samples/sample-single.py: -------------------------------------------------------------------------------- 1 | """ 2 | Single file sample 3 | """ 4 | from pyapp.app import CliApplication 5 | from pyapp.events import listen_to 6 | 7 | APP = CliApplication() 8 | 9 | 10 | @listen_to(APP.pre_dispatch) 11 | def pre_dispatch(opts): 12 | print(opts) 13 | 14 | 15 | @APP.default 16 | def helper(*, bar: dict): 17 | print(bar) 18 | # print(opts.bar) 19 | 20 | 21 | @listen_to(APP.post_dispatch) 22 | def pre_dispatch(result, opts): 23 | print(result, opts) 24 | 25 | 26 | if __name__ == "__main__": 27 | APP.dispatch() 28 | -------------------------------------------------------------------------------- /samples/sample/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0" 2 | -------------------------------------------------------------------------------- /samples/sample/__main__.py: -------------------------------------------------------------------------------- 1 | import sample.cli 2 | 3 | if __name__ == "__main__": 4 | sample.cli.main() 5 | -------------------------------------------------------------------------------- /samples/sample/alt_checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/samples/sample/alt_checks.py -------------------------------------------------------------------------------- /samples/sample/checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/samples/sample/checks.py -------------------------------------------------------------------------------- /samples/sample/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | CLI Interface 3 | ~~~~~~~~~~~~~ 4 | 5 | """ 6 | from pyapp.app import Arg 7 | from pyapp.app import CliApplication 8 | from pyapp.app.argument_actions import KeyValueAction 9 | from pyapp.conf import settings 10 | 11 | APP = CliApplication(prog="sample", description="Sample pyApp application.") 12 | 13 | 14 | @APP.command 15 | def foo_do(*, verbose: bool = False): 16 | """ 17 | Perform a foo operation. 18 | """ 19 | # Use a command line argument 20 | if verbose: 21 | print("Doing foo verbosely!") 22 | else: 23 | print("Doing foo.") 24 | 25 | # Print a message from the settings file 26 | print(settings.FOO_MESSAGE) 27 | 28 | 29 | class BarGroup: 30 | group = APP.create_command_group("bar", aliases="b") 31 | 32 | @staticmethod 33 | @group.command(name="do", aliases="d") 34 | def do_bar(*, repeat: int = 1, options: dict): 35 | for _ in range(repeat): 36 | print(f"Doing bar with {options}") 37 | 38 | 39 | @APP.command(name="async") 40 | async def async_(): 41 | print("Async task") 42 | 43 | 44 | @APP.default 45 | def default_command(*, bananas: str = None): 46 | print("Bananas", bananas) 47 | 48 | 49 | main = APP.dispatch 50 | -------------------------------------------------------------------------------- /samples/sample/default_settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | here = Path(__file__).parent 4 | 5 | 6 | FOO_MESSAGE = "Message from foo..." 7 | 8 | CHECK_LOCATIONS = ["sample.alt_checks"] 9 | 10 | INCLUDE_SETTINGS = [ 11 | f"file://{here}/more-settings.json", 12 | f"file://{here}/more-settings.yaml", 13 | f"file://{here}/more-settings.toml", 14 | ] 15 | 16 | LOGGING = { 17 | "formatters": { 18 | "default": {"format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s"}, 19 | }, 20 | "handlers": { 21 | "console": { 22 | "class": "logging.StreamHandler", 23 | "formatter": "default", 24 | "stream": "ext://sys.stdout", 25 | }, 26 | "file": { 27 | "class": "logging.FileHandler", 28 | "formatter": "default", 29 | "filename": "out.log", 30 | "level": "ERROR", 31 | }, 32 | }, 33 | "loggers": {"pyapp.conf": {"handlers": ["file"]}}, 34 | "root": { 35 | # 'level' : 'INFO', # Set from command line arg parser. 36 | "handlers": ["console"], 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /samples/sample/events.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Awaitable 3 | from typing import Callable 4 | 5 | from pyapp.events import AsyncEvent 6 | from pyapp.events import Event 7 | from pyapp.events import listen_to 8 | 9 | 10 | class MyClass: 11 | started = Event[Callable[[], None]]() 12 | new_message = AsyncEvent[Callable[[str, "MyClass"], Awaitable]]() 13 | 14 | def start(self): 15 | self.started() 16 | 17 | async def process_message(self, message): 18 | await self.new_message(message, self) 19 | 20 | 21 | class ProxyClass: 22 | new_message = AsyncEvent[Callable[[str, MyClass], Awaitable]]() 23 | 24 | def __init__(self, my_class: MyClass): 25 | my_class.new_message += self.new_message 26 | 27 | 28 | instance = MyClass() 29 | proxy = ProxyClass(instance) 30 | 31 | 32 | def on_started(): 33 | print("Started: 1...") 34 | 35 | 36 | instance.started += on_started 37 | 38 | 39 | @listen_to(instance.started) 40 | def on_another_started(): 41 | print("Started: 2...") 42 | 43 | 44 | async def on_new_message(message: str, source: MyClass): 45 | print(message) 46 | 47 | 48 | instance.new_message += on_new_message 49 | proxy.new_message.add(on_new_message) 50 | 51 | instance.start() 52 | aw = instance.process_message("Hi!") 53 | asyncio.get_event_loop().run_until_complete(aw) 54 | -------------------------------------------------------------------------------- /samples/sample/more-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "BAR_MESSAGE": "Message from bar..." 3 | } -------------------------------------------------------------------------------- /samples/sample/more-settings.toml: -------------------------------------------------------------------------------- 1 | [TOML_MESSAGES] 2 | one = "Message from 1..." 3 | two = "Message from 2..." 4 | -------------------------------------------------------------------------------- /samples/sample/more-settings.yaml: -------------------------------------------------------------------------------- 1 | YAML_MESSAGE: "Message from eek..." 2 | -------------------------------------------------------------------------------- /samples/sample_extension/__init__.py: -------------------------------------------------------------------------------- 1 | __name__ = "Sample Extension" 2 | __version__ = "0.1" 3 | 4 | # Default settings file to use 5 | __default_settings__ = ".default_settings" 6 | 7 | # Checks module 8 | __checks__ = ".checks" 9 | -------------------------------------------------------------------------------- /samples/sample_extension/checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/samples/sample_extension/checks.py -------------------------------------------------------------------------------- /samples/sample_extension/default_settings.py: -------------------------------------------------------------------------------- 1 | # Test extension setting 2 | SAMPLE_EXT_FOO = "Bar" 3 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=pyapp-org_pyapp 2 | sonar.organization=pyapp-org 3 | 4 | # Path to sources 5 | sonar.sources=src 6 | sonar.exclusions=samples/**,docs/** 7 | #sonar.inclusions= 8 | 9 | # Path to tests 10 | sonar.tests=tests 11 | #sonar.test.exclusions= 12 | #sonar.test.inclusions= 13 | 14 | # Source encoding 15 | sonar.sourceEncoding=UTF-8 16 | 17 | sonar.python.version=3.10 18 | -------------------------------------------------------------------------------- /src/pyapp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ###################################### 3 | pyApp - A python application framework 4 | ###################################### 5 | 6 | *Let us handle the boring stuff!* 7 | 8 | """ 9 | 10 | import logging 11 | 12 | from pyapp.versioning import get_installed_version # noqa 13 | 14 | # Configure a default null handler for logging. 15 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 16 | 17 | __author__ = "Tim Savage " 18 | -------------------------------------------------------------------------------- /src/pyapp/app/argument_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Additional "types" to handle common CLI situations. 3 | 4 | .. autoclass:: RegexType 5 | 6 | """ 7 | 8 | import argparse 9 | import re 10 | 11 | from .arguments import ArgumentType 12 | 13 | 14 | class RegexType(ArgumentType): 15 | """ 16 | Factory for validating string options against a regular expression. 17 | 18 | Instances of RegexType are typically passed as type= arguments to the 19 | ArgumentParser add_argument() method or pyApp argument decorator. 20 | 21 | :param regex: Regular expression string (or pre-compiled expression) 22 | :param message: Optional message if validation fails, defaults to a simple 23 | fallback. 24 | 25 | Example of use:: 26 | 27 | @app.command 28 | @argument("--alpha", type=RegexType(r"[a-z]+")) 29 | def my_command(args: Namespace): 30 | print(args.option) 31 | 32 | @app.command 33 | def my_command(*, alpha: RegexType(r"[a-z]+")): 34 | print(alpha) 35 | 36 | From CLI:: 37 | 38 | > my_app m_command --alpha abc 39 | abc 40 | 41 | .. versionadded:: 4.2 42 | 43 | """ 44 | 45 | def __init__(self, regex, message: str = None): 46 | self._re = re.compile(regex) 47 | self._message = message or f"Value does not match {self._re.pattern!r}" 48 | 49 | def __call__(self, string) -> str: 50 | if not self._re.match(string): 51 | raise argparse.ArgumentTypeError(self._message) 52 | return string 53 | -------------------------------------------------------------------------------- /src/pyapp/app/builtin_handlers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=import-outside-toplevel 2 | """ 3 | App Builtin Handlers 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | 6 | Commands that come builtin to the pyApp CLI. 7 | 8 | """ 9 | import sys 10 | from argparse import FileType 11 | from typing import Optional 12 | 13 | from pyapp.app.arguments import CommandGroup, argument 14 | 15 | 16 | def checks(app): 17 | """ 18 | Register the checks handler 19 | """ 20 | 21 | @argument( 22 | "-t", 23 | "--tag", 24 | dest="tags", 25 | action="append", 26 | help_text="Run checks associated with a tag.", 27 | ) 28 | @argument( 29 | "--verbose", dest="verbose", action="store_true", help_text="Verbose output." 30 | ) 31 | @argument( 32 | "--out", 33 | dest="out", 34 | default=sys.stdout, 35 | type=FileType(mode="w"), 36 | help_text="File to output check report to; default is stdout.", 37 | ) 38 | @argument( 39 | "--table", 40 | dest="table", 41 | action="store_true", 42 | help_text="Output report in tabular format.", 43 | ) 44 | @app.command(name="checks", help_text="Run a check report") 45 | def _handler(opts) -> Optional[int]: 46 | from pyapp.checks.report import execute_report 47 | 48 | return ( 49 | 4 50 | if execute_report( 51 | opts.out, 52 | app.application_checks, 53 | opts.checks_message_level, 54 | tags=opts.tags, 55 | verbose=opts.verbose, 56 | no_color=opts.no_color, 57 | table=opts.table, 58 | header=f"Check report for {app.application_summary}", 59 | ) 60 | else None 61 | ) 62 | 63 | 64 | def extensions(app: CommandGroup): 65 | """ 66 | Register extension report handler 67 | """ 68 | 69 | @argument( 70 | "--verbose", dest="verbose", action="store_true", help_text="Verbose output." 71 | ) 72 | @argument( 73 | "--out", 74 | dest="out", 75 | default=sys.stdout, 76 | type=FileType(mode="w"), 77 | help_text="File to output extension report to; default is stdout.", 78 | ) 79 | @app.command(name="extensions") 80 | def _handler(opts) -> Optional[int]: 81 | """ 82 | Report of installed PyApp extensions. 83 | """ 84 | from pyapp.extensions.report import ExtensionReport 85 | 86 | return ExtensionReport(opts.verbose, opts.no_color, opts.out).run() 87 | 88 | 89 | def settings(app: CommandGroup): 90 | """ 91 | Register settings report handler 92 | """ 93 | 94 | @argument( 95 | "--out", 96 | dest="out", 97 | default=sys.stdout, 98 | type=FileType(mode="w"), 99 | help_text="File to output settings report to; default is stdout.", 100 | ) 101 | def _handler(opts) -> Optional[int]: 102 | """ 103 | Report of current settings. 104 | """ 105 | from pyapp.conf.report import SettingsReport 106 | 107 | return SettingsReport(False, opts.no_color, opts.out).run() 108 | 109 | app.command(_handler, name="settings") 110 | -------------------------------------------------------------------------------- /src/pyapp/app/init_logger.py: -------------------------------------------------------------------------------- 1 | """Logger used in the initial setup.""" 2 | 3 | import logging 4 | from typing import List 5 | 6 | 7 | class InitHandler(logging.Handler): 8 | """Handler that provides initial logging and captures logging up to a certain 9 | level, it is then replayed once logging has been initialised. 10 | """ 11 | 12 | def __init__(self, handler: logging.Handler, pass_through_level=logging.WARNING): 13 | super().__init__(logging.DEBUG) 14 | self.handler = handler 15 | self.pass_through_level = pass_through_level 16 | self._store: List[logging.LogRecord] = [] 17 | 18 | def handle(self, record: logging.LogRecord) -> None: 19 | """Handle record""" 20 | self._store.append(record) 21 | if record.levelno >= self.pass_through_level: 22 | super().handle(record) 23 | 24 | def replay(self): 25 | """Replay stored log records""" 26 | 27 | for record in self._store: 28 | logger = logging.getLogger(record.name) 29 | if logger.isEnabledFor(record.levelno): 30 | logger.handle(record) 31 | self._store.clear() 32 | 33 | def emit(self, record: logging.LogRecord) -> None: 34 | """Emit the record""" 35 | 36 | # Pass to initial handler 37 | self.handler.emit(record) 38 | -------------------------------------------------------------------------------- /src/pyapp/app/logging_formatter.py: -------------------------------------------------------------------------------- 1 | """ 2 | App Logger Formatter 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | Custom formatter for logging messages. 6 | 7 | """ 8 | 9 | import logging 10 | 11 | import colorama 12 | 13 | RESET_ALL = colorama.Style.RESET_ALL 14 | 15 | 16 | class ColourFormatter(logging.Formatter): 17 | """ 18 | Formatter that adds colourised versions of log levels 19 | 20 | Extends LogRecord with: 21 | 22 | %(clevelno)s Numeric logging level for the message (DEBUG, INFO, 23 | WARNING, ERROR, CRITICAL) with ANSI terminal colours 24 | applied. 25 | %(clevelname)s Text logging level for the message ("DEBUG", "INFO", 26 | "WARNING", "ERROR", "CRITICAL") with ANSI terminal 27 | colours applied. 28 | """ 29 | 30 | COLOURS = { 31 | logging.CRITICAL: colorama.Fore.RED + colorama.Style.BRIGHT, 32 | logging.ERROR: colorama.Fore.RED, 33 | logging.WARNING: colorama.Fore.BLUE, 34 | logging.INFO: colorama.Fore.GREEN, 35 | logging.DEBUG: colorama.Fore.LIGHTBLACK_EX, 36 | logging.NOTSET: colorama.Fore.WHITE, 37 | } 38 | 39 | def formatMessage(self, record: logging.LogRecord): # noqa: N802 40 | color = self.COLOURS[record.levelno] 41 | record.clevelname = f"{color}{record.levelname}{RESET_ALL}" 42 | record.clevelno = f"{color}{record.levelno}{RESET_ALL}" 43 | return super().formatMessage(record) 44 | 45 | 46 | # For "English" variants =oP 47 | ColorFormatter = ColourFormatter 48 | -------------------------------------------------------------------------------- /src/pyapp/checks/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Checks 3 | ~~~~~~ 4 | 5 | *Provides an interface and reports of simple pre-flight sanity checks for* 6 | *your application.* 7 | 8 | Checks are executed via the CLI with the `checks` command. This will run all checks 9 | and report the outcome. This is an invaluable tool for operations teams, checks can 10 | be used to diagnose connectivity issues, for daily status checks or for PIV (Post 11 | Implementation Verification) checking. 12 | 13 | Checks are simply functions that accept a `settings` instance and optionally return 14 | one or many message objects. Message objects come as Debug, Info, Warn, Error and 15 | Critical varieties corresponding to the associated logging status definitions. 16 | 17 | In addition to providing a basic description of why a check failed, messages can 18 | also provide hints on how to correct an issue identified by a check. 19 | 20 | An example check method:: 21 | 22 | from pyapp.checks import register, Warning 23 | 24 | @register(Tags.security) 25 | def debug_enabled(settings, **_): 26 | ''' 27 | Raise a warning if the debug setting is enabled. 28 | ''' 29 | if settings.DEBUG: 30 | return Warning( 31 | msg="Debug mode is enabled", 32 | hint="Set DEBUG = False in your settings file" 33 | ) 34 | 35 | A good example of using checks is to ensure services defined in your settings file 36 | are enabled and are accessible. 37 | 38 | """ 39 | 40 | from . import built_in # noqa 41 | from .messages import * # noqa 42 | from .registry import Tags # noqa 43 | from .registry import register # noqa 44 | -------------------------------------------------------------------------------- /src/pyapp/checks/built_in.py: -------------------------------------------------------------------------------- 1 | """ 2 | Builtin Checks 3 | ~~~~~~~~~~~~~~ 4 | 5 | """ 6 | from .messages import Warn 7 | from .registry import Tags, register 8 | 9 | W001 = Warn( 10 | "You should not have DEBUG set to True in deployment.", 11 | hint="Ensure DEBUG is set to False for deployment in settings.", 12 | ) 13 | 14 | 15 | @register(Tags.security) 16 | def debug_enabled(settings, **_): 17 | """ 18 | Raise a warning if the debug setting is enabled. 19 | """ 20 | if settings.DEBUG: 21 | return W001 22 | return None 23 | -------------------------------------------------------------------------------- /src/pyapp/checks/messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Messages 3 | """ 4 | 5 | from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, getLevelName 6 | from traceback import format_exc 7 | from typing import Any 8 | 9 | __all__ = ( 10 | "CheckMessage", 11 | "DEBUG", 12 | "INFO", 13 | "WARNING", 14 | "ERROR", 15 | "CRITICAL", 16 | "Debug", 17 | "Info", 18 | "Warn", 19 | "Error", 20 | "Critical", 21 | "UnhandledException", 22 | ) 23 | 24 | 25 | class CheckMessage: 26 | """ 27 | Check message base class 28 | """ 29 | 30 | __slots__ = ("level", "msg", "hint", "obj") 31 | 32 | def __init__( 33 | self, 34 | level: int, 35 | msg: str, 36 | hint: str = None, 37 | obj: Any = None, 38 | ): 39 | """ 40 | Messages returned from check functions. 41 | 42 | :param level: Importance level of message (based on logging levels) 43 | :param msg: Description of issue identified by check. Note that this 44 | message will be word wrapped to 80 characters. 45 | :param hint: A hint on how to fix the issue (this can be either a 46 | single string or a list of strings that make up individual 47 | paragraphs. Note that any messages are word wrapped to 80 chars 48 | for display. 49 | :param obj: An object this message relates to (useful in the case of 50 | multiple database connections for example). 51 | 52 | """ 53 | self.level = level 54 | self.msg = msg 55 | self.obj = obj 56 | self.hint = hint 57 | 58 | def __eq__(self, other): 59 | if isinstance(other, self.__class__): 60 | return all( 61 | getattr(self, attr) == getattr(other, attr) 62 | for attr in ("level", "msg", "hint", "obj") 63 | ) 64 | return NotImplemented 65 | 66 | def __ne__(self, other): 67 | return not self == other 68 | 69 | def __str__(self) -> str: 70 | obj = "?" if self.obj is None else str(self.obj) 71 | hint = f"\n\tHINT: {self.hint}" if self.hint else "" 72 | return f"{obj}: {self.msg}{hint}" 73 | 74 | def __repr__(self): 75 | return ( 76 | f"{self.__class__.__name__}(" 77 | f"msg={self.msg!r}, " 78 | f"hint={self.hint!r}, " 79 | f"obj={self.obj!r})" 80 | ) 81 | 82 | @property 83 | def level_name(self) -> str: 84 | """ 85 | Level as a string. 86 | """ 87 | return getLevelName(self.level) 88 | 89 | def is_serious(self, level=ERROR): 90 | """ 91 | Is this message a serious message? 92 | """ 93 | return self.level >= level 94 | 95 | 96 | class Debug(CheckMessage): 97 | """ 98 | Debug check message 99 | """ 100 | 101 | __slots__ = () 102 | 103 | def __init__( 104 | self, 105 | msg: str, 106 | hint: str = None, 107 | obj: Any = None, 108 | ): 109 | super().__init__(DEBUG, msg, hint, obj) 110 | 111 | 112 | class Info(CheckMessage): 113 | """ 114 | Info check message 115 | """ 116 | 117 | __slots__ = () 118 | 119 | def __init__( 120 | self, 121 | msg: str, 122 | hint: str = None, 123 | obj: Any = None, 124 | ): 125 | super().__init__(INFO, msg, hint, obj) 126 | 127 | 128 | class Warn(CheckMessage): 129 | """ 130 | Warning check message 131 | """ 132 | 133 | __slots__ = () 134 | 135 | def __init__( 136 | self, 137 | msg: str, 138 | hint: str = None, 139 | obj: Any = None, 140 | ): 141 | super().__init__(WARNING, msg, hint, obj) 142 | 143 | 144 | class Error(CheckMessage): 145 | """ 146 | Error check message 147 | """ 148 | 149 | __slots__ = () 150 | 151 | def __init__( 152 | self, 153 | msg: str, 154 | hint: str = None, 155 | obj: Any = None, 156 | ): 157 | super().__init__(ERROR, msg, hint, obj) 158 | 159 | 160 | class Critical(CheckMessage): 161 | """ 162 | Critical check message 163 | """ 164 | 165 | __slots__ = () 166 | 167 | def __init__( 168 | self, 169 | msg: str, 170 | hint: str = None, 171 | obj: Any = None, 172 | ): 173 | super().__init__(CRITICAL, msg, hint, obj) 174 | 175 | 176 | class UnhandledException(CheckMessage): 177 | """ 178 | Special case of error message for unhandled exceptions 179 | """ 180 | 181 | def __init__( 182 | self, 183 | msg: str = None, 184 | hint: str = None, 185 | obj: Any = None, 186 | ): 187 | super().__init__(ERROR, msg or "Unhandled Exception", hint or format_exc(), obj) 188 | -------------------------------------------------------------------------------- /src/pyapp/checks/registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Checks Registry 3 | ~~~~~~~~~~~~~~~ 4 | 5 | Location for registering and listing checks. 6 | 7 | """ 8 | 9 | from itertools import chain 10 | from typing import Callable, Iterable, List, NamedTuple, Sequence, TypeVar, Union 11 | 12 | from pyapp import extensions 13 | from pyapp.checks.messages import CheckMessage, UnhandledException 14 | from pyapp.conf import Settings, settings 15 | 16 | 17 | class Tags: 18 | """Built-in tags used by pyApp.""" 19 | 20 | security = "security" 21 | 22 | 23 | Check = Callable[[Settings], Union[CheckMessage, Sequence[CheckMessage]]] 24 | _C = TypeVar("_C", bound=Callable[[Check], Check]) 25 | 26 | 27 | class CheckResult(NamedTuple): 28 | """Result of a check execution.""" 29 | 30 | check: Check 31 | messages: Sequence[CheckMessage] 32 | 33 | 34 | class CheckRegistry(List[Check]): 35 | """Registry list for checks.""" 36 | 37 | def register( 38 | self, 39 | check: Union[Check, str] = None, 40 | *tags: str, 41 | ) -> Union[_C, Callable[[_C], _C]]: 42 | """Can be used as a function or a decorator. Register given function 43 | `func` labeled with given `tags`. The function should receive **kwargs 44 | and return a list of Messages. 45 | 46 | Calling this method a second time allows for additional tags to be added. 47 | 48 | """ 49 | 50 | def inner(func): 51 | func._check__tags = tags 52 | if func not in self: 53 | self.append(func) 54 | return func 55 | 56 | if callable(check) or hasattr(check, "checks"): 57 | return inner(check) 58 | 59 | if check: 60 | tags += (check,) 61 | return inner 62 | 63 | def checks_by_tags(self, tags: Iterable[str] = None): 64 | """Return an iterator of checks that relate to a specific tag (or tags)""" 65 | if tags: 66 | tags = set(tags) 67 | return ( 68 | check 69 | for check in self 70 | if set(getattr(check, "_check__tags", [])) & tags 71 | ) 72 | return iter(self) 73 | 74 | def run_checks_iter(self, tags: Iterable[str] = None, pre_callback=None): 75 | """Iterate through all registered checks and run each to return messages. 76 | 77 | :param tags: Iterable of tags to filter checks by. 78 | :param pre_callback: Callback triggered before each check is executed. 79 | 80 | """ 81 | check_kwargs = {"settings": settings} 82 | 83 | for check in self.checks_by_tags(tags): 84 | if pre_callback: 85 | pre_callback(check) 86 | 87 | # Detect attached checks (or a class with checks) 88 | check_func = check.checks if hasattr(check, "checks") else check 89 | try: 90 | messages = check_func(**check_kwargs) 91 | except Exception: # noqa: 92 | messages = UnhandledException("Unhandled Exception") 93 | 94 | if isinstance(messages, CheckMessage): 95 | yield CheckResult(check, (messages,)) 96 | elif messages: 97 | yield CheckResult(check, messages) 98 | else: 99 | yield CheckResult(check, tuple()) 100 | 101 | def run_checks(self, tags: Iterable[str] = None) -> Sequence[CheckMessage]: 102 | """ 103 | Run all registered checks and return Messages. Use tags to filter checks. 104 | 105 | :param tags: Iterable of tags to filter checks by. 106 | 107 | """ 108 | return tuple( 109 | chain.from_iterable(r.messages for r in self.run_checks_iter(tags)) 110 | ) 111 | 112 | 113 | # Singleton instance of registry 114 | registry = CheckRegistry() # pylint: disable=invalid-name 115 | register = registry.register # pylint: disable=invalid-name 116 | run_checks = registry.run_checks # pylint: disable=invalid-name 117 | 118 | 119 | def import_checks(): 120 | """ 121 | Import all the modules defined in the setting `CHECK_LOCATIONS` and any checks 122 | defined by extensions. 123 | 124 | By importing the modules this ensures that checks are registered. 125 | """ 126 | for location in settings.CHECK_LOCATIONS: 127 | __import__(location) 128 | 129 | for location in extensions.registry.check_locations: 130 | __import__(location) 131 | -------------------------------------------------------------------------------- /src/pyapp/compatability.py: -------------------------------------------------------------------------------- 1 | """Compatibility between Python Releases and Operating Systems.""" 2 | 3 | __all__ = ("async_run",) 4 | 5 | import os 6 | import sys 7 | from asyncio import run as async_run 8 | 9 | if sys.platform.startswith("win"): 10 | from ctypes import windll 11 | 12 | ROOT_NAME = "Administrator" 13 | 14 | def is_root_user() -> bool: 15 | """This is a root user.""" 16 | return bool(windll.shell32.IsUserAnAdmin()) 17 | 18 | else: 19 | ROOT_NAME = "root" 20 | 21 | def is_root_user() -> bool: 22 | """This is a root user.""" 23 | return bool(os.getuid() == 0) 24 | -------------------------------------------------------------------------------- /src/pyapp/conf/base_settings.py: -------------------------------------------------------------------------------- 1 | """Base settings used to initialise settings object.""" 2 | 3 | from ..typed_settings import SettingsDef 4 | 5 | DEBUG: bool = False 6 | """Enable debug mode""" 7 | 8 | 9 | class LoggingSettings(SettingsDef): 10 | """Settings for logging.""" 11 | 12 | LOG_LOGGERS: dict[str, dict] = {} 13 | """ 14 | Simple method for configuring loggers that is merged into the default 15 | logging configuration. This allows for custom loggers to be configured 16 | without needing to duplicate the entire logging configuration. 17 | 18 | Example:: 19 | 20 | LOG_LOGGERS = { 21 | "my_package.my_module": { 22 | "level": "INFO", 23 | "handlers": ["console"] 24 | } 25 | } 26 | 27 | """ 28 | 29 | LOG_HANDLERS: dict[str, dict] = {} 30 | """ 31 | Simple method for configuring log handlers that is merged into the default 32 | logging configuration. This allows for custom handlers to be configured 33 | without needing to duplicate the entire logging configuration. 34 | 35 | By default all handlers defined in this dict are added to the `root` 36 | handler, if this is not desired set the ``non_root`` argument to ``True``. 37 | 38 | See the `Logging Handlers `_ 39 | in the Python documentation for a complete list of builtin handlers. 40 | 41 | Example:: 42 | 43 | LOG_HANDLERS = { 44 | "file": { 45 | "class": "logging.handlers.RotatingFileHandler", 46 | "stream": "/path/to/my/file.log", 47 | "maxBytes": 5_242_880, # 5MiB 48 | }, 49 | "special_file": { 50 | "class": "logging.FileHandler", 51 | "non_root": True, # Don't assign to root logger 52 | "stream": "/path/to/my/special.log", 53 | } 54 | } 55 | 56 | """ 57 | 58 | LOGGING: dict[str, dict] = {} 59 | """Logging configuration. 60 | 61 | The following configuration is applied by default:: 62 | 63 | LOGGING = { 64 | "formatters": { 65 | "default": { 66 | "format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s", 67 | }, 68 | }, 69 | "handlers": { 70 | "console": { 71 | "class": "logging.StreamHandler", 72 | "formatter": "default", 73 | "stream": "ext://sys.stderr", 74 | }, 75 | }, 76 | "root": { 77 | # "level" : "INFO", # Set from command line arg parser. 78 | "handlers": ["console"], 79 | } 80 | } 81 | 82 | """ 83 | 84 | 85 | ############################################################################### 86 | # Checks 87 | class ChecksSettings(SettingsDef): 88 | """Checks settings.""" 89 | 90 | CHECK_LOCATIONS: list[str] = [] 91 | """Locations to import to ensure checks are registered.""" 92 | 93 | 94 | class FeatureFlagsSettings(SettingsDef): 95 | """Feature flag settings.""" 96 | 97 | FEATURE_FLAGS: dict[str, bool] = {} 98 | """Feature flags definition, this is a simple configuration of:: 99 | 100 | FEATURE_FLAGS = { 101 | "flag-name": True, # State of True, False 102 | } 103 | 104 | """ 105 | 106 | FEATURE_FLAG_PREFIX: str = "PYAPP_FLAG_" 107 | """Prefix applied to flag names for environment variables""" 108 | -------------------------------------------------------------------------------- /src/pyapp/conf/helpers/bases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conf Helper Bases 3 | ~~~~~~~~~~~~~~~~~ 4 | 5 | """ 6 | import abc 7 | import threading 8 | from abc import ABCMeta 9 | from typing import Any, Generic, TypeVar 10 | 11 | 12 | class DefaultCache(dict): 13 | """ 14 | Very similar to :py:class:`collections.defaultdict` (using __missing__) 15 | however passes the specified key to the default factory method. 16 | """ 17 | 18 | __slots__ = ("default_factory",) 19 | 20 | def __init__(self, default_factory=None, **kwargs): 21 | super().__init__(**kwargs) 22 | self.default_factory = default_factory 23 | 24 | def __missing__(self, key: Any): 25 | if not self.default_factory: 26 | raise KeyError(key) 27 | self[key] = value = self.default_factory(key) 28 | return value 29 | 30 | 31 | FT = TypeVar("FT") 32 | 33 | 34 | class FactoryMixin(Generic[FT], metaclass=ABCMeta): 35 | """ 36 | Mixing to provide a factory interface 37 | """ 38 | 39 | __slots__ = () 40 | 41 | @abc.abstractmethod 42 | def create(self, name: str = None) -> FT: 43 | """ 44 | Create an instance based on a named setting. 45 | """ 46 | 47 | 48 | class SingletonFactoryMixin(FactoryMixin[FT], metaclass=ABCMeta): 49 | """ " 50 | Mixin that provides a single named instance. 51 | 52 | This instance factory type is useful for instance types that only require 53 | a single instance eg database connections, web service agents. 54 | 55 | If your instance types are not thread safe it is recommended that the 56 | :py:class:`ThreadLocalSingletonFactoryMixin` is used. 57 | 58 | """ 59 | 60 | __slots__ = ("_instances",) 61 | 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | 65 | self._instances = DefaultCache(self.create) 66 | instances_lock = threading.RLock() 67 | 68 | def create_wrapper(name: str = None) -> FT: 69 | with instances_lock: 70 | return self._instances[name] 71 | 72 | self.create = create_wrapper 73 | 74 | 75 | class ThreadLocalSingletonFactoryMixin(FactoryMixin[FT], metaclass=ABCMeta): 76 | """ 77 | Mixin that provides a single named instance per thread. 78 | 79 | This instance factory type is useful for instance types that only require 80 | a single instance eg database connections, web service agents and that are 81 | not thread safe. 82 | 83 | """ 84 | 85 | __slots__ = ("_instances",) 86 | 87 | def __init__(self, *args, **kwargs): 88 | super().__init__(*args, **kwargs) 89 | 90 | self._instances = threading.local() 91 | create = self.create 92 | 93 | def create_wrapper(name: str = None) -> FT: 94 | try: 95 | cache = self._instances.cache 96 | except AttributeError: 97 | cache = self._instances.cache = DefaultCache(create) 98 | return cache[name] 99 | 100 | self.create = create_wrapper 101 | -------------------------------------------------------------------------------- /src/pyapp/conf/loaders/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conf Loaders 3 | ~~~~~~~~~~~~ 4 | 5 | Loaders are used to load settings from an external source, eg a Python module 6 | (using :py:class:`ModuleLoader`). 7 | 8 | A loader provides key/value pairs to the settings container to merge into the 9 | application settings. 10 | """ 11 | 12 | import importlib 13 | from collections.abc import Iterator 14 | from typing import Any 15 | 16 | from yarl import URL 17 | 18 | from pyapp.conf.loaders.base import Loader 19 | from pyapp.conf.loaders.file_loader import FileLoader 20 | from pyapp.conf.loaders.http_loader import HttpLoader 21 | from pyapp.exceptions import InvalidConfiguration 22 | from pyapp.typed_settings import SettingsDefType 23 | 24 | 25 | def settings_iterator(obj: object): 26 | """Iterate settings from an object""" 27 | 28 | for key in dir(obj): 29 | value = getattr(obj, key) 30 | if isinstance(value, SettingsDefType): 31 | yield from getattr(value, "_settings", ()) 32 | elif key.isupper(): 33 | yield key, value 34 | 35 | 36 | class ModuleLoader(Loader): 37 | """ 38 | Load configuration from an importable module. 39 | 40 | Loader will load all upper case attributes from the imported module. 41 | 42 | Usage: 43 | 44 | >>> loader = ModuleLoader("name.of.module") 45 | >>> settings = dict(loader) 46 | 47 | """ 48 | 49 | scheme = "python" 50 | 51 | @classmethod 52 | def from_url(cls, url: URL) -> Loader: 53 | """Create an instance of :class:`ModuleLoader` from :class:`urllib.parse.ParseResult`.""" 54 | return cls(url.path) 55 | 56 | def __init__(self, module: str): 57 | """:param module: Fully qualify python module path.""" 58 | self.module = module 59 | 60 | def __iter__(self) -> Iterator[tuple[str, Any]]: 61 | try: 62 | mod = importlib.import_module(self.module) 63 | except ImportError as ex: 64 | raise InvalidConfiguration(f"Unable to load module: {self}\n{ex}") from ex 65 | 66 | return settings_iterator(mod) 67 | 68 | def __str__(self): 69 | return f"{self.scheme}:{self.module}" # pylint: disable=no-member 70 | 71 | 72 | class ObjectLoader(Loader): 73 | """ 74 | Load configuration variables from any object. This can be used to mirror 75 | settings from Django settings. 76 | 77 | Loader will only read UPPERCASE attributes from the object. 78 | 79 | Usage: 80 | 81 | >>> from django.conf import settings as django_settings 82 | >>> from pyapp.conf import settings as pyapp_settings 83 | >>> loader = ObjectLoader(django_settings) 84 | >>> pyapp_settings.load(loader) 85 | 86 | .. versionadded:: 4.2 87 | 88 | """ 89 | 90 | @classmethod 91 | def from_url(cls, url: URL) -> "Loader": 92 | raise NotImplementedError("This loader does not support from_url.") 93 | 94 | def __init__(self, obj: object): 95 | self.obj = obj 96 | 97 | def __iter__(self) -> Iterator[tuple[str, Any]]: 98 | return settings_iterator(self.obj) 99 | 100 | 101 | LoaderType = type[Loader] 102 | 103 | 104 | class SettingsLoaderRegistry(dict[str, LoaderType]): 105 | """Registry of settings loaders""" 106 | 107 | def register(self, loader: LoaderType) -> LoaderType: 108 | """Register a new loader, this method can be used as decorator 109 | 110 | :param loader: Loader to register 111 | 112 | """ 113 | loader_schemes = loader.scheme 114 | if isinstance(loader_schemes, str): 115 | loader_schemes = (loader_schemes,) 116 | 117 | for loader_scheme in loader_schemes: 118 | self[loader_scheme] = loader 119 | 120 | return loader 121 | 122 | def factory(self, settings_url: str | URL) -> Loader: 123 | """Factory method that returns a factory suitable for opening the settings uri reference. 124 | 125 | The URI scheme (identifier prior to the first `:`) is used to determine the correct loader. 126 | 127 | :param settings_url: URI that references a settings source. 128 | :return: Loader instance 129 | :raises: ValueError 130 | 131 | """ 132 | url = URL(settings_url) 133 | if not url.scheme: 134 | # If no scheme is defined assume python module 135 | return ModuleLoader.from_url(url) 136 | 137 | try: 138 | return self[url.scheme].from_url(url) 139 | except KeyError: 140 | raise InvalidConfiguration( 141 | f"Unknown scheme `{url.scheme}` in settings URI: {url}" 142 | ) from None 143 | 144 | 145 | # Singleton instance 146 | registry = SettingsLoaderRegistry( # pylint: disable=invalid-name 147 | { 148 | "python": ModuleLoader, 149 | "file": FileLoader, 150 | "http": HttpLoader, 151 | "https": HttpLoader, 152 | } 153 | ) 154 | register = registry.register # pylint: disable=invalid-name 155 | factory = registry.factory # pylint: disable=invalid-name 156 | -------------------------------------------------------------------------------- /src/pyapp/conf/loaders/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base Loader 3 | ~~~~~~~~~~~ 4 | 5 | ABC for loader implementations. 6 | 7 | """ 8 | import abc 9 | from typing import Any, Iterable, Sequence, Tuple, Union 10 | 11 | from yarl import URL 12 | 13 | 14 | class Loader(abc.ABC, Iterable[Tuple[str, Any]]): 15 | """ 16 | ABC class to define the loader interface. 17 | """ 18 | 19 | scheme: Union[str, Sequence[str]] 20 | """ 21 | Scheme that this loader provides handling of. 22 | """ 23 | 24 | def __enter__(self) -> "Loader": 25 | return self 26 | 27 | def __exit__(self, exc_type, exc_val, exc_tb): 28 | self.close() 29 | 30 | @classmethod 31 | @abc.abstractmethod 32 | def from_url(cls, url: URL) -> "Loader": 33 | """ 34 | Create an instance of a `Loader` using the results of the parsed file URL. 35 | """ 36 | 37 | def close(self): 38 | """ 39 | Called by framework when this loader is no longer required. 40 | 41 | This allows any open handles to be closed or caches to be cleared. 42 | """ 43 | -------------------------------------------------------------------------------- /src/pyapp/conf/loaders/content_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Content Type Support 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | Used by File and HTTP loaders to handle both JSON and YAML content. 6 | 7 | """ 8 | 9 | import mimetypes 10 | from collections.abc import Callable, Sequence 11 | from json import load as json_load 12 | from pathlib import Path 13 | from typing import Any, TextIO 14 | 15 | from yarl import URL 16 | 17 | # Supported content types 18 | try: 19 | from yaml import safe_load as yaml_load 20 | except ImportError: # pragma: no cover 21 | yaml_load = None 22 | 23 | try: 24 | from toml import load as toml_load 25 | except ImportError: # pragma: no cover 26 | toml_load = None 27 | 28 | from pyapp.exceptions import UnsupportedContentType 29 | 30 | JSON_MIME_TYPE = "application/json" 31 | TOML_MIME_TYPE = "application/toml" # Ref: https://toml.io/en/v1.0.0#mime-type 32 | YAML_MIME_TYPE = "application/x-yaml" 33 | 34 | # These are content types that are not registered but are in common use. 35 | UNOFFICIAL_CONTENT_TYPES = { 36 | ".toml": TOML_MIME_TYPE, 37 | ".yaml": YAML_MIME_TYPE, 38 | ".yml": YAML_MIME_TYPE, 39 | } 40 | 41 | 42 | def content_type_from_url(url: URL) -> str: 43 | """ 44 | Determine a content type from a parse result. 45 | """ 46 | # Check for an explicit type 47 | file_type = url.query.get("type") 48 | if not file_type: 49 | # Fallback to guessing based off the file name 50 | file_type, _ = mimetypes.guess_type(url.path, strict=False) 51 | if not file_type: 52 | # Try non-official source 53 | extension = Path(url.path).suffix 54 | file_type = UNOFFICIAL_CONTENT_TYPES.get(extension) 55 | 56 | return file_type 57 | 58 | 59 | ContentTypeParser = Callable[[TextIO], dict[str, Any]] 60 | 61 | 62 | class ContentTypeParserRegistry(dict[str, ContentTypeParser]): 63 | """ 64 | Registry of content type parsers. 65 | """ 66 | 67 | def parse_file(self, fp, content_type: str) -> dict[str, Any]: 68 | """ 69 | Parse a file using the specified content type. 70 | 71 | :raises: UnsupportedContentType 72 | 73 | """ 74 | content_parser = self.get(content_type) 75 | if not content_parser: 76 | raise UnsupportedContentType(f"No parser for `{content_type}`") 77 | 78 | return content_parser(fp) 79 | 80 | def register( 81 | self, 82 | content_types: str | Sequence[str], 83 | parser: ContentTypeParser, 84 | ) -> None: 85 | """ 86 | Register a content type parser. 87 | """ 88 | if isinstance(content_types, str): 89 | content_types = (content_types,) 90 | 91 | for content_type in content_types: 92 | self[content_type] = parser 93 | 94 | 95 | registry = ContentTypeParserRegistry( # pylint: disable=invalid-name 96 | {JSON_MIME_TYPE: json_load, TOML_MIME_TYPE: toml_load, YAML_MIME_TYPE: yaml_load} 97 | ) 98 | -------------------------------------------------------------------------------- /src/pyapp/conf/loaders/file_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | File Loader 3 | ~~~~~~~~~~~ 4 | 5 | Loads settings from a file 6 | 7 | """ 8 | 9 | from pathlib import Path 10 | from typing import Union 11 | 12 | from yarl import URL 13 | 14 | from pyapp.conf.loaders.base import Loader 15 | from pyapp.conf.loaders.content_types import content_type_from_url, registry 16 | from pyapp.exceptions import InvalidConfiguration 17 | 18 | 19 | class FileLoader(Loader): 20 | """ 21 | Load settings from a file. 22 | 23 | Usage:: 24 | 25 | >>> loader = FileLoader('/path/to/settings.json') 26 | >>> settings = dict(loader) 27 | 28 | """ 29 | 30 | scheme = "file" 31 | 32 | @classmethod 33 | def from_url(cls, url: URL) -> Loader: 34 | """ 35 | Create an instance of :class:`FileLoader` from :class:`urllib.parse.ParseResult`. 36 | """ 37 | content_type = content_type_from_url(url) 38 | return cls(url.path, content_type) 39 | 40 | def __init__( 41 | self, path: Union[str, Path], content_type: str, *, encoding: str = "UTF8" 42 | ): 43 | """ 44 | :param path: Path to file; can be either absolute or relative to PWD. 45 | :param content_type: Content type of the file 46 | :param encoding: Encoding of the file; defaults to UTF-8 47 | 48 | """ 49 | self.path = Path(path) 50 | self.content_type = content_type 51 | self.encoding = encoding 52 | 53 | def __iter__(self): 54 | try: 55 | with self.path.open(encoding=self.encoding) as fp: 56 | data = registry.parse_file(fp, self.content_type) 57 | 58 | except FileNotFoundError as ex: 59 | raise InvalidConfiguration(f"Settings file not found: {self}\n{ex}") from ex 60 | 61 | except OSError as ex: 62 | raise InvalidConfiguration(f"Unable to load settings: {self}\n{ex}") from ex 63 | 64 | except ValueError as ex: 65 | raise InvalidConfiguration(f"Unable to parse file: {self}\n{ex}") from ex 66 | 67 | # Check we have a valid container object 68 | if not isinstance(data, dict): 69 | raise InvalidConfiguration( 70 | f"Invalid root object, expected an Object: {self}" 71 | ) 72 | 73 | return ((k, v) for k, v in data.items() if k.isupper()) 74 | 75 | def __str__(self): 76 | return f"file://{self.path.as_posix()}?type={self.content_type}" 77 | -------------------------------------------------------------------------------- /src/pyapp/conf/loaders/http_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP Loader 3 | ~~~~~~~~~~~ 4 | 5 | Loads settings from an HTTP endpoint (HTTPS is recommended) 6 | 7 | """ 8 | 9 | import contextlib 10 | import ssl 11 | import tempfile 12 | from typing import TextIO, Tuple 13 | from urllib.error import ContentTooShortError 14 | from urllib.request import urlopen 15 | 16 | from yarl import URL 17 | 18 | from pyapp.conf.loaders.base import Loader 19 | from pyapp.conf.loaders.content_types import content_type_from_url, registry 20 | from pyapp.exceptions import InvalidConfiguration 21 | 22 | 23 | def retrieve_file(url: URL) -> Tuple[TextIO, str]: 24 | """Fetch a file from a URL (handling SSL). 25 | 26 | This is based off `urllib.request.urlretrieve`. 27 | 28 | """ 29 | if url.scheme not in ("http", "https"): 30 | raise InvalidConfiguration("Illegal scheme.") 31 | 32 | context = ( 33 | ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) 34 | if url.scheme == "https" 35 | else None 36 | ) 37 | 38 | with contextlib.closing( 39 | urlopen(url, context=context) # noqa: S310 - Completed above 40 | ) as response: 41 | block_size = 1024 * 8 42 | size = -1 43 | read = 0 44 | 45 | headers = response.info() 46 | if "Content-Length" in headers: 47 | size = int(headers["Content-Length"]) 48 | 49 | content_type = ( 50 | headers["Content-Type"] 51 | if "Content-Type" in headers 52 | else content_type_from_url(url) 53 | ) 54 | 55 | tfp = tempfile.TemporaryFile() 56 | while True: 57 | block = response.read(block_size) 58 | if not block: 59 | break 60 | read += len(block) 61 | tfp.write(block) 62 | 63 | # Seek to start 64 | tfp.seek(0) 65 | 66 | if size >= 0 and read < size: 67 | tfp.close() 68 | 69 | raise ContentTooShortError( 70 | f"retrieval incomplete: got only {read} out of {size} bytes", headers 71 | ) 72 | 73 | return tfp, content_type 74 | 75 | 76 | class HttpLoader(Loader): 77 | """ 78 | Load settings from a file. 79 | 80 | Usage:: 81 | 82 | >>> loader = HttpLoader(URL("https://hostname/path/to/settings.json")) 83 | >>> settings = dict(loader) 84 | 85 | """ 86 | 87 | scheme = ("http", "https") 88 | 89 | @classmethod 90 | def from_url(cls, url: URL) -> Loader: 91 | """Create an instance of :class:`HttpLoader` from :class:`urllib.parse.ParseResult`.""" 92 | return HttpLoader(url) 93 | 94 | def __init__(self, url: URL): 95 | self.url = url 96 | self._fp = None 97 | self.content_type = None 98 | 99 | def __del__(self): 100 | self.close() 101 | 102 | def __iter__(self): 103 | try: 104 | self._fp, self.content_type = retrieve_file(self.url) 105 | except OSError as ex: 106 | raise InvalidConfiguration(f"Unable to load settings: {self}\n{ex}") from ex 107 | 108 | try: 109 | data = registry.parse_file(self._fp, self.content_type) 110 | 111 | except ValueError as ex: 112 | raise InvalidConfiguration( 113 | f"Unable to parse JSON file: {self}\n{ex}" 114 | ) from ex 115 | 116 | # Check we have a valid container object 117 | if not isinstance(data, dict): 118 | raise InvalidConfiguration( 119 | f"Invalid root object, expected a JSON Object: {self}" 120 | ) 121 | 122 | return ((k, v) for k, v in data.items() if k.isupper()) 123 | 124 | def __str__(self): 125 | return str(self.url) 126 | 127 | def close(self): 128 | """ 129 | Ensure the file pointer is closed 130 | """ 131 | if self._fp: 132 | self._fp.close() 133 | self._fp = None 134 | -------------------------------------------------------------------------------- /src/pyapp/conf/report.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conf Report 3 | ~~~~~~~~~~~ 4 | 5 | Generates settings reports 6 | 7 | """ 8 | import pprint 9 | import sys 10 | from typing import TextIO 11 | 12 | from colorama import Fore, Style 13 | 14 | from pyapp import conf 15 | from pyapp.utils import wrap_text 16 | 17 | 18 | class SettingsReport: 19 | """ 20 | Report of all settings in use. 21 | """ 22 | 23 | width = 96 24 | 25 | def __init__( 26 | self, 27 | verbose: bool = False, 28 | no_color: bool = False, 29 | f_out: TextIO = sys.stdout, 30 | settings: str = None, 31 | ): 32 | """ 33 | Initialise check report 34 | 35 | :param verbose: Enable verbose output 36 | :param no_color: Disable colourised output 37 | :param f_out: File to output report to; default is ``stdout`` 38 | :param settings: Settings to produce a report of. 39 | 40 | """ 41 | self.verbose = verbose 42 | self.f_out = f_out 43 | self.no_color = no_color 44 | self.settings = settings or conf.settings 45 | 46 | # Generate templates 47 | if self.no_color: 48 | self.basic_template = "{key:20} : {ppsetting}\n" 49 | 50 | else: 51 | self.basic_template = ( 52 | Fore.YELLOW 53 | + "{key:20} : " 54 | + Fore.CYAN 55 | + "{ppsetting}" 56 | + Style.RESET_ALL 57 | + "\n" 58 | ) 59 | 60 | def output_result(self, key: str, setting): 61 | """ 62 | Output a result to output file. 63 | """ 64 | format_args = dict( 65 | key=key, 66 | setting=setting, 67 | ppsetting=wrap_text( 68 | pprint.pformat(setting, 2), width=self.width, indent=25 69 | ).strip(), 70 | ) 71 | 72 | self.f_out.write(self.basic_template.format(**format_args)) 73 | 74 | def run(self): 75 | """ 76 | Run the report 77 | """ 78 | for key, value in self.settings.items(): 79 | self.output_result(key, value) 80 | -------------------------------------------------------------------------------- /src/pyapp/documentation/__init__.py: -------------------------------------------------------------------------------- 1 | """Documentation utilities.""" 2 | -------------------------------------------------------------------------------- /src/pyapp/documentation/sphinx.py: -------------------------------------------------------------------------------- 1 | """Sphinx plugin to document elements of PyApp.""" 2 | from typing import Any, Final 3 | 4 | from sphinx.application import Sphinx 5 | from sphinx.ext.autodoc import ModuleDocumenter, bool_option 6 | from sphinx.util.typing import OptionSpec 7 | 8 | from .settings import SettingDef, SettingDefGroup, SettingsCollection 9 | 10 | 11 | DOC_SOURCE: Final[str] = "" 12 | 13 | 14 | class SettingsDocumenter(ModuleDocumenter): 15 | """Sphinx autodoc class for documenting settings.""" 16 | 17 | objtype = "pyapp-settings" 18 | directivetype = "module" 19 | 20 | option_spec: OptionSpec = { 21 | "noindex": bool_option, 22 | "grouped": bool_option, 23 | "sorted": bool_option, 24 | } 25 | 26 | @classmethod 27 | def can_document_member( 28 | cls, member: Any, membername: str, isattr: bool, parent: Any 29 | ) -> bool: 30 | """Don't document submodules automatically""" 31 | return False 32 | 33 | def add_block(self, lines: str): 34 | """Add a multi-line block of text to the output.""" 35 | 36 | for line in lines.strip().splitlines(): 37 | self.add_line(line, DOC_SOURCE) 38 | self.add_line("", DOC_SOURCE) 39 | 40 | def document_setting(self, setting: SettingDef): 41 | """Document a setting definition.""" 42 | 43 | self.add_line(f"``{setting.key}``", DOC_SOURCE) 44 | 45 | old_indent = self.indent 46 | self.indent += self._extra_indent 47 | 48 | if setting.type_name is not None: 49 | self.add_line(f"**Type**: :python:`{setting.type_name}`", DOC_SOURCE) 50 | self.add_line("", DOC_SOURCE) 51 | self.add_line(f"**Default**: :python:`{setting.default}`", DOC_SOURCE) 52 | self.add_line("", DOC_SOURCE) 53 | 54 | if setting.doc is not None: 55 | self.add_block(setting.doc) 56 | else: 57 | self.add_line("", DOC_SOURCE) 58 | 59 | self.indent = old_indent 60 | 61 | def document_group_settings(self, group: SettingDefGroup): 62 | """Document a group of settings.""" 63 | 64 | settings = ( 65 | group.sorted_settings 66 | if self.options.get("sorted", False) 67 | else group.settings 68 | ) 69 | for setting in settings: 70 | self.document_setting(setting) 71 | 72 | def document_group(self, group: SettingDefGroup): 73 | """Document a group of settings.""" 74 | 75 | self.add_line(f".. class:: {group.name}", DOC_SOURCE) 76 | self.add_line("", DOC_SOURCE) 77 | 78 | old_indent = self.indent 79 | self.indent += self._extra_indent 80 | 81 | if group.doc is not None: 82 | self.add_block(group.doc) 83 | else: 84 | self.add_line("", DOC_SOURCE) 85 | 86 | self.document_group_settings(group) 87 | 88 | self.indent = old_indent 89 | 90 | def document_members(self, all_members=False): 91 | """Update the document members section to include settings.""" 92 | collection = SettingsCollection(self.object).process() 93 | 94 | # Define a code highlight role 95 | self.add_line(".. role:: python(code)", DOC_SOURCE) 96 | self.add_line(" :language: python", DOC_SOURCE) 97 | self.add_line(" :class: highlight", DOC_SOURCE) 98 | self.add_line("", DOC_SOURCE) 99 | 100 | if self.options.get("grouped", False): 101 | # Do un-grouped first 102 | self.document_group_settings(collection.settings[None]) 103 | 104 | for group in collection.settings.values(): 105 | if group.name is not None: 106 | self.document_group(group) 107 | else: 108 | self.document_group_settings(collection.all_settings) 109 | 110 | 111 | def setup(app: Sphinx): 112 | app.add_autodocumenter(SettingsDocumenter) 113 | return {"version": "0.1", "parallel_read_safe": True} 114 | -------------------------------------------------------------------------------- /src/pyapp/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions 3 | ~~~~~~~~~~ 4 | 5 | Collection of standard exceptions. 6 | 7 | """ 8 | from typing import Optional 9 | 10 | 11 | class ApplicationExit(SystemExit): 12 | """Exception used to directly exit a PyApp application. 13 | 14 | Will be caught by the CliApplication instance.""" 15 | def __init__(self, status_code: int, message: Optional[str] = None): 16 | super().__init__(status_code) 17 | self.status_code = status_code 18 | self.message = message 19 | 20 | def __str__(self): 21 | if self.message: 22 | return self.message 23 | return f"Application exit: {self.status_code}" 24 | 25 | 26 | class InvalidConfiguration(Exception): 27 | """ 28 | Invalid configuration was detected. 29 | """ 30 | 31 | 32 | class FactoryException(Exception): 33 | """ 34 | Exceptions raised by factories 35 | """ 36 | 37 | 38 | class NotProvided(FactoryException, TypeError): 39 | """ 40 | Definition in settings was not provided. 41 | """ 42 | 43 | 44 | class NotFound(FactoryException, KeyError): 45 | """ 46 | The settings definition was not found. 47 | """ 48 | 49 | 50 | class CannotImport(FactoryException, ImportError): 51 | """ 52 | The plugin defined in settings cannot be imported. 53 | """ 54 | 55 | 56 | class BadAlias(FactoryException, KeyError): 57 | """ 58 | Alias is not configured correctly. 59 | """ 60 | 61 | 62 | class InvalidSubType(FactoryException, TypeError): 63 | """ 64 | Instance type specified in settings does not match a subclass of the factory ABC 65 | """ 66 | 67 | 68 | class ProviderException(Exception): 69 | """ 70 | Exceptions raised by providers. 71 | """ 72 | 73 | 74 | class ProviderNotFound(KeyError, ProviderException): 75 | """ 76 | Specified provider not found. 77 | """ 78 | 79 | 80 | class ProviderConfigNotFound(ProviderException): 81 | """ 82 | Specified provider configuration could not be loaded. 83 | """ 84 | 85 | 86 | class UnsupportedContentType(Exception): 87 | """ 88 | Content type of the file is not supported 89 | """ 90 | 91 | 92 | class EventException(Exception): 93 | """ 94 | Exception caused by event/callback definition 95 | """ 96 | 97 | 98 | class UnsupportedObject(EventException, TypeError): 99 | """ 100 | Instance does not have a dict that descriptor can use to store callbacks 101 | """ 102 | -------------------------------------------------------------------------------- /src/pyapp/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extensions 3 | ~~~~~~~~~~ 4 | 5 | *Extensions add additional features to pyApp.* 6 | 7 | The extension interface was completely redesigned for pyApp 4.0 to make the let 8 | extensions provide far more functionality. 9 | 10 | Extensions can: 11 | 12 | - Provide `default_settings` 13 | 14 | - Register checks 15 | 16 | - Add commands to the CLI 17 | 18 | - Register factories with the injection framework. 19 | 20 | .. note:: Extensions developed for previous versions of pyApp need to be upgraded 21 | to work with pyApp 4. 22 | 23 | 24 | Using extensions 25 | ---------------- 26 | 27 | Once an extension has been installed it is immediately available in pyApp. The 28 | CLI `extensions` command will list all installed extensions. When starting an 29 | application additional settings files will show up being loaded. 30 | 31 | Control of the extension version and availability of extensions is left up to the 32 | standard packaging tools and processes. I would recommend using 33 | `pipenv `_ or poetry to manage your applications 34 | dependencies (along with extensions). If you project has specific security 35 | requirements a whitelist can be provided to `CliApplication` to only allow specific 36 | extensions. 37 | 38 | 39 | Developing an extension 40 | ----------------------- 41 | 42 | An extension is a normal Python package that follows the pyApp extension protocol. 43 | 44 | Extensions must provide a `setuptools` entry point, this is how they are identified 45 | by pyApp and loaded. 46 | 47 | In a `setup.py` file:: 48 | 49 | setup( 50 | ... 51 | entry_points={'pyapp.extensions': 'my-extension = my_extension:Extension'} 52 | ... 53 | ) 54 | 55 | or `setup.cfg` file:: 56 | 57 | [options.entry_points] 58 | pyapp.extensions = 59 | my-extension = my_extension:Extension 60 | 61 | 62 | Any name or names (if the package provides multiple extensions) can be used for 63 | the extension. The entry `my_extension:Extension` refers to a class called 64 | `Extension` in the `my_extension` package. 65 | 66 | The extension class requires no specific base class and duck typing is used to 67 | determine what functions the extension provides. 68 | 69 | The following attributes/methods are used by pyApp: 70 | 71 | `default_settings` 72 | A string that specifies a module that provides default settings. This can 73 | be a relative import. 74 | 75 | `checks` 76 | A string that specifies a module that registers additional checks. This can 77 | be a relative import. 78 | 79 | `def register_commands(root: CommandGroup)` 80 | A method that is called at startup to allow the extension to register additional 81 | commands. This happens early in the startup process prior to settings being 82 | loaded. 83 | 84 | `def ready()` 85 | The application has been successfully started and is ready. This is an opportunity 86 | to register any custom functionality with pyApp. 87 | 88 | 89 | An example extension class:: 90 | 91 | class Extension: 92 | default_settings = ".default_settings" 93 | checks = ".checks" 94 | 95 | @staticmethod 96 | def register_commands(root: CommandGroup): 97 | # Register a group and commands 98 | group = root.create_command_group("foo") 99 | 100 | @group.command 101 | def bar(opts): 102 | ''' 103 | Do a foo bar operation. 104 | ''' 105 | 106 | @staticmethod 107 | def ready(): 108 | pass 109 | """ 110 | 111 | from pyapp.extensions.registry import * # noqa: F403 112 | -------------------------------------------------------------------------------- /src/pyapp/extensions/registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extension Registry 3 | ~~~~~~~~~~~~~~~~~~ 4 | 5 | Central location for registering and obtaining information about registered 6 | extensions. 7 | 8 | """ 9 | 10 | try: 11 | import importlib_metadata as metadata 12 | except ImportError: 13 | from importlib import metadata 14 | from typing import Iterable, Iterator, List, NamedTuple, Optional, Sequence 15 | 16 | from pyapp.app.arguments import CommandGroup 17 | from pyapp.utils import AllowBlockFilter 18 | 19 | __all__ = ("registry", "ExtensionEntryPoints", "ExtensionDetail") 20 | 21 | ENTRY_POINTS = "pyapp.extensions" 22 | 23 | 24 | class ExtensionDetail(NamedTuple): 25 | """Details of an entry point Extension""" 26 | 27 | extension: object 28 | key: str 29 | name: str 30 | version: str 31 | 32 | @property 33 | def default_settings(self) -> Optional[str]: 34 | """Get reference to optional default settings.""" 35 | module = getattr(self.extension, "default_settings", None) 36 | if module and module.startswith("."): 37 | return f"{self.extension.__module__}{module}" 38 | return module 39 | 40 | @property 41 | def checks_module(self) -> Optional[str]: 42 | """Get reference to optional `checks` module.""" 43 | module = getattr(self.extension, "checks", None) 44 | if module and module.startswith("."): 45 | return f"{self.extension.__module__}{module}" 46 | return module 47 | 48 | def register_commands(self, root: CommandGroup): 49 | """Call an extension to register commands with a command group. 50 | 51 | If the extension provides a callback point. 52 | """ 53 | if hasattr(self.extension, "register_commands"): 54 | self.extension.register_commands(root) 55 | 56 | def ready(self): 57 | """Generate a ready event to an extension. 58 | 59 | If the extension provides a callback point. 60 | """ 61 | if hasattr(self.extension, "ready"): 62 | self.extension.ready() 63 | 64 | 65 | class ExtensionEntryPoints: 66 | """Identifies and loads extensions.""" 67 | 68 | def __init__( 69 | self, allow_list: Sequence[str] = None, block_list: Sequence[str] = None 70 | ): 71 | """Initialise extension entry points.""" 72 | self.filter = AllowBlockFilter(allow_list, block_list) 73 | 74 | def _entry_points(self) -> Iterator[metadata.EntryPoint]: 75 | """Iterator of filtered extension entry points""" 76 | entry_points = metadata.entry_points(group=ENTRY_POINTS) 77 | yield from ( 78 | entry_point for entry_point in entry_points if self.filter(entry_point.name) 79 | ) 80 | 81 | def extensions(self, load: bool = True) -> Iterator[object]: 82 | """Iterator of loaded extensions.""" 83 | for entry_point in self._entry_points(): 84 | yield ExtensionDetail( 85 | entry_point.load() if load else None, 86 | entry_point.name, 87 | entry_point.dist.name, 88 | entry_point.dist.version, 89 | ) 90 | 91 | 92 | class ExtensionRegistry(List[ExtensionDetail]): 93 | """Registry for tracking install PyApp extensions.""" 94 | 95 | def load_from(self, extensions: Iterable[ExtensionDetail]): 96 | """Load specified extensions from the supplied iterable of Extension Details.""" 97 | for extension in extensions: 98 | self.append(extension) 99 | 100 | def register_commands(self, root: CommandGroup): 101 | """Trigger ready callback on all extension modules.""" 102 | for extension in self: 103 | extension.register_commands(root) 104 | 105 | def ready(self): 106 | """Trigger ready callback on all extension modules.""" 107 | for extension in self: 108 | extension.ready() 109 | 110 | @property 111 | def default_settings(self) -> Sequence[str]: 112 | """Return a list of module loaders for extensions that specify default settings.""" 113 | return tuple( 114 | module.default_settings for module in self if module.default_settings 115 | ) 116 | 117 | @property 118 | def check_locations(self) -> Sequence[str]: 119 | """Return a list of checks modules for extensions that specify checks.""" 120 | return tuple(module.checks_module for module in self if module.checks_module) 121 | 122 | 123 | # Shortcuts and global extension registry. 124 | registry = ExtensionRegistry() # pylint: disable=invalid-name 125 | -------------------------------------------------------------------------------- /src/pyapp/extensions/report.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extension Report 3 | ~~~~~~~~~~~~~~~~ 4 | 5 | Generates the report of the currently loaded/available extensions. 6 | 7 | """ 8 | import sys 9 | 10 | from colorama import Fore, Style 11 | 12 | from pyapp.extensions.registry import ExtensionDetail, registry 13 | 14 | 15 | class ExtensionReport: 16 | """ 17 | Wrapper for the generation of a check report. 18 | """ 19 | 20 | width = 80 21 | 22 | BASIC_TEMPLATE_MONO = "+ {name} ({version})\n" 23 | VERBOSE_TEMPLATE_MONO = ( 24 | f"{'=' * width}\n" 25 | f" Name: {{name}} ({{key}})\n" 26 | f" Version {{version}}\n" 27 | f" Settings: {{default_settings}}\n" 28 | f" Has Checks: {{has_checks}}\n" 29 | f"{'=' * width}\n\n" 30 | ) 31 | BASIC_TEMPLATE = ( 32 | f"{Fore.YELLOW}+{Fore.CYAN} {{name}}{Style.RESET_ALL} ({{version}})\n" 33 | ) 34 | VERBOSE_TEMPLATE = ( 35 | f"{Fore.YELLOW}{'=' * width}{Style.RESET_ALL}\n" 36 | f"{Style.BRIGHT} Name: {Style.RESET_ALL}{Fore.CYAN}{{name}} ({{key}}){Style.RESET_ALL}\n" 37 | f"{Style.BRIGHT} Version: {Style.RESET_ALL}{Fore.CYAN}{{version}}{Style.RESET_ALL}\n" 38 | f"{Style.BRIGHT} Settings: {Style.RESET_ALL}{Fore.CYAN}{{default_settings}}{Style.RESET_ALL}\n" 39 | f"{Style.BRIGHT} Has Checks: {Style.RESET_ALL}{Fore.CYAN}{{has_checks}}{Style.RESET_ALL}\n" 40 | f"{Fore.YELLOW}{'=' * width}{Style.RESET_ALL}\n\n" 41 | ) 42 | 43 | def __init__( 44 | self, 45 | verbose=False, 46 | no_color=False, 47 | f_out=sys.stdout, 48 | extension_registry=registry, 49 | ): 50 | """ 51 | Initialise check report 52 | 53 | :param verbose: Enable verbose output 54 | :param no_color: Disable colourised output 55 | :param f_out: File to output report to; default is ``stdout`` 56 | :param extension_registry: Registry to source extensions from; defaults to the builtin registry. 57 | 58 | """ 59 | self.verbose = verbose 60 | self.f_out = f_out 61 | self.no_color = no_color 62 | self.registry = extension_registry 63 | 64 | # Generate templates 65 | if self.no_color: 66 | self.basic_template = self.BASIC_TEMPLATE_MONO 67 | self.verbose_template = self.VERBOSE_TEMPLATE_MONO 68 | else: 69 | self.basic_template = self.BASIC_TEMPLATE 70 | self.verbose_template = self.VERBOSE_TEMPLATE 71 | 72 | def output_result(self, extension: ExtensionDetail): 73 | """ 74 | Output a result to output file. 75 | """ 76 | format_args = dict( 77 | name=extension.name, 78 | key=extension.key, 79 | version=extension.version or "Unknown", 80 | default_settings=extension.default_settings or "None", 81 | has_checks="Yes" if bool(extension.checks_module) else "No", 82 | ) 83 | 84 | if self.verbose: 85 | self.f_out.write(self.verbose_template.format(**format_args)) 86 | else: 87 | self.f_out.write(self.basic_template.format(**format_args)) 88 | 89 | def run(self): 90 | """ 91 | Run the report 92 | """ 93 | for extension in self.registry: 94 | self.output_result(extension) 95 | -------------------------------------------------------------------------------- /src/pyapp/multiprocessing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Multi-processing 3 | ~~~~~~~~~~~~~~~~ 4 | 5 | The ``pyapp.multiprocessing`` package provides an implementation of a multiprocessing 6 | ``Pool`` that ensures that settings are configured on each of the worker nodes. This 7 | allows for config factory helpers, dependency injection and feature flags to be 8 | safely used. 9 | 10 | A ``pyapp.multiprocessing.Pool`` is a drop in replacement for the stdlib 11 | implementation. 12 | 13 | """ 14 | 15 | from io import BytesIO 16 | from multiprocessing.pool import Pool as _Pool 17 | from typing import Any, Callable, Sequence 18 | 19 | from pyapp.conf import export_settings, restore_settings 20 | 21 | 22 | def pyapp_initializer(pickled_settings: bytes, initializer, init_args): 23 | """ 24 | initializer for pyApp that that restores pickled settings 25 | """ 26 | file = BytesIO(pickled_settings) 27 | restore_settings(file) 28 | if initializer: 29 | initializer(*init_args) 30 | 31 | 32 | def prepare_settings() -> bytes: 33 | """ 34 | Generate init args for a worker process initializer 35 | """ 36 | file = BytesIO() 37 | export_settings(file) 38 | return file.getvalue() 39 | 40 | 41 | class Pool(_Pool): 42 | """ 43 | Wrapper around multiprocessing pool that ensures each worker has a valid 44 | settings object initialised. 45 | """ 46 | 47 | def __init__( # noqa: PLR0913 48 | self, 49 | processes: int = None, 50 | initializer: Callable = None, 51 | initargs: Sequence[Any] = (), 52 | maxtasksperchild: int = None, 53 | context: Any = None, 54 | ): 55 | if initializer is not None and not callable(initializer): 56 | raise TypeError("initializer must be a callable") 57 | 58 | super().__init__( 59 | processes, 60 | pyapp_initializer, 61 | (prepare_settings(), initializer, initargs), 62 | maxtasksperchild, 63 | context, 64 | ) 65 | 66 | def __reduce__(self): 67 | raise NotImplementedError( 68 | "pool objects cannot be passed between processes or pickled" 69 | ) 70 | -------------------------------------------------------------------------------- /src/pyapp/py.typed: -------------------------------------------------------------------------------- 1 | Hi! =oD 2 | -------------------------------------------------------------------------------- /src/pyapp/testing/__init__.py: -------------------------------------------------------------------------------- 1 | """Helper methods for testing. 2 | 3 | Includes a plugin for Pytest. 4 | """ 5 | 6 | from types import ModuleType 7 | from collections.abc import Sequence 8 | 9 | from ..conf.loaders import settings_iterator 10 | 11 | 12 | def settings_in_module( 13 | *modules: ModuleType, 14 | exclude: Sequence[str] = ("INCLUDE_SETTINGS",), 15 | ): 16 | """Generate a list of settings defined in a module (or modules). 17 | 18 | Used for ensuring that a second settings module only contains specified settings. 19 | """ 20 | settings = set() 21 | 22 | for mod in modules: 23 | settings.update( 24 | name for name, _ in settings_iterator(mod) if name not in exclude 25 | ) 26 | 27 | return settings 28 | -------------------------------------------------------------------------------- /src/pyapp/testing/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plugin and Fixtures for pyTest 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | Provides ``settings``, ``patch_settings``, ``patch_feature_flags``, 6 | ``patch_injection`` and ``check_registry`` fixtures. 7 | 8 | """ 9 | 10 | import pytest 11 | 12 | import pyapp.checks 13 | import pyapp.conf 14 | import pyapp.feature_flags 15 | import pyapp.injection 16 | 17 | 18 | @pytest.fixture 19 | def settings() -> pyapp.conf.Settings: 20 | """ 21 | Fixture that provides access to current pyApp settings. 22 | 23 | This fixture will raise an error is settings have not been configured. 24 | """ 25 | return pyapp.conf.settings 26 | 27 | 28 | @pytest.fixture 29 | def patch_settings(settings) -> pyapp.conf.ModifySettingsContext: 30 | """ 31 | Fixture that provides a :class:`pyapp.conf.ModifySettingsContext` instance 32 | that allows a test to modify settings that will be rolled back after the 33 | test has completed. 34 | """ 35 | with settings.modify() as patch: 36 | yield patch 37 | 38 | 39 | @pytest.fixture 40 | def pyapp_feature_flags() -> pyapp.feature_flags.FeatureFlags: 41 | """ 42 | Fixture that provides a :class:`pyapp.feature_flags.ModifyFeatureFlagsContext` 43 | instance that allows a test to modify feature flags that will be rolled back 44 | after the test has completed. 45 | """ 46 | return pyapp.feature_flags.DEFAULT 47 | 48 | 49 | @pytest.fixture 50 | def patch_feature_flags( 51 | pyapp_feature_flags, 52 | ) -> pyapp.feature_flags.ModifyFeatureFlagsContext: 53 | """ 54 | Fixture that provides a :class:`pyapp.feature_flags.ModifyFeatureFlagsContext` 55 | instance that allows a test to modify feature flags that will be rolled back 56 | after the test has completed. 57 | """ 58 | with pyapp_feature_flags.modify() as patch: 59 | yield patch 60 | 61 | 62 | @pytest.fixture 63 | def pyapp_factory_registry() -> pyapp.injection.FactoryRegistry: 64 | """ 65 | Fixture that provides the default factory registry used by the ``@inject`` 66 | decorator. 67 | """ 68 | return pyapp.injection.default_registry 69 | 70 | 71 | @pytest.fixture 72 | def patch_injection( 73 | pyapp_factory_registry, 74 | ) -> pyapp.injection.ModifyFactoryRegistryContext: 75 | """ 76 | Fixture that proces a :class:`pyapp.injection.ModifyFactoryRegistryContext` 77 | instance that allows a test to modify factories mapped to abstract types 78 | used by the ``@inject`` decorator. Any changes to the registry are rolled 79 | back after the test has completed. 80 | """ 81 | with pyapp_factory_registry.modify() as patch: 82 | yield patch 83 | 84 | 85 | @pytest.fixture 86 | def check_registry(monkeypatch) -> pyapp.checks.registry.CheckRegistry: 87 | """ 88 | Fixture that provides access to a check registry. 89 | 90 | Returned registry will be empty and will replace the default registry from 91 | ``pyapp.checks.registry`` and ``pyapp.checks.register`` during the test. 92 | """ 93 | registry = pyapp.checks.registry.CheckRegistry() 94 | monkeypatch.setattr("pyapp.checks.registry", registry) 95 | monkeypatch.setattr("pyapp.checks.register", registry.register) 96 | return registry 97 | -------------------------------------------------------------------------------- /src/pyapp/typed_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Typed Settings 3 | ~~~~~~~~~~~~~~ 4 | 5 | Building on the standard settings features that is a core part of pyApp typed 6 | settings provides a more convenient way to define and access settings. 7 | 8 | Default settings are defined using a `SettingsDef` object along with the expected 9 | type via type annotations. The settings can then be accessed at runtime using the 10 | same definition. 11 | 12 | For example settings are defined in the `default_settings.py` file as: 13 | 14 | .. code-block:: python 15 | 16 | from pyapp.typed_settings import SettingsDef 17 | 18 | class MyAppSettings(SettingsDef): 19 | MY_CONFIG_VALUE: str = "Foo" 20 | 21 | `MY_CONFIG_VALUE` is added to `pyapp.conf.settings` just like any other setting 22 | and can be overridden by any other settings file. 23 | 24 | Where typed settings really shine is using the settings in your application. 25 | The `SettingsDef` object can be imported from the `default_settings` file and 26 | used to access the runtime settings values using the same definition with all 27 | the benefits of auto-completion and typing. 28 | 29 | .. code-block:: python 30 | 31 | from myapp.default_settings import MyAppSettings 32 | 33 | print(MyAppSettings.MY_CONFIG_VALUE) 34 | 35 | """ 36 | 37 | from collections.abc import Mapping 38 | from typing import Any 39 | 40 | 41 | class SettingDescriptor: 42 | """Descriptor that can access a named setting.""" 43 | 44 | __slots__ = ("setting",) 45 | 46 | def __init__(self, setting): 47 | self.setting = setting 48 | 49 | def __get__(self, instance, owner): 50 | from pyapp.conf import settings 51 | 52 | return getattr(settings, self.setting, None) 53 | 54 | 55 | class SettingsDefType(type): 56 | """Typed Settings definition type.""" 57 | 58 | def __new__(cls, name: str, bases, dct: dict[str, Any], *, prefix: str = ""): 59 | """Generate new type.""" 60 | 61 | if prefix and not prefix.isupper(): 62 | raise ValueError("Prefix must be upper snake case.") 63 | 64 | values = [] 65 | descriptors = {} 66 | for key, value in dct.items(): 67 | # Settings must be upper case (or constant style) 68 | if key.isupper(): 69 | setting = f"{prefix}{key}" 70 | values.append((setting, value)) 71 | descriptors[key] = SettingDescriptor(setting) 72 | 73 | # Update original dict. 74 | dct.update(descriptors) 75 | dct["_settings"] = tuple(values) 76 | dct["__slots__"] = () 77 | 78 | return super().__new__(cls, name, bases, dct) 79 | 80 | 81 | class SettingsDef(metaclass=SettingsDefType): 82 | """Typed settings definition.""" 83 | 84 | 85 | NamedConfig = Mapping[str, Mapping[str, Any]] 86 | NamedPluginConfig = Mapping[str, tuple[str, Mapping[str, Any]]] 87 | -------------------------------------------------------------------------------- /src/pyapp/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyApp Utils 3 | ~~~~~~~~~~~ 4 | """ 5 | 6 | import importlib 7 | import textwrap 8 | from collections.abc import Container, Sequence 9 | from fnmatch import fnmatch 10 | from typing import Any 11 | 12 | 13 | def is_iterable(obj: Any) -> bool: 14 | """Determine if an object is iterable.""" 15 | try: 16 | iter(obj) 17 | except TypeError: 18 | return False 19 | else: 20 | return True 21 | 22 | 23 | class CachedProperty: 24 | """ 25 | A property that is only computed once per instance and then replaces 26 | itself with an ordinary attribute. Deleting the attribute resets the 27 | property. (From bottle) 28 | """ 29 | 30 | def __init__(self, func): 31 | self.__doc__ = func.__doc__ 32 | self.func = func 33 | 34 | def __get__(self, obj, cls): 35 | value = obj.__dict__[self.func.__name__] = self.func(obj) 36 | return value 37 | 38 | 39 | # Alias to be consistent with Python. 40 | cached_property = CachedProperty # pylint: disable=invalid-name 41 | 42 | 43 | def import_type(type_name: str) -> type: 44 | """Import a type from a fully qualified module+type name""" 45 | module_name, type_name = type_name.rsplit(".", 1) 46 | mod = importlib.import_module(module_name) 47 | return getattr(mod, type_name) 48 | 49 | 50 | def wrap_text( 51 | text: str, width: int, *, indent: int = 0, padding: int = 1, line_sep: str = "\n" 52 | ) -> str: 53 | """Perform word wrapping on text 54 | 55 | :param text: Text to wrap. 56 | :param width: Width of text to wrap 57 | :param indent: Size of text indent. 58 | :param padding: On the start and end of lines 59 | :param line_sep: Line separator 60 | 61 | """ 62 | indent = " " * indent 63 | lines = textwrap.wrap( 64 | text, width - (padding * 2), initial_indent=indent, subsequent_indent=indent 65 | ) 66 | return line_sep.join(f"{line}{' ' * (width - len(line))}" for line in lines) 67 | 68 | 69 | TRUE_VALUES = ("TRUE", "T", "YES", "Y", "ON", "1") 70 | 71 | 72 | def text_to_bool(value: Any, *, true_values: Container[str] = TRUE_VALUES) -> bool: 73 | """Resolve a string into a bool eg "yes" -> True""" 74 | if isinstance(value, str): 75 | return value.upper() in true_values 76 | return False 77 | 78 | 79 | class AllowBlockFilter: 80 | """Filter for allow/block lists. 81 | 82 | Filter lists can be either plan strings or glob patterns. 83 | """ 84 | 85 | def __init__( 86 | self, 87 | allow_list: Sequence[str] = None, 88 | block_list: Sequence[str] = None, 89 | ): 90 | """Initialise filter""" 91 | self.allow_list = allow_list 92 | self.block_list = block_list 93 | 94 | def __call__(self, value: str) -> bool: 95 | """Check if a value is allowed""" 96 | allow_list, block_list = self.allow_list, self.block_list 97 | 98 | if block_list is not None and any( 99 | fnmatch(value, pattern) for pattern in block_list 100 | ): 101 | return False 102 | 103 | if allow_list is not None: 104 | return any(fnmatch(value, pattern) for pattern in self.allow_list) 105 | 106 | return True 107 | -------------------------------------------------------------------------------- /src/pyapp/utils/compatibility.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compatibility Utils 3 | ~~~~~~~~~~~~~~~~~~~ 4 | 5 | Utils for managing deprecation of methods and tools 6 | 7 | """ 8 | 9 | import functools 10 | import inspect 11 | import warnings 12 | 13 | 14 | def deprecated(message: str, category: type[Warning] = DeprecationWarning): 15 | """ 16 | Decorator for marking classes/functions as being deprecated and are to be removed in the future. 17 | 18 | :param message: Message provided. 19 | :param category: Category of warning. 20 | 21 | """ 22 | 23 | def decorator(obj): 24 | if inspect.isclass(obj): 25 | old_init = obj.__init__ 26 | 27 | @functools.wraps(old_init) 28 | def init_wrapper(*args, **kwargs): 29 | warnings.warn( 30 | f"{obj.__name__} is deprecated and scheduled for removal. {message}", 31 | category=category, 32 | stacklevel=2, 33 | ) 34 | return old_init(*args, **kwargs) 35 | 36 | obj.__init__ = init_wrapper 37 | return obj 38 | 39 | @functools.wraps(obj) 40 | def func_wrapper(*args, **kwargs): 41 | warnings.warn( 42 | "{obj.__name__} is deprecated and scheduled for removal. {message}", 43 | category=category, 44 | stacklevel=2, 45 | ) 46 | return obj(*args, **kwargs) 47 | 48 | return func_wrapper 49 | 50 | return decorator 51 | -------------------------------------------------------------------------------- /src/pyapp/utils/inspect.py: -------------------------------------------------------------------------------- 1 | """ 2 | Inspect Utils 3 | ~~~~~~~~~~~~~ 4 | 5 | """ 6 | import importlib 7 | import inspect 8 | from pathlib import Path 9 | 10 | 11 | def find_root_folder(start_file: Path): 12 | """ 13 | Find the root package folder from a file within the package 14 | """ 15 | # Get starting location 16 | package_path = start_file if start_file.is_dir() else start_file.parent 17 | 18 | # Check current location isn't a path 19 | if not (package_path / "__init__.py").exists(): 20 | raise ValueError("File not part of a package") 21 | 22 | # Walk up folders to find top level package path 23 | while (package_path.parent / "__init__.py").exists(): 24 | package_path = package_path.parent 25 | 26 | return package_path 27 | 28 | 29 | def import_root_module(stack_offset: int = 2): 30 | """ 31 | Identify and import the root module. 32 | """ 33 | stack = inspect.stack() 34 | frame_globals = stack[stack_offset].frame.f_globals 35 | package_name = frame_globals.get("__package__") 36 | 37 | if package_name: 38 | root_package = package_name.split(".")[0] 39 | 40 | else: 41 | # Likely the __main__ module, this module is different and does not contain 42 | # the package name some assumptions need to be made. 43 | try: 44 | root_package = find_root_folder(Path(frame_globals.get("__file__"))).name 45 | except ValueError as ex: 46 | # If the module name is __main__ this is a standalone script. 47 | name = frame_globals.get("__name__") 48 | if name in ("__main__", "__mp_main__"): 49 | root_package = name 50 | else: 51 | raise RuntimeError(f"Unable to determine root module: {ex}") from ex 52 | 53 | return importlib.import_module(root_package) 54 | -------------------------------------------------------------------------------- /src/pyapp/versioning.py: -------------------------------------------------------------------------------- 1 | """ 2 | Versioning 3 | ~~~~~~~~~~ 4 | 5 | This module provides a method for obtaining the installed version of a named 6 | package. This is used by `pyApp` itself to determine it's version at runtime 7 | to avoid the back-flips required to bake the current version into the package 8 | at build time. 9 | 10 | This module is only for compatibility reasons and will be removed in a future. 11 | 12 | Recommended replacement is to use ``importlib.metadata.distribution`` directly. 13 | 14 | """ 15 | 16 | try: 17 | import importlib_metadata as metadata 18 | except ImportError: 19 | from importlib import metadata 20 | 21 | 22 | def get_installed_version( 23 | package_name: str, 24 | package_root: str, # pylint: disable=unused-argument 25 | ) -> str: 26 | """ 27 | Get the version of the currently installed version of a package. 28 | 29 | Provide the name of the package as well as the file location of the root 30 | package e.g. in your packages `__init__.py` file:: 31 | 32 | __version__ = get_installed_version("MyPackage", __file__) 33 | 34 | """ 35 | try: 36 | dist = metadata.distribution(package_name) 37 | except metadata.PackageNotFoundError: 38 | return f"Please install {package_name} via a package." 39 | else: 40 | return dist.version 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | HERE = Path(__file__).parent.absolute() 7 | SRC_PATH = HERE.parent.parent / "src" 8 | 9 | sys.path.insert(0, SRC_PATH.as_posix()) 10 | 11 | # Ensure settings are configured 12 | from pyapp.conf import settings # noqa: E402 13 | 14 | settings.configure(["tests.settings"]) 15 | 16 | # Enable the pytester plugin 17 | pytest_plugins = "pytester" 18 | 19 | 20 | @pytest.fixture 21 | def fixture_path() -> Path: 22 | return Path(__file__).parent / "fixtures" 23 | -------------------------------------------------------------------------------- /tests/fixtures/settings-invalid-container.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "UPPER_CASE": "foo", 4 | "lower_case": "bar", 5 | "Mixed_Case": "eek" 6 | } 7 | ] -------------------------------------------------------------------------------- /tests/fixtures/settings-invalid-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "UPPER_CASE": "foo 3 | } -------------------------------------------------------------------------------- /tests/fixtures/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "UPPER_CASE": "foo", 3 | "lower_case": "bar", 4 | "Mixed_Case": "eek" 5 | } -------------------------------------------------------------------------------- /tests/fixtures/settings/default_settings.py: -------------------------------------------------------------------------------- 1 | """Sample default settings file with various settings for testing documentation.""" 2 | from pathlib import Path 3 | from typing import Union 4 | 5 | from pyapp.typed_settings import NamedConfig, NamedPluginConfig, SettingsDef 6 | 7 | INCLUDE_SETTINGS = ["myapp.plugins.default", "myapp.plugins.extra"] 8 | 9 | not_a_setting = "Just a random constant." 10 | not_a_setting_either: str = "But with a type!" 11 | 12 | TOP_LEVEL_SETTING: bool = False 13 | """This is a top level setting""" 14 | 15 | TOP_LEVEL_WITH_NO_COMMENT: int = 42 16 | 17 | 18 | class FooSettings(SettingsDef): 19 | """Settings for Foo""" 20 | 21 | FOO_SETTING: str = "foo" 22 | """A setting for Foo""" 23 | 24 | BAR_SETTING: int = 13 25 | """Another setting for Foo""" 26 | 27 | BAZ_SETTING: NamedConfig = { 28 | "default": {"value": 1}, 29 | } 30 | 31 | PLUGIN_SETTING: NamedPluginConfig = { 32 | "default": ("myapp.plugins.default", {}), 33 | } 34 | """Settings for plugin configuration.""" 35 | 36 | ENSURE_LAST_SETTING: bool = True 37 | 38 | 39 | PATH_TO_SOME_FILE: Union[str, Path] = "/path/to/some/file" 40 | """Path to some file""" 41 | 42 | 43 | class RandomClass: 44 | NOT_ACTUALLY_A_SETTING = "But a class attribute" 45 | 46 | 47 | ENSURE_LAST_SETTING_DEFINED = False 48 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/test_multiprocessing.py: -------------------------------------------------------------------------------- 1 | import pyapp.multiprocessing 2 | 3 | _DATA = [] 4 | 5 | 6 | def _sample_func(a, b): 7 | from pyapp.conf import settings 8 | 9 | return f"{a}-{b}-{settings.UPPER_VALUE}-{':'.join(_DATA)}" 10 | 11 | 12 | def sample_initializer(c, d): 13 | _DATA.append(c) 14 | _DATA.append(d) 15 | 16 | 17 | class TestPool: 18 | def test_call_pool_and_ensure_settings_are_available(self): 19 | pool = pyapp.multiprocessing.Pool(processes=2) 20 | actual = pool.starmap(_sample_func, [(1, 2), (3, 4)]) 21 | 22 | assert actual == ["1-2-foo-", "3-4-foo-"] 23 | 24 | def test_call_pool_and_with_custom_initializer(self): 25 | pool = pyapp.multiprocessing.Pool( 26 | processes=2, initializer=sample_initializer, initargs=("a", "b") 27 | ) 28 | actual = pool.starmap(_sample_func, [(1, 2), (3, 4)]) 29 | 30 | assert actual == ["1-2-foo-a:b", "3-4-foo-a:b"] 31 | -------------------------------------------------------------------------------- /tests/runtime_settings.py: -------------------------------------------------------------------------------- 1 | # Test only upper values are included 2 | RUNTIME_VALUE = "foo" 3 | -------------------------------------------------------------------------------- /tests/runtime_settings_with_imports.py: -------------------------------------------------------------------------------- 1 | INCLUDE_SETTINGS = ["python:tests.runtime_settings"] 2 | 3 | # Test only upper values are included 4 | TEST_VALUE = "bar" 5 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # Test only upper values are included 2 | from typing import Sequence 3 | 4 | from pyapp.typed_settings import NamedConfig, NamedPluginConfig, SettingsDef 5 | 6 | 7 | class MySettings(SettingsDef): 8 | UPPER_VALUE: str = "foo" 9 | lower_value: str = "bar" 10 | mixed_VALUE: str = "eek" 11 | 12 | # Helpful test placeholders. 13 | SETTING_1: int = 1 14 | SETTING_2: int = 2 15 | SETTING_3: int = 3 16 | SETTING_4: int = 4 17 | SETTING_5: int = 5 18 | 19 | # Factory sample values 20 | TEST_NAMED_FACTORY: NamedPluginConfig = { 21 | "default": ("tests.unit.factory.Bar", {"length": 42}), 22 | "iron": ("tests.unit.factory.IronBar", {"length": 24}), 23 | "steel": ("tests.unit.factory.SteelBeam", {}), 24 | } 25 | 26 | # Factory sample values 27 | TEST_NAMED_FACTORY_NO_DEFAULT: NamedPluginConfig = { 28 | "iron": ("tests.unit.factory.IronBar", {"length": 24}), 29 | "steel": ("tests.unit.factory.SteelBeam", {}), 30 | } 31 | 32 | TEST_ALIAS_FACTORY: NamedPluginConfig = { 33 | "steel": ("tests.unit.factory.SteelBeam", {}), 34 | "metal": ("alias", {"name": "steel"}), 35 | # Bad entries 36 | "plastic": ("alias", {}), 37 | "nylon": ("alias", {"name": ""}), 38 | "polythene": ("alias", {"name": "plastic"}), 39 | "polypropylene": ("alias", {"name": "oil"}), 40 | # Circular 41 | "stone": ("alias", {"name": "marble"}), 42 | "marble": ("alias", {"name": "brick"}), 43 | "brick": ("alias", {"name": "stone"}), 44 | } 45 | 46 | # Config sample values 47 | TEST_NAMED_CONFIG: NamedConfig = { 48 | "default": {"length": 42, "foo": "bar"}, 49 | "eek": {"length": 24, "foo": "bar"}, 50 | } 51 | 52 | # Providers 53 | TEST_PROVIDERS: Sequence[str] = [ 54 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest" 55 | ] 56 | 57 | 58 | class MyPrefixedSettings(SettingsDef, prefix="FOO_"): 59 | SETTING_1: str = "my-prefixed-setting" 60 | 61 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/app/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/test_argument_types.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentTypeError 2 | 3 | import pytest 4 | from pyapp.app import argument_types 5 | 6 | 7 | class TestRegexType: 8 | def test_valid_value(self): 9 | target = argument_types.RegexType(r"[a-z]+") 10 | 11 | actual = target("abc") 12 | 13 | assert actual == "abc" 14 | 15 | @pytest.mark.parametrize("value", ("123", "1abc2")) 16 | def test_invalid_value(self, value): 17 | target = argument_types.RegexType(r"[a-z]+") 18 | 19 | with pytest.raises(ArgumentTypeError, match="Value does not match "): 20 | target(value) 21 | 22 | def test_invalid_value_with_a_custom_message(self): 23 | target = argument_types.RegexType(r"[a-z]+", message="Value not alpha") 24 | 25 | with pytest.raises(ArgumentTypeError, match="Value not alpha"): 26 | target("123") 27 | -------------------------------------------------------------------------------- /tests/unit/app/test_arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from unittest import mock 3 | 4 | import pytest 5 | from pyapp.app import arguments 6 | 7 | 8 | class TestCommandProxy: 9 | def test_basic_usage(self): 10 | def sample_handler(_): 11 | return 1 12 | 13 | mock_parser = mock.Mock() 14 | 15 | target = arguments.CommandProxy(sample_handler, mock_parser) 16 | 17 | assert sample_handler is target.handler 18 | assert sample_handler.__doc__ == target.__doc__ 19 | assert sample_handler.__name__ == target.__name__ 20 | assert sample_handler.__module__ == target.__module__ 21 | assert target(None) == 1 22 | 23 | def test_with_arguments(self): 24 | @arguments.argument("--foo", dest="foo", help_text="Foo option") 25 | @arguments.argument("--bar", dest="bar", help_text="Bar option") 26 | def sample_handler(): 27 | pass 28 | 29 | mock_parser = mock.Mock() 30 | arguments.CommandProxy(sample_handler, mock_parser) 31 | 32 | assert mock_parser.add_argument.call_count == 2 33 | 34 | 35 | class TestAsyncCommandProxy: 36 | def test_basic_usage(self): 37 | async def sample_handler(_): 38 | return 1 39 | 40 | mock_parser = mock.Mock() 41 | 42 | target = arguments.AsyncCommandProxy(sample_handler, mock_parser) 43 | 44 | assert sample_handler is target.handler 45 | assert sample_handler.__doc__ == target.__doc__ 46 | assert sample_handler.__name__ == target.__name__ 47 | assert sample_handler.__module__ == target.__module__ 48 | assert target(None) == 1 49 | 50 | def test_with_arguments(self): 51 | @arguments.argument("--foo", dest="foo", help_text="Foo option") 52 | @arguments.argument("--bar", dest="bar", help_text="Bar option") 53 | async def sample_handler(): 54 | pass 55 | 56 | mock_parser = mock.Mock() 57 | arguments.AsyncCommandProxy(sample_handler, mock_parser) 58 | 59 | assert mock_parser.add_argument.call_count == 2 60 | 61 | 62 | class TestCommandGroup: 63 | @pytest.fixture 64 | def target(self): 65 | return arguments.CommandGroup(argparse.ArgumentParser("test")) 66 | 67 | @pytest.mark.parametrize( 68 | "prefix, expected", ((None, ":handler:"), ("foo", ":handler:foo")) 69 | ) 70 | def test_handler_dest(self, prefix, expected): 71 | target = arguments.CommandGroup(argparse.ArgumentParser("test"), _prefix=prefix) 72 | 73 | assert target.handler_dest == expected 74 | 75 | def test_create_command_group(self, target: arguments.CommandGroup): 76 | actual = target.create_command_group("foo", help_text="bar", aliases=("f",)) 77 | 78 | assert isinstance(actual, arguments.CommandGroup) 79 | assert actual.parser.prog == "test foo" 80 | assert "foo" in target._handlers 81 | assert "f" in target._handlers 82 | 83 | def test_create_command_group__nested(self, target: arguments.CommandGroup): 84 | group = target.create_command_group("foo") 85 | actual = group.create_command_group("bar") 86 | 87 | assert actual.handler_dest == ":handler:foo:bar" 88 | 89 | def test_default(self, target: arguments.CommandGroup): 90 | @target.default 91 | def my_default(args): 92 | return 13 93 | 94 | actual = target.dispatch_handler(argparse.Namespace()) 95 | 96 | assert actual == 13 97 | 98 | def test_default__async(self, target: arguments.CommandGroup): 99 | @target.default 100 | async def my_default(args): 101 | return 13 102 | 103 | actual = target.dispatch_handler(argparse.Namespace()) 104 | 105 | assert actual == 13 106 | 107 | def test_dispatch_handler__known_command(self, target: arguments.CommandGroup): 108 | @target.command 109 | def known(args) -> int: 110 | return 42 111 | 112 | actual = target.dispatch_handler(argparse.Namespace(**{":handler:": "known"})) 113 | 114 | assert actual == 42 115 | 116 | def test_dispatch_handler__unknown_command(self, target: arguments.CommandGroup): 117 | @target.command 118 | def known(args) -> int: 119 | return 42 120 | 121 | actual = target.dispatch_handler(argparse.Namespace(**{":handler:": "unknown"})) 122 | 123 | assert actual == 1 124 | 125 | def test_dispatch_handler__nested_command(self, target: arguments.CommandGroup): 126 | group = target.create_command_group("foo") 127 | 128 | @group.command 129 | def known(args) -> int: 130 | return 24 131 | 132 | actual = target.dispatch_handler( 133 | argparse.Namespace(**{":handler:foo": "known", ":handler:": "foo"}) 134 | ) 135 | 136 | assert actual == 24 137 | 138 | def test_dispatch_handler__with_alias(self, target: arguments.CommandGroup): 139 | group = target.create_command_group("foo") 140 | 141 | @group.command(aliases=("k", "kwn")) 142 | def known(args) -> int: 143 | return 42 144 | 145 | actual = target.dispatch_handler( 146 | argparse.Namespace(**{":handler:foo": "kwn", ":handler:": "foo"}) 147 | ) 148 | 149 | assert actual == 42 150 | 151 | def test_dispatch_handler__async(self, target: arguments.CommandGroup): 152 | @target.command 153 | async def known(args) -> int: 154 | return 42 155 | 156 | actual = target.dispatch_handler(argparse.Namespace(**{":handler:": "known"})) 157 | 158 | assert actual == 42 159 | 160 | @pytest.mark.asyncio 161 | async def test_dispatch_handler__async_in_run_loop( 162 | self, target: arguments.CommandGroup 163 | ): 164 | @target.command 165 | async def known(args) -> int: 166 | return 42 167 | 168 | with pytest.raises(RuntimeError): 169 | target.dispatch_handler(argparse.Namespace(**{":handler:": "known"})) 170 | -------------------------------------------------------------------------------- /tests/unit/app/test_init_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from unittest.mock import Mock 3 | 4 | from pyapp.app import init_logger 5 | 6 | 7 | class TestInitHandler: 8 | def test_over_threshold(self): 9 | """ 10 | Given a record that has a log level over the threshold is passed to default handler 11 | """ 12 | mock_handler = Mock() 13 | target = init_logger.InitHandler(mock_handler) 14 | record = logging.LogRecord( 15 | "Foo", logging.ERROR, "path.to.module", 42, "Bar", {}, None 16 | ) 17 | 18 | target.handle(record) 19 | 20 | mock_handler.emit.assert_called_with(record) 21 | -------------------------------------------------------------------------------- /tests/unit/app/test_logging_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import colorama 4 | import pytest 5 | from pyapp.app import logging_formatter 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "level", 10 | ( 11 | logging.CRITICAL, 12 | logging.ERROR, 13 | logging.WARNING, 14 | logging.INFO, 15 | logging.DEBUG, 16 | logging.NOTSET, 17 | ), 18 | ) 19 | def test_format_message(level): 20 | record = logging.LogRecord("test", level, "foo", 42, "bar", (), False) 21 | target = logging_formatter.ColourFormatter("%(clevelno)s - %(clevelname)s") 22 | 23 | actual = target.formatMessage(record) 24 | 25 | assert actual.endswith(colorama.Style.RESET_ALL) 26 | -------------------------------------------------------------------------------- /tests/unit/checks/test_built_in.py: -------------------------------------------------------------------------------- 1 | from pyapp.checks import built_in 2 | from pyapp.conf import settings 3 | 4 | 5 | class TestSecurityChecks: 6 | def test_debug_enabled__check_defaults(self): 7 | result = built_in.debug_enabled(settings) 8 | 9 | # The default is for DEBUG to be False 10 | assert result is None 11 | 12 | def test_debug_enabled__warning_returned_if_enabled(self): 13 | with settings.modify() as patch: 14 | patch.DEBUG = True 15 | 16 | result = built_in.debug_enabled(settings) 17 | 18 | assert result == built_in.W001 19 | -------------------------------------------------------------------------------- /tests/unit/checks/test_checks_registry.py: -------------------------------------------------------------------------------- 1 | from pyapp.checks import messages, registry 2 | 3 | 4 | class TestCheckRegistry: 5 | def test_register__with_decorator_without_tags(self): 6 | target = registry.CheckRegistry() 7 | 8 | @target.register 9 | def my_check_func(**kwargs): 10 | pass 11 | 12 | assert len(target) == 1 13 | assert my_check_func in target 14 | assert len(my_check_func._check__tags) == 0 15 | 16 | def test_register__with_decorator_with_tags(self): 17 | target = registry.CheckRegistry() 18 | 19 | @target.register("foo", "bar") 20 | def my_check_func(**kwargs): 21 | pass 22 | 23 | assert len(target) == 1 24 | assert my_check_func in target 25 | assert len(my_check_func._check__tags) == 2 26 | 27 | def test_register__with_method(self): 28 | target = registry.CheckRegistry() 29 | 30 | def my_check_func(**kwargs): 31 | pass 32 | 33 | target.register(my_check_func, "foo", "bar") 34 | 35 | assert len(target) == 1 36 | assert my_check_func in target 37 | assert len(my_check_func._check__tags) == 2 38 | 39 | def test_register__same_check(self): 40 | target = registry.CheckRegistry() 41 | 42 | @target.register 43 | def my_check_func(**kwargs): 44 | pass 45 | 46 | target.register(my_check_func, "foo", "bar") 47 | 48 | assert len(target) == 1 49 | assert my_check_func in target 50 | assert len(my_check_func._check__tags) == 2 51 | 52 | def test_register__attached_check(self): 53 | target = registry.CheckRegistry() 54 | 55 | def my_func(): 56 | return "foo" 57 | 58 | def my_func_checks(**kwargs): 59 | pass 60 | 61 | my_func.checks = my_func_checks 62 | 63 | target.register(my_func, "foo") 64 | 65 | assert len(target) == 1 66 | assert my_func in target 67 | assert len(my_func._check__tags) == 1 68 | 69 | def test_run_checks__all_successful(self): 70 | target = registry.CheckRegistry() 71 | 72 | @target.register 73 | def check_1(settings, **kwargs): 74 | assert settings 75 | 76 | @target.register() 77 | def check_2(settings, **kwargs): 78 | assert settings 79 | 80 | actual = target.run_checks() 81 | 82 | assert len(actual) == 0 83 | 84 | def test_run_checks__singular_and_multiple_responses(self): 85 | target = registry.CheckRegistry() 86 | 87 | @target.register 88 | def check_1(settings, **kwargs): 89 | return messages.Info("Message1") 90 | 91 | @target.register() 92 | def check_2(settings, **kwargs): 93 | return messages.Info("Message2"), messages.Info("Message3") 94 | 95 | actual = target.run_checks() 96 | 97 | assert len(actual) == 3 98 | assert ( 99 | messages.Info("Message1"), 100 | messages.Info("Message2"), 101 | messages.Info("Message3"), 102 | ) == actual 103 | 104 | def test_run_checks__filter_by_tag(self): 105 | target = registry.CheckRegistry() 106 | 107 | @target.register("foo") 108 | def check_1(settings, **kwargs): 109 | return messages.Info("Message1") 110 | 111 | @target.register("foo", "bar") 112 | def check_2(settings, **kwargs): 113 | return messages.Info("Message2") 114 | 115 | @target.register("bar") 116 | def check_3(settings, **kwargs): 117 | return messages.Info("Message3"), messages.Info("Message4") 118 | 119 | actual = target.run_checks(["foo"]) 120 | assert (messages.Info("Message1"), messages.Info("Message2")) == actual 121 | 122 | actual = target.run_checks(["bar"]) 123 | assert ( 124 | messages.Info("Message2"), 125 | messages.Info("Message3"), 126 | messages.Info("Message4"), 127 | ) == actual 128 | 129 | actual = target.run_checks(["foo", "bar"]) 130 | assert ( 131 | messages.Info("Message1"), 132 | messages.Info("Message2"), 133 | messages.Info("Message3"), 134 | messages.Info("Message4"), 135 | ) == actual 136 | 137 | def test_run_checks__attached_checks(self): 138 | target = registry.CheckRegistry() 139 | 140 | class MyClass: 141 | def checks(self, settings, **kwargs): 142 | return messages.Info("Message1"), messages.Info("Message2") 143 | 144 | instance = MyClass() 145 | 146 | target.register(instance) 147 | 148 | actual = target.run_checks() 149 | assert (messages.Info("Message1"), messages.Info("Message2")) == actual 150 | 151 | def test_run_checks__unhandled_exception(self): 152 | target = registry.CheckRegistry() 153 | 154 | @target.register 155 | def check(settings, **kwargs): 156 | raise RuntimeError("Un-expected error") 157 | 158 | actual = target.run_checks() 159 | 160 | assert len(actual) == 1 161 | assert isinstance(actual[0], messages.UnhandledException) 162 | -------------------------------------------------------------------------------- /tests/unit/checks/test_messages.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp.checks import messages 3 | 4 | 5 | def exception_check(): 6 | raise RuntimeError("Didn't see that one!") 7 | 8 | 9 | class TestCheckMessage: 10 | @pytest.mark.parametrize( 11 | ("message_type", "expected", "is_serious"), 12 | ( 13 | (messages.Debug, messages.DEBUG, False), 14 | (messages.Info, messages.INFO, False), 15 | (messages.Warn, messages.WARNING, False), 16 | (messages.Error, messages.ERROR, True), 17 | (messages.Critical, messages.CRITICAL, True), 18 | ), 19 | ) 20 | def test_level__from_message_overrides(self, message_type, expected, is_serious): 21 | instance = message_type("Foo") 22 | 23 | assert instance.level == expected 24 | assert instance.is_serious() == is_serious 25 | 26 | def test_eq__equal_to_self(self): 27 | target = messages.Debug("Debug") 28 | 29 | assert target == target 30 | 31 | def test_eq__equal_to_same_constructor_values(self): 32 | assert messages.Info("Info") == messages.Info("Info") 33 | 34 | def test_eq__unknown_type(self): 35 | assert messages.Info("Info") != 123 36 | 37 | def test_ne___not_equal_to_different_constructor_values(self): 38 | assert messages.Error("Error") != messages.Error("Error2") 39 | 40 | @pytest.mark.parametrize( 41 | ("cls", "msg", "hint", "obj", "expected"), 42 | ( 43 | (messages.Debug, "Message", None, None, "?: Message"), 44 | (messages.Info, "Message", "Hint", None, "?: Message\n\tHINT: Hint"), 45 | (messages.Warn, "Message", None, "Obj", "Obj: Message"), 46 | (messages.Error, "Message", "Hint", "Obj", "Obj: Message\n\tHINT: Hint"), 47 | ), 48 | ) 49 | def test_str(self, cls, msg, hint, obj, expected): 50 | actual = str(cls(msg, hint, obj)) 51 | 52 | assert actual == expected 53 | 54 | @pytest.mark.parametrize( 55 | ("cls", "msg", "hint", "obj", "expected"), 56 | ( 57 | ( 58 | messages.Debug, 59 | "Message", 60 | None, 61 | None, 62 | "Debug(msg='Message', hint=None, obj=None)", 63 | ), 64 | ( 65 | messages.Info, 66 | "Message", 67 | "Hint", 68 | None, 69 | "Info(msg='Message', hint='Hint', obj=None)", 70 | ), 71 | ( 72 | messages.Warn, 73 | "Message", 74 | None, 75 | "Obj", 76 | "Warn(msg='Message', hint=None, obj='Obj')", 77 | ), 78 | ( 79 | messages.Error, 80 | "Message", 81 | "Hint", 82 | "Obj", 83 | "Error(msg='Message', hint='Hint', obj='Obj')", 84 | ), 85 | ), 86 | ) 87 | def test_repr(self, cls, msg, hint, obj, expected): 88 | actual = repr(cls(msg, hint, obj)) 89 | 90 | assert actual == expected 91 | 92 | def test_exc_info(self): 93 | try: 94 | exception_check() 95 | except Exception: 96 | target = messages.UnhandledException() 97 | 98 | assert (target.level_name, target.msg) == ("ERROR", "Unhandled Exception") 99 | assert target.hint.startswith("Traceback (most recent call last):") 100 | assert target.hint.endswith("RuntimeError: Didn't see that one!\n") 101 | -------------------------------------------------------------------------------- /tests/unit/checks/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp.conf import settings 3 | 4 | import tests.unit.sample_app_simple 5 | from tests.unit.sample_app.__main__ import app 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "args", 10 | ( 11 | ("checks",), 12 | ("checks", "--verbose"), 13 | ("checks", "--table"), 14 | ("--nocolor", "checks"), 15 | ("--nocolor", "checks", "--verbose"), 16 | ("--checks", "extensions", "--verbose"), 17 | ), 18 | ) 19 | def test_run_report_from_app(args, exit_code=4): 20 | with settings.modify() as patch: 21 | patch.DEBUG = True 22 | 23 | with pytest.raises(SystemExit) as ex: 24 | app.dispatch(args=args) 25 | 26 | assert ex.value.code == exit_code 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "args", 31 | ( 32 | ("checks",), 33 | ("checks", "--verbose"), 34 | ("checks", "--table"), 35 | ("--nocolor", "checks"), 36 | ("--nocolor", "checks", "--verbose"), 37 | ), 38 | ) 39 | def test_run_report_from_simple_app(monkeypatch, args, exit_code=4): 40 | monkeypatch.setattr(tests.unit.sample_app_simple.app, "application_settings", None) 41 | monkeypatch.setattr( 42 | tests.unit.sample_app_simple.app, "application_checks", "__main__.checks" 43 | ) 44 | 45 | with settings.modify() as patch: 46 | patch.DEBUG = True 47 | 48 | with pytest.raises(SystemExit) as ex: 49 | tests.unit.sample_app_simple.app.dispatch(args=args) 50 | 51 | assert ex.value.code == exit_code 52 | -------------------------------------------------------------------------------- /tests/unit/conf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/conf/__init__.py -------------------------------------------------------------------------------- /tests/unit/conf/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/conf/helpers/__init__.py -------------------------------------------------------------------------------- /tests/unit/conf/helpers/test_providers_factories.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp import checks 3 | from pyapp.conf import settings 4 | from pyapp.conf.helpers import providers 5 | 6 | 7 | class ProviderBaseTest: 8 | """ 9 | Description. 10 | """ 11 | 12 | name = "Test Provider" 13 | 14 | def __init__(self, foo): 15 | self.foo = foo 16 | 17 | 18 | class ProviderFactoryTest(providers.ProviderFactoryBase): 19 | setting = "TEST_PROVIDERS" 20 | 21 | def __init__(self, ref=None, config=None, multi_messages=False): 22 | super().__init__(self.setting) 23 | self.ref = ref 24 | self.config = config or {} 25 | self.multi_messages = multi_messages 26 | 27 | def load_config(self, *args, **kwargs): 28 | return self.ref, self.config 29 | 30 | def check_instance(self, idx: int, provider_ref: str, **kwargs): 31 | message = super().check_instance(idx, provider_ref, **kwargs) 32 | if message: 33 | return message 34 | 35 | if self.multi_messages: 36 | return checks.Info("foo"), checks.Warn("bar") 37 | 38 | 39 | class TestProviderFactoryBase: 40 | def test_providers(self): 41 | target = ProviderFactoryTest() 42 | actual = target.providers 43 | 44 | assert len(actual) == 1 45 | 46 | def test_provider_summaries(self): 47 | target = ProviderFactoryTest() 48 | actual = target.provider_summaries 49 | 50 | assert len(actual) == 1 51 | assert actual == ( 52 | providers.ProviderSummary( 53 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest", 54 | "Test Provider", 55 | "Description.", 56 | ), 57 | ) 58 | 59 | def test_get_provider(self): 60 | target = ProviderFactoryTest() 61 | actual = target.get_provider( 62 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest" 63 | ) 64 | 65 | assert actual is ProviderBaseTest 66 | 67 | def test_get_provider__not_found(self): 68 | target = ProviderFactoryTest() 69 | 70 | with pytest.raises(providers.ProviderNotFound): 71 | target.get_provider("tests.wrong.ProviderBaseTest") 72 | 73 | def test_get_instance(self): 74 | target = ProviderFactoryTest( 75 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest", 76 | {"foo": "bar"}, 77 | ) 78 | 79 | actual = target.create() 80 | 81 | assert isinstance(actual, ProviderBaseTest) 82 | assert actual.foo == "bar" 83 | 84 | def test_get_instance__not_found(self): 85 | target = ProviderFactoryTest("tests.unit.wrong.ProviderBaseTest") 86 | 87 | with pytest.raises(providers.ProviderNotFound): 88 | target.create() 89 | 90 | @pytest.mark.parametrize( 91 | "provider_settings, expected", 92 | ( 93 | (None, None), 94 | ([], []), 95 | ( 96 | {}, 97 | checks.Critical( 98 | "Provider definitions defined in settings not a list/tuple instance.", 99 | "Change setting TEST_PROVIDERS to be a list or tuple in settings file.", 100 | "settings.TEST_PROVIDERS", 101 | ), 102 | ), 103 | (["tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest"], []), 104 | ( 105 | [123], 106 | [ 107 | checks.Critical( 108 | "Provider definition is not a string.", 109 | "Change definition to be a string in settings.", 110 | "settings.TEST_PROVIDERS[0]", 111 | ) 112 | ], 113 | ), 114 | ), 115 | ) 116 | def test_checks(self, provider_settings, expected): 117 | target = ProviderFactoryTest() 118 | 119 | with settings.modify() as ctx: 120 | ctx.TEST_PROVIDERS = provider_settings 121 | 122 | actual = target.checks(settings=settings) 123 | 124 | assert actual == expected 125 | 126 | def test_checks__multiple_instance_messages(self): 127 | target = ProviderFactoryTest(multi_messages=True) 128 | 129 | with settings.modify() as ctx: 130 | ctx.TEST_PROVIDERS = ( 131 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest", 132 | ) 133 | 134 | actual = target.checks(settings=settings) 135 | 136 | assert actual == [checks.Info("foo"), checks.Warn("bar")] 137 | 138 | def test_checks__missing_settings(self): 139 | target = ProviderFactoryTest() 140 | 141 | with settings.modify() as ctx: 142 | del ctx.TEST_PROVIDERS 143 | 144 | actual = target.checks(settings=settings) 145 | 146 | assert actual == checks.Critical( 147 | "Provider definitions missing from settings.", 148 | "Add a TEST_PROVIDERS entry into settings.", 149 | "settings.TEST_PROVIDERS", 150 | ) 151 | 152 | def test_checks__invalid_import(self): 153 | target = ProviderFactoryTest() 154 | 155 | with settings.modify() as ctx: 156 | ctx.TEST_PROVIDERS = [ 157 | "tests.unit.conf.helpers.test_providers_factories.ProviderBaseTest", 158 | "tests.unit.wrong.ProviderBaseTest", 159 | ] 160 | 161 | actual = target.checks(settings=settings)[0] 162 | 163 | assert isinstance(actual, checks.Critical) 164 | assert actual.msg == "Unable to import provider type." 165 | assert actual.obj == "settings.TEST_PROVIDERS[1]" 166 | -------------------------------------------------------------------------------- /tests/unit/conf/helpers/test_singleton_factories.py: -------------------------------------------------------------------------------- 1 | from pyapp.conf import helpers 2 | 3 | 4 | class ExampleNamedSingletonFactory(helpers.NamedSingletonFactory): 5 | def create(self, name: str = None): 6 | return object() 7 | 8 | 9 | class TestNamedSingletonFactory: 10 | def test_create__same_name_same_instance(self): 11 | target = ExampleNamedSingletonFactory("FOO") 12 | 13 | actual_a = target.create() 14 | actual_b = target.create() 15 | 16 | assert actual_a is actual_b 17 | 18 | def test_create__different_name_different_instance(self): 19 | target = ExampleNamedSingletonFactory("FOO") 20 | 21 | actual_a = target.create("A") 22 | actual_b = target.create("B") 23 | 24 | assert actual_a is not actual_b 25 | 26 | 27 | class ExampleThreadLocalNamedSingletonFactory(helpers.ThreadLocalNamedSingletonFactory): 28 | def create(self, name: str = None): 29 | return object() 30 | 31 | 32 | class TestThreadLocalNamedSingletonFactory: 33 | """ 34 | Note this test is only within a single thead 35 | """ 36 | 37 | def test_create__same_name_same_instance(self): 38 | target = ExampleThreadLocalNamedSingletonFactory("FOO") 39 | 40 | actual_a = target.create() 41 | actual_b = target.create() 42 | 43 | assert actual_a is actual_b 44 | 45 | def test_create__different_name_different_instance(self): 46 | target = ExampleThreadLocalNamedSingletonFactory("FOO") 47 | 48 | actual_a = target.create("A") 49 | actual_b = target.create("B") 50 | 51 | assert actual_a is not actual_b 52 | -------------------------------------------------------------------------------- /tests/unit/conf/loaders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/conf/loaders/__init__.py -------------------------------------------------------------------------------- /tests/unit/conf/loaders/test_.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp.conf import loaders 3 | from pyapp.conf.loaders import Loader 4 | from pyapp.exceptions import InvalidConfiguration 5 | from yarl import URL 6 | 7 | 8 | class TestModuleLoader: 9 | def test__module_exists(self): 10 | target = loaders.ModuleLoader("tests.settings") 11 | 12 | actual = dict(target) 13 | 14 | assert str(target) == "python:tests.settings" 15 | assert all(key.isupper() for key in actual) 16 | 17 | def test__module_not_found(self): 18 | target = loaders.ModuleLoader("tests.unknown.settings") 19 | 20 | with pytest.raises(InvalidConfiguration): 21 | dict(target) 22 | 23 | assert str(target) == "python:tests.unknown.settings" 24 | 25 | 26 | class TestObjectLoader: 27 | def test_from_url(self): 28 | with pytest.raises(NotImplementedError): 29 | loaders.ObjectLoader.from_url(URL("")) 30 | 31 | def test_extracts_attributes(self): 32 | class MyObject: 33 | FOO = "abc" 34 | BAR = 2 35 | eek = "def" 36 | 37 | target = loaders.ObjectLoader(MyObject) 38 | 39 | actual = dict(target) 40 | 41 | assert actual == {"FOO": "abc", "BAR": 2} 42 | 43 | 44 | class TestSettingsLoaderRegistry: 45 | def test_register__as_decorator(self): 46 | target = loaders.SettingsLoaderRegistry() 47 | 48 | @target.register 49 | class SimpleSettings(Loader): 50 | scheme = "eek" 51 | 52 | @classmethod 53 | def from_url(cls, settings_url): 54 | return cls(settings_url) 55 | 56 | def __init__(self, settings_url): 57 | self.settings_url = settings_url 58 | 59 | def __iter__(self): 60 | return {"SIMPLE": self.settings_url}.items() 61 | 62 | assert "eek" in target 63 | assert isinstance(target.factory("eek:sample"), SimpleSettings) 64 | 65 | def test_register__as_method(self): 66 | target = loaders.SettingsLoaderRegistry() 67 | 68 | class SimpleSettings(Loader): 69 | scheme = ("eek", "ook") 70 | 71 | @classmethod 72 | def from_url(cls, settings_url): 73 | return cls(settings_url) 74 | 75 | def __init__(self, settings_url): 76 | self.settings_url = settings_url 77 | 78 | def __iter__(self): 79 | return {"SIMPLE": self.settings_url}.items() 80 | 81 | target.register(SimpleSettings) 82 | 83 | assert "eek" in target 84 | assert "ook" in target 85 | assert isinstance(target.factory("eek:sample"), SimpleSettings) 86 | assert isinstance(target.factory("ook:sample"), SimpleSettings) 87 | 88 | @pytest.mark.parametrize( 89 | ("settings_uri", "expected", "str_value"), 90 | ( 91 | ("sample.settings", loaders.ModuleLoader, "python:sample.settings"), 92 | ("python:sample.settings", loaders.ModuleLoader, "python:sample.settings"), 93 | ( 94 | "file:///path/to/sample.json", 95 | loaders.FileLoader, 96 | "file:///path/to/sample.json?type=application/json", 97 | ), 98 | ), 99 | ) 100 | def test_factory__loaders_correctly_resolved( 101 | self, settings_uri, expected, str_value 102 | ): 103 | target = loaders.registry 104 | 105 | actual = target.factory(settings_uri) 106 | 107 | assert isinstance(actual, expected) 108 | assert str(actual) == str_value 109 | 110 | @pytest.mark.parametrize( 111 | ("settings_uri", "expected"), 112 | (("py:sample.settings", "Unknown scheme `py` in settings URI:"),), 113 | ) 114 | def test_factory__invalid_settings_uri(self, settings_uri, expected): 115 | target = loaders.registry 116 | 117 | with pytest.raises(InvalidConfiguration) as e: 118 | target.factory(settings_uri) 119 | 120 | assert str(e.value).startswith(expected) 121 | -------------------------------------------------------------------------------- /tests/unit/conf/loaders/test_content_types.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from pyapp.conf.loaders import content_types 5 | from yarl import URL 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "url, expected", 10 | ( 11 | ("http://myhost/path/to/my/file.json", ("application/json",)), 12 | ("http://myhost/path/to/my/file?type=application/json", ("application/json",)), 13 | ( 14 | "http://myhost/path/to/my/file.yaml", 15 | ("application/yaml", "application/x-yaml"), 16 | ), 17 | ("file:///path/to/my/file.yml", ("application/yaml", "application/x-yaml")), 18 | ("file:///path/to/my/file.txt", ("text/plain",)), 19 | ), 20 | ) 21 | def test_content_type_from_url__known_types(url: str, expected: str): 22 | actual = content_types.content_type_from_url(URL(url)) 23 | 24 | assert actual in expected 25 | 26 | 27 | class TestContentTypeParserRegistry: 28 | def test_parse__known_type(self): 29 | text_parser = mock.Mock(return_value="foo") 30 | 31 | target = content_types.ContentTypeParserRegistry({"text/plain": text_parser}) 32 | 33 | actual = target.parse_file("abc", "text/plain") 34 | 35 | text_parser.assert_called_with("abc") 36 | assert actual == "foo" 37 | 38 | def test_parse__unknown_type(self): 39 | text_parser = mock.Mock(return_value="foo") 40 | 41 | target = content_types.ContentTypeParserRegistry({"text/plain": text_parser}) 42 | 43 | with pytest.raises(content_types.UnsupportedContentType): 44 | target.parse_file("abc", "application/json") 45 | 46 | def test_register__single(self): 47 | target = content_types.ContentTypeParserRegistry() 48 | 49 | target.register("text/plain", content_types.json_load) 50 | 51 | assert len(target) == 1 52 | assert "text/plain" in target 53 | assert target["text/plain"] is content_types.json_load 54 | 55 | def test_register__multiple(self): 56 | target = content_types.ContentTypeParserRegistry() 57 | 58 | target.register(("text/plain", "application/json"), content_types.json_load) 59 | 60 | assert len(target) == 2 61 | assert "text/plain" in target 62 | assert "application/json" in target 63 | assert target["text/plain"] is content_types.json_load 64 | assert target["application/json"] is content_types.json_load 65 | -------------------------------------------------------------------------------- /tests/unit/conf/loaders/test_file_loader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pyapp.conf.loaders import file_loader 5 | from pyapp.exceptions import InvalidConfiguration 6 | 7 | 8 | class TestFileLoader: 9 | def test__valid_file(self, fixture_path: Path): 10 | file = fixture_path / "settings.json" 11 | target = file_loader.FileLoader(file, "application/json") 12 | 13 | actual = dict(target) 14 | 15 | assert str(target) == f"file://{file}?type=application/json" 16 | assert actual == {"UPPER_CASE": "foo"} 17 | 18 | def test__missing_file(self, fixture_path: Path): 19 | file = fixture_path / "missing-file.json" 20 | target = file_loader.FileLoader(file, "application/json") 21 | 22 | with pytest.raises(InvalidConfiguration): 23 | dict(target) 24 | 25 | assert str(target) == f"file://{file}?type=application/json" 26 | 27 | def test__invalid_file(self, fixture_path: Path): 28 | file = fixture_path / "settings-invalid-file.json" 29 | target = file_loader.FileLoader(file, "application/json") 30 | 31 | with pytest.raises(InvalidConfiguration): 32 | dict(target) 33 | 34 | assert str(target) == f"file://{file}?type=application/json" 35 | 36 | def test__invalid_container(self, fixture_path: Path): 37 | file = fixture_path / "settings-invalid-container.json" 38 | target = file_loader.FileLoader(file, "application/json") 39 | 40 | with pytest.raises(InvalidConfiguration): 41 | dict(target) 42 | 43 | assert str(target) == f"file://{file}?type=application/json" 44 | -------------------------------------------------------------------------------- /tests/unit/conf/loaders/test_http_loader.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest import mock 3 | from urllib.error import ContentTooShortError 4 | 5 | import pytest 6 | from pyapp.conf.loaders import http_loader 7 | from pyapp.exceptions import InvalidConfiguration, UnsupportedContentType 8 | from yarl import URL 9 | 10 | from tests.unit.mock import ANY_INSTANCE_OF 11 | 12 | 13 | class TestRetrieveFile: 14 | def test_retrieve_file__invalid_scheme(self): 15 | with pytest.raises(InvalidConfiguration): 16 | http_loader.retrieve_file(URL("ftp://hostname/foo/bar.json")) 17 | 18 | @pytest.mark.parametrize( 19 | "url, expected", 20 | ( 21 | (URL("http://hostname/foo/bar.json"), None), 22 | ( 23 | URL("https://hostname/foo/bar.json"), 24 | ANY_INSTANCE_OF(http_loader.ssl.SSLContext), 25 | ), 26 | ), 27 | ) 28 | def test_retrieve_file__correct_context(self, monkeypatch, url, expected): 29 | urlopen_mock = mock.Mock(side_effect=AssertionError) 30 | monkeypatch.setattr(http_loader, "urlopen", urlopen_mock) 31 | 32 | with pytest.raises(AssertionError): 33 | http_loader.retrieve_file(url) 34 | 35 | urlopen_mock.assert_called_with(url, context=expected) 36 | 37 | @pytest.mark.parametrize( 38 | "headers", ({}, {"Content-Length": "6"}, {"Content-Type": "application/json"}) 39 | ) 40 | def test_retrieve_file__ok(self, monkeypatch, headers): 41 | response_mock = mock.Mock() 42 | response_mock.info.return_value = headers 43 | response_mock.read.side_effect = [b"foo", b"bar", None] 44 | 45 | urlopen_mock = mock.Mock(return_value=response_mock) 46 | monkeypatch.setattr(http_loader, "urlopen", urlopen_mock) 47 | 48 | file, content_type = http_loader.retrieve_file( 49 | URL("http://hostname/foo/bar.json") 50 | ) 51 | 52 | assert content_type == "application/json" 53 | assert file.read() == b"foobar" 54 | 55 | file.close() 56 | 57 | def test_retrieve_file__invalid_length(self, monkeypatch): 58 | response_mock = mock.Mock() 59 | response_mock.info.return_value = {"Content-Length": "10"} 60 | response_mock.read.side_effect = [b"foo", b"bar", None] 61 | 62 | urlopen_mock = mock.Mock(return_value=response_mock) 63 | monkeypatch.setattr(http_loader, "urlopen", urlopen_mock) 64 | 65 | with pytest.raises(ContentTooShortError): 66 | http_loader.retrieve_file(URL("http://hostname/foo/bar.json")) 67 | 68 | 69 | class TestHttpLoader: 70 | @pytest.fixture 71 | def target(self): 72 | return http_loader.HttpLoader.from_url(URL("http://hostname/foo/bar.json")) 73 | 74 | def test_str(self, target: http_loader.HttpLoader): 75 | assert str(target) == "http://hostname/foo/bar.json" 76 | 77 | def test_close(self, target: http_loader.HttpLoader): 78 | mock_fp = mock.Mock() 79 | 80 | target._fp = mock_fp 81 | target.close() 82 | 83 | mock_fp.close.assert_called() 84 | 85 | def test_iter(self, target: http_loader.HttpLoader, monkeypatch): 86 | retrieve_file_mock = mock.Mock( 87 | return_value=(StringIO('{"FOO": "bar"}'), "application/json") 88 | ) 89 | monkeypatch.setattr(http_loader, "retrieve_file", retrieve_file_mock) 90 | 91 | with target: 92 | actual = list(target) 93 | 94 | assert actual == [("FOO", "bar")] 95 | 96 | def test_iter__io_error(self, target: http_loader.HttpLoader, monkeypatch): 97 | retrieve_file_mock = mock.Mock(side_effect=IOError) 98 | monkeypatch.setattr(http_loader, "retrieve_file", retrieve_file_mock) 99 | 100 | with target, pytest.raises(InvalidConfiguration): 101 | list(target) 102 | 103 | def test_iter__unknown_type(self, target: http_loader.HttpLoader, monkeypatch): 104 | retrieve_file_mock = mock.Mock( 105 | return_value=(StringIO('{"FOO": "bar"}'), "application/eek") 106 | ) 107 | monkeypatch.setattr(http_loader, "retrieve_file", retrieve_file_mock) 108 | 109 | with target, pytest.raises(UnsupportedContentType): 110 | list(target) 111 | 112 | def test_iter__invalid_json(self, target: http_loader.HttpLoader, monkeypatch): 113 | retrieve_file_mock = mock.Mock( 114 | return_value=(StringIO('{"FOO": "bar}'), "application/json") 115 | ) 116 | monkeypatch.setattr(http_loader, "retrieve_file", retrieve_file_mock) 117 | 118 | with target, pytest.raises(InvalidConfiguration): 119 | list(target) 120 | 121 | def test_iter__invalid_data(self, target: http_loader.HttpLoader, monkeypatch): 122 | retrieve_file_mock = mock.Mock( 123 | return_value=(StringIO('["FOO"]'), "application/json") 124 | ) 125 | monkeypatch.setattr(http_loader, "retrieve_file", retrieve_file_mock) 126 | 127 | with target, pytest.raises(InvalidConfiguration): 128 | list(target) 129 | -------------------------------------------------------------------------------- /tests/unit/conf/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.unit.sample_app.__main__ import app 4 | 5 | 6 | @pytest.mark.parametrize("args", (("settings",), ("--nocolor", "settings"))) 7 | def test_run_report_from_app(args): 8 | app.dispatch(args=args) 9 | -------------------------------------------------------------------------------- /tests/unit/documentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/documentation/__init__.py -------------------------------------------------------------------------------- /tests/unit/documentation/test_settings.py: -------------------------------------------------------------------------------- 1 | from pyapp.documentation import settings 2 | 3 | 4 | class TestSettingsDocumentor: 5 | def test_document(self, fixture_path): 6 | target = settings.SettingsCollection( 7 | fixture_path / "settings" / "default_settings.py" 8 | ) 9 | 10 | target.process() 11 | 12 | assert target.settings == { 13 | None: settings.SettingDefGroup( 14 | None, 15 | [ 16 | settings.SettingDef( 17 | "TOP_LEVEL_SETTING", 18 | "bool", 19 | False, 20 | "This is a top level setting", 21 | ), 22 | settings.SettingDef( 23 | "TOP_LEVEL_WITH_NO_COMMENT", 24 | "int", 25 | 42, 26 | None, 27 | ), 28 | settings.SettingDef( 29 | "PATH_TO_SOME_FILE", 30 | "str | Path", 31 | "/path/to/some/file", 32 | "Path to some file", 33 | ), 34 | settings.SettingDef( 35 | "ENSURE_LAST_SETTING_DEFINED", 36 | None, 37 | False, 38 | None, 39 | ), 40 | ], 41 | None, 42 | ), 43 | "FooSettings": settings.SettingDefGroup( 44 | "FooSettings", 45 | [ 46 | settings.SettingDef( 47 | "FOO_SETTING", 48 | "str", 49 | "foo", 50 | "A setting for Foo", 51 | ), 52 | settings.SettingDef( 53 | "BAR_SETTING", 54 | "int", 55 | 13, 56 | "Another setting for Foo", 57 | ), 58 | settings.SettingDef( 59 | "BAZ_SETTING", 60 | "NamedConfig", 61 | {"default": {"value": 1}}, 62 | None, 63 | ), 64 | settings.SettingDef( 65 | "PLUGIN_SETTING", 66 | "NamedPluginConfig", 67 | { 68 | "default": ["myapp.plugins.default", {}], 69 | }, 70 | "Settings for plugin configuration.", 71 | ), 72 | settings.SettingDef( 73 | "ENSURE_LAST_SETTING", 74 | "bool", 75 | True, 76 | None, 77 | ), 78 | ], 79 | "Settings for Foo", 80 | ), 81 | } 82 | assert target.all_settings == settings.SettingDefGroup( 83 | None, 84 | [ 85 | settings.SettingDef( 86 | "TOP_LEVEL_SETTING", 87 | "bool", 88 | False, 89 | "This is a top level setting", 90 | ), 91 | settings.SettingDef( 92 | "TOP_LEVEL_WITH_NO_COMMENT", 93 | "int", 94 | 42, 95 | None, 96 | ), 97 | settings.SettingDef( 98 | "PATH_TO_SOME_FILE", 99 | "str | Path", 100 | "/path/to/some/file", 101 | "Path to some file", 102 | ), 103 | settings.SettingDef( 104 | "ENSURE_LAST_SETTING_DEFINED", 105 | None, 106 | False, 107 | None, 108 | ), 109 | settings.SettingDef( 110 | "FOO_SETTING", 111 | "str", 112 | "foo", 113 | "A setting for Foo", 114 | ), 115 | settings.SettingDef( 116 | "BAR_SETTING", 117 | "int", 118 | 13, 119 | "Another setting for Foo", 120 | ), 121 | settings.SettingDef( 122 | "BAZ_SETTING", 123 | "NamedConfig", 124 | {"default": {"value": 1}}, 125 | None, 126 | ), 127 | settings.SettingDef( 128 | "PLUGIN_SETTING", 129 | "NamedPluginConfig", 130 | { 131 | "default": ["myapp.plugins.default", {}], 132 | }, 133 | "Settings for plugin configuration.", 134 | ), 135 | settings.SettingDef( 136 | "ENSURE_LAST_SETTING", 137 | "bool", 138 | True, 139 | None, 140 | ), 141 | ], 142 | None, 143 | ) 144 | -------------------------------------------------------------------------------- /tests/unit/documentation/test_sphinx.py: -------------------------------------------------------------------------------- 1 | from pyapp.documentation import sphinx 2 | 3 | 4 | class TestSettingsDocumenter: 5 | def test_can_document_member(self): 6 | actual = sphinx.SettingsDocumenter.can_document_member(None, "foo", False, None) 7 | 8 | assert actual is False 9 | -------------------------------------------------------------------------------- /tests/unit/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/extensions/__init__.py -------------------------------------------------------------------------------- /tests/unit/extensions/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp.extensions import report 3 | from pyapp.extensions.registry import ExtensionDetail 4 | 5 | from tests.unit.sample_app.__main__ import app 6 | from tests.unit.sample_ext import SampleExtension 7 | from tests.unit.sample_ext_simple import SampleSimpleExtension 8 | 9 | 10 | class MockFile: 11 | def __init__(self): 12 | self.lines = [] 13 | 14 | def write(self, data): 15 | self.lines.append(data) 16 | 17 | 18 | class TestExtensionReport: 19 | @pytest.fixture 20 | def extensions(self): 21 | return [ 22 | ExtensionDetail( 23 | SampleExtension(), "SampleExtension", "Sample Extension", "1.1" 24 | ), 25 | ExtensionDetail( 26 | SampleSimpleExtension(), 27 | "SampleSimpleExtension", 28 | "Sample Simple Extension", 29 | "1.2", 30 | ), 31 | ] 32 | 33 | @pytest.mark.parametrize( 34 | "kwargs, expected", 35 | ( 36 | ({"verbose": False, "no_color": False}, []), 37 | ({"verbose": True, "no_color": False}, []), 38 | ({"verbose": False, "no_color": True}, []), 39 | ({"verbose": True, "no_color": True}, []), 40 | ), 41 | ) 42 | def test_output_result(self, extensions, kwargs, expected): 43 | f_out = MockFile() 44 | target = report.ExtensionReport( 45 | **kwargs, f_out=f_out, extension_registry=extensions 46 | ) 47 | 48 | target.run() 49 | 50 | assert len(f_out.lines) == 2 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "args", 55 | ( 56 | ("extensions",), 57 | ("extensions", "--verbose"), 58 | ("--nocolor", "extensions"), 59 | ("--nocolor", "extensions", "--verbose"), 60 | ), 61 | ) 62 | def test_run_report_from_app(args, exit_code=4): 63 | app.dispatch(args=args) 64 | -------------------------------------------------------------------------------- /tests/unit/factory/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class BarABC(abc.ABC): 5 | @abc.abstractmethod 6 | def is_long(self): 7 | raise NotImplementedError() 8 | 9 | 10 | class Bar(BarABC): 11 | def __init__(self, length): 12 | self.length = length 13 | 14 | def __str__(self): 15 | return "Bar" 16 | 17 | def is_long(self): 18 | return self.length > 30 19 | 20 | 21 | class IronBar(Bar): 22 | def __str__(self): 23 | return "Iron Bar" 24 | 25 | 26 | class SteelBeam: 27 | def __str__(self): 28 | return "Steel Beam" 29 | -------------------------------------------------------------------------------- /tests/unit/mock.py: -------------------------------------------------------------------------------- 1 | class _AnyInstanceOf: 2 | """ 3 | A helper object that compares the type of anything. 4 | 5 | For use with mock when you want a scoped any eg:: 6 | 7 | mock.assert_called_with(InstanceOf(list)) 8 | 9 | """ 10 | 11 | def __init__(self, type_): 12 | self.type = type_ 13 | 14 | def __eq__(self, other): 15 | return isinstance(other, self.type) 16 | 17 | def __ne__(self, other): 18 | return not isinstance(other, self.type) 19 | 20 | def __repr__(self): 21 | return f"" 22 | 23 | __hash__ = None 24 | 25 | 26 | ANY_INSTANCE_OF = _AnyInstanceOf 27 | -------------------------------------------------------------------------------- /tests/unit/sample_app/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.3" 2 | -------------------------------------------------------------------------------- /tests/unit/sample_app/__main__.py: -------------------------------------------------------------------------------- 1 | from pyapp import checks, feature_flags 2 | from pyapp.app import CliApplication 3 | 4 | import tests.unit.sample_app 5 | 6 | app = CliApplication(tests.unit.sample_app) 7 | 8 | 9 | @app.command 10 | def happy(): 11 | print("=o)") 12 | 13 | 14 | @app.command 15 | def sad(): 16 | print("=o(") 17 | return -2 18 | 19 | 20 | @app.command 21 | def cheeky(opts): 22 | print("=oD") 23 | raise KeyboardInterrupt() 24 | 25 | 26 | @app.command 27 | def angry(opts): 28 | print(">=o(") 29 | raise Exception("Grrrr") 30 | 31 | 32 | @app.command 33 | def undecided(): 34 | if feature_flags.get("happy"): 35 | print("=o)") 36 | return 10 37 | 38 | elif feature_flags.get("sad", default=True): 39 | print("=o(") 40 | return 30 41 | 42 | else: 43 | print("=o|") 44 | return 20 45 | 46 | 47 | plain_group = app.create_command_group("plain") 48 | 49 | 50 | @plain_group.command(name="sample") 51 | def plain_group_sample(): 52 | return 1324 53 | 54 | 55 | class ClassGroup: 56 | group = app.create_command_group("class") 57 | 58 | @staticmethod 59 | @group.command 60 | def static(): 61 | return 1332 62 | 63 | @group.command(name="non-static") 64 | def non_static(self, arg1: int): 65 | return 1348 + arg1 66 | 67 | 68 | @checks.register 69 | def critical_check(**_): 70 | return checks.Critical( 71 | "Critical message, that is really really long and should be wrapped across lines. Actually across two no THREE " 72 | "lines! Now that is getting fairly full on! :)", 73 | "Remove critical messages", 74 | ) 75 | 76 | 77 | @checks.register("skippable") 78 | def error_check(**_): 79 | return checks.Error("Error message", obj="App") 80 | 81 | 82 | @checks.register 83 | def double_check(**_): 84 | return ( 85 | checks.Warn("Warn message", "Remove warning messages", obj="App"), 86 | checks.Info( 87 | "Info message", 88 | [ 89 | "Just a tip really message.", 90 | "This is also a multi-paragraph hint as an example of what can be done.", 91 | ], 92 | obj="App", 93 | ), 94 | ) 95 | 96 | 97 | @checks.register 98 | def all_good(**_): 99 | pass 100 | 101 | 102 | @checks.register 103 | def debug_check(**_): 104 | return checks.Debug("Debug message") 105 | 106 | 107 | if __name__ == "__main__": 108 | app.dispatch() 109 | -------------------------------------------------------------------------------- /tests/unit/sample_app/alt_checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/sample_app/alt_checks.py -------------------------------------------------------------------------------- /tests/unit/sample_app/checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/sample_app/checks.py -------------------------------------------------------------------------------- /tests/unit/sample_app/default_settings.py: -------------------------------------------------------------------------------- 1 | SAMPLE_VALUE = "123" 2 | 3 | EXT = ("tests.unit.sample_ext", "tests.unit.sample_ext_simple") 4 | 5 | CHECK_LOCATIONS = ("tests.unit.sample_app.alt_checks",) 6 | -------------------------------------------------------------------------------- /tests/unit/sample_app/logging_settings.py: -------------------------------------------------------------------------------- 1 | LOGGING = { 2 | "odin": { 3 | "level": "ERROR", # Set from command line arg parser. 4 | "handlers": ["console"], 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/unit/sample_app_simple.py: -------------------------------------------------------------------------------- 1 | from pyapp.app import CliApplication 2 | 3 | app = CliApplication() 4 | 5 | 6 | if __name__ == "__main__": 7 | app.dispatch() 8 | -------------------------------------------------------------------------------- /tests/unit/sample_ext/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Sample Extension 3 | """ 4 | 5 | 6 | class SampleExtension: 7 | """ 8 | Sample Extension 9 | """ 10 | 11 | default_settings = ".default_settings" 12 | checks = ".checks" 13 | 14 | ready_called = False 15 | register_commands_called = False 16 | 17 | def ready(self): 18 | self.ready_called = True 19 | 20 | def register_commands(self, group): 21 | self.register_commands_called = group 22 | -------------------------------------------------------------------------------- /tests/unit/sample_ext/checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/sample_ext/checks.py -------------------------------------------------------------------------------- /tests/unit/sample_ext/default_settings.py: -------------------------------------------------------------------------------- 1 | SAMPLE_EXT_VALUE = "123" 2 | -------------------------------------------------------------------------------- /tests/unit/sample_ext_simple/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Sample Simple Extension 3 | """ 4 | 5 | 6 | class SampleSimpleExtension: 7 | """ 8 | Sample Simple Extension 9 | """ 10 | 11 | default_settings = "tests.sample_ext.default_settings" 12 | checks = "tests.sample_ext.checks" 13 | 14 | ready_called = False 15 | register_commands_called = False 16 | -------------------------------------------------------------------------------- /tests/unit/test_compatability.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp import compatability 3 | 4 | 5 | def test_async_run__not_a_coroutine(): 6 | """ 7 | Given a function that is not async raise a ValueError 8 | """ 9 | 10 | def example(): 11 | pass 12 | 13 | with pytest.raises((ValueError, TypeError)): 14 | compatability.async_run(example) 15 | -------------------------------------------------------------------------------- /tests/unit/test_feature_flags.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from pyapp import feature_flags 5 | from pyapp.conf import settings 6 | 7 | 8 | class FeatureFlagsWrapper(feature_flags.FeatureFlags): 9 | pass 10 | 11 | 12 | class TestFeatureFlags: 13 | @pytest.mark.parametrize( 14 | "flag, expected", 15 | ( 16 | ("enable-a", True), 17 | ("Enable b", False), 18 | ("ENABLE_C", None), 19 | ), 20 | ) 21 | def test_resolve_from_environment(self, monkeypatch, flag, expected): 22 | monkeypatch.setenv("PYAPP_FLAG_ENABLE_A", "On") 23 | monkeypatch.setenv("PYAPP_FLAG_ENABLE_B", "false") 24 | 25 | actual = feature_flags.DEFAULT._resolve_from_environment(flag) 26 | 27 | assert actual is expected 28 | 29 | @pytest.mark.parametrize( 30 | "flag, expected", 31 | ( 32 | ("enable-a", True), 33 | ("Enable b", False), 34 | ("ENABLE_C", None), 35 | ), 36 | ) 37 | def test_resolve_from_settings(self, flag, expected): 38 | with settings.modify() as patch: 39 | patch.FEATURE_FLAGS = { 40 | "enable-a": True, 41 | "Enable b": False, 42 | } 43 | 44 | actual = feature_flags.DEFAULT._resolve_from_settings(flag) 45 | 46 | assert actual is expected 47 | 48 | @pytest.mark.parametrize( 49 | "flag, expected", 50 | ( 51 | ("enable-a", True), 52 | ("Enable b", True), 53 | ("Enable_C", True), 54 | ("Enable-D", False), 55 | ), 56 | ) 57 | def test_resolve(self, monkeypatch, flag, expected): 58 | monkeypatch.setenv("PYAPP_FLAG_ENABLE_A", "On") 59 | monkeypatch.setenv("PYAPP_FLAG_ENABLE_B", "true") 60 | 61 | with settings.modify() as patch: 62 | patch.FEATURE_FLAGS = { 63 | "enable-b": False, 64 | "Enable_C": True, 65 | } 66 | 67 | actual = feature_flags.DEFAULT._resolve(flag, False) 68 | 69 | assert actual is expected 70 | 71 | def test_get__where_feature_flag_is_cached(self): 72 | target = FeatureFlagsWrapper() 73 | target._cache["EnableD"] = "Mock_Value" 74 | target._resolve = Mock(return_value="Mock_Value") 75 | 76 | actual = target.get("EnableD") 77 | 78 | assert actual == "Mock_Value" 79 | target._resolve.assert_not_called() 80 | 81 | def test_get__where_feature_flag_is_not_cached(self): 82 | target = FeatureFlagsWrapper() 83 | target._resolve = Mock(return_value="Mock_Value") 84 | 85 | actual = target.get("EnableD") 86 | 87 | assert actual == "Mock_Value" 88 | target._resolve.assert_called_once_with("EnableD", False) 89 | 90 | def test_set(self): 91 | target = feature_flags.FeatureFlags() 92 | 93 | target.set("EnableA", True) 94 | 95 | assert target._cache["EnableA"] is True 96 | 97 | @pytest.mark.parametrize( 98 | "option_a, option_b, state, expected", 99 | ( 100 | ("A-Value", "B-Value", True, "A-Value"), 101 | ("A-Value", "B-Value", False, "B-Value"), 102 | (lambda: "A-Value", lambda: "B-Value", True, "A-Value"), 103 | (lambda: "A-Value", lambda: "B-Value", False, "B-Value"), 104 | ), 105 | ) 106 | def test_a_or_b(self, option_a, option_b, state, expected): 107 | target = FeatureFlagsWrapper() 108 | target._get = Mock(return_value=state) 109 | 110 | actual = target.a_or_b("EnableE", option_a, option_b) 111 | 112 | assert actual == expected 113 | 114 | @pytest.mark.parametrize( 115 | "state, expected", 116 | ( 117 | (True, "ValueA"), 118 | (False, None), 119 | ), 120 | ) 121 | def test_if_enabled__where_return_is_default(self, state, expected): 122 | target = FeatureFlagsWrapper() 123 | target._get = Mock(return_value=state) 124 | 125 | @target.if_enabled("EnableF") 126 | def _sample(arg_a): 127 | return arg_a 128 | 129 | actual = _sample("ValueA") 130 | 131 | assert actual == expected 132 | 133 | @pytest.mark.parametrize( 134 | "state, expected", 135 | ( 136 | (True, "ValueA"), 137 | (False, "ValueB"), 138 | ), 139 | ) 140 | def test_if_enabled__where_return_is_customised(self, state, expected): 141 | target = FeatureFlagsWrapper() 142 | target._get = Mock(return_value=state) 143 | 144 | @target.if_enabled("EnableF", disabled_return="ValueB") 145 | def _sample(arg_a): 146 | return arg_a 147 | 148 | actual = _sample("ValueA") 149 | 150 | assert actual == expected 151 | 152 | def test_if_enabled__where_is_wrapped_correctly(self): 153 | target = FeatureFlagsWrapper() 154 | target._get = Mock(return_value=True) 155 | 156 | @target.if_enabled("FOO") 157 | def foo(): 158 | """Sample""" 159 | 160 | assert foo.__doc__ == "Sample" 161 | 162 | def test_modify__change_a_flag_state(self): 163 | target = FeatureFlagsWrapper() 164 | target._cache["foo"] = True 165 | target._cache["bar"] = False 166 | 167 | with target.modify() as patch: 168 | patch["foo"] = False 169 | patch["bar"] = True 170 | 171 | assert target.get("foo") is False 172 | assert target.get("bar", default=False) is True 173 | 174 | assert target.get("foo") is True 175 | assert target.get("bar") is False 176 | 177 | def test__add_a_flag_state(self): 178 | target = FeatureFlagsWrapper() 179 | target._cache["foo"] = True 180 | 181 | with target.modify() as patch: 182 | patch["foo"] = False 183 | patch["bar"] = True 184 | 185 | assert target.get("foo") is False 186 | assert target.get("bar", default=False) is True 187 | 188 | assert target.get("foo") is True 189 | assert target.get("bar", default=False) is False 190 | -------------------------------------------------------------------------------- /tests/unit/test_injection.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | import pytest 4 | from pyapp import injection 5 | 6 | 7 | class ThingBase(abc.ABC): 8 | @abc.abstractmethod 9 | def do_stuff(self, item: str): 10 | pass 11 | 12 | 13 | class AThing(ThingBase): 14 | def do_stuff(self, item: str): 15 | print(f"Doing A to {item}") 16 | 17 | 18 | class BThing(ThingBase): 19 | def do_stuff(self, item: str): 20 | print(f"Doing B to {item}") 21 | 22 | 23 | class CThing(ThingBase): 24 | def __init__(self): 25 | raise hell # noqa: F821 26 | 27 | def do_stuff(self, item: str): 28 | pass 29 | 30 | 31 | def thing_factory(name: str = None) -> ThingBase: 32 | name = name or "default" 33 | return {"default": AThing, "b": BThing, "c": CThing}[name]() 34 | 35 | 36 | local_registry = injection.FactoryRegistry() 37 | local_registry.register(ThingBase, thing_factory) 38 | 39 | 40 | class TestFactoryRegistry: 41 | def test_register(self): 42 | target = injection.FactoryRegistry() 43 | target.register(ThingBase, thing_factory) 44 | 45 | assert len(target) == 1 46 | 47 | @pytest.mark.parametrize( 48 | "abstract_type, expected", 49 | ((None, None), (ThingBase, thing_factory), ("abc", None), (123, None)), 50 | ) 51 | def test_resolve(self, abstract_type, expected): 52 | actual = local_registry.resolve(abstract_type) 53 | 54 | assert actual is expected 55 | 56 | def test_modify(self): 57 | with local_registry.modify() as patch: 58 | mock = patch.mock_type(ThingBase) 59 | 60 | assert local_registry.resolve(ThingBase)() is mock 61 | 62 | assert local_registry.resolve(ThingBase)() is not mock 63 | 64 | 65 | def test_inject(): 66 | actual = None 67 | 68 | @injection.inject(from_registry=local_registry) 69 | def get_value(value: ThingBase): 70 | nonlocal actual 71 | actual = value 72 | 73 | get_value() 74 | 75 | assert isinstance(actual, AThing) 76 | 77 | 78 | def test_inject__no_dependencies(): 79 | actual = None 80 | 81 | @injection.inject(from_registry=local_registry) 82 | def get_value(value: str): 83 | nonlocal actual 84 | actual = value 85 | 86 | get_value("123") 87 | 88 | assert actual == "123" 89 | 90 | 91 | def test_inject__override(): 92 | actual = None 93 | 94 | @injection.inject(from_registry=local_registry) 95 | def get_value(*, value: ThingBase): 96 | nonlocal actual 97 | actual = value 98 | 99 | get_value(value=BThing()) 100 | 101 | assert isinstance(actual, BThing) 102 | 103 | 104 | def test_inject__with_args(): 105 | actual = None 106 | 107 | @injection.inject(from_registry=local_registry) 108 | def get_value(*, value: ThingBase = injection.Args("b")): # noqa: B008 109 | nonlocal actual 110 | actual = value 111 | 112 | get_value() 113 | 114 | assert isinstance(actual, BThing) 115 | 116 | 117 | def test_inject__with_factory_args_and_not_kwarg(): 118 | with pytest.raises(injection.InjectionSetupError): 119 | 120 | @injection.inject() 121 | def get_value(value: ThingBase = injection.Args("*")): # noqa: B008 122 | pass 123 | 124 | 125 | def test_inject__with_factory_args_and_no_type_annotation(): 126 | with pytest.raises(injection.InjectionSetupError): 127 | 128 | @injection.inject() 129 | def get_value(*, value=injection.Args("*")): # noqa: B008 130 | pass 131 | 132 | 133 | def test_inject__factory_raises_error(): 134 | """ 135 | Error generated constructing a object 136 | """ 137 | 138 | @injection.inject(from_registry=local_registry) 139 | def get_value(*, value: ThingBase = injection.Args("c")): # noqa: B008 140 | pass 141 | 142 | with pytest.raises(injection.InjectionError): 143 | get_value() 144 | -------------------------------------------------------------------------------- /tests/unit/test_multiprocessing.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pyapp.conf 4 | import pytest 5 | from pyapp import multiprocessing 6 | 7 | 8 | def test_roundtrip_settings(monkeypatch): 9 | source_settings = pyapp.conf.Settings() 10 | source_settings.__dict__["FOO"] = "foo" 11 | source_settings.__dict__["BAR"] = "bar" 12 | source_settings.SETTINGS_SOURCES.append("self") 13 | with patch("pyapp.conf.settings", source_settings): 14 | pickled_settings = multiprocessing.prepare_settings() 15 | 16 | target_settings = pyapp.conf.Settings() 17 | with patch("pyapp.conf.settings", target_settings): 18 | multiprocessing.pyapp_initializer(pickled_settings, None, ()) 19 | 20 | assert target_settings.FOO == source_settings.FOO 21 | assert target_settings.BAR == source_settings.BAR 22 | assert target_settings.SETTINGS_SOURCES == source_settings.SETTINGS_SOURCES 23 | 24 | 25 | def test_roundtrip_settings__with_initializer(monkeypatch): 26 | mock_initializer = Mock() 27 | 28 | source_settings = pyapp.conf.Settings() 29 | source_settings.__dict__["FOO"] = "foo" 30 | source_settings.SETTINGS_SOURCES.append("self") 31 | with patch("pyapp.conf.settings", source_settings): 32 | pickled_settings = multiprocessing.prepare_settings() 33 | 34 | target_settings = pyapp.conf.Settings() 35 | with patch("pyapp.conf.settings", target_settings): 36 | multiprocessing.pyapp_initializer(pickled_settings, mock_initializer, (1, 2)) 37 | 38 | assert target_settings.FOO == source_settings.FOO 39 | assert target_settings.SETTINGS_SOURCES == source_settings.SETTINGS_SOURCES 40 | 41 | mock_initializer.assert_called_with(1, 2) 42 | 43 | 44 | class TestPool: 45 | def test_call_pool_and_with_invalid_initializer(self): 46 | with pytest.raises(TypeError): 47 | pyapp.multiprocessing.Pool( 48 | processes=2, initializer=123, initargs=("a", "b") 49 | ) 50 | -------------------------------------------------------------------------------- /tests/unit/test_pyapp.py: -------------------------------------------------------------------------------- 1 | import pyapp 2 | 3 | 4 | def test_metadata(): 5 | assert pyapp.__author__.startswith("Tim") 6 | -------------------------------------------------------------------------------- /tests/unit/test_typed_settings.py: -------------------------------------------------------------------------------- 1 | from ..settings import MySettings, MyPrefixedSettings 2 | 3 | 4 | def test_access_settings(): 5 | assert MySettings.UPPER_VALUE == "foo" 6 | 7 | 8 | def test_access_settings__where_setting_modified(patch_settings): 9 | patch_settings.UPPER_VALUE = "bar" 10 | 11 | assert MySettings.UPPER_VALUE == "bar" 12 | 13 | 14 | def test_access_settings__where_settings_has_a_prefix(settings): 15 | assert settings.FOO_SETTING_1 == "my-prefixed-setting" 16 | assert MyPrefixedSettings.SETTING_1 == "my-prefixed-setting" 17 | assert MySettings.SETTING_1 == 1 18 | 19 | -------------------------------------------------------------------------------- /tests/unit/test_versioning.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import patch 3 | 4 | from pyapp import versioning 5 | 6 | 7 | @patch("pyapp.versioning.metadata.distribution") 8 | def test_get_installed_version__found(distribution, monkeypatch): 9 | """ 10 | Package found with the correct location 11 | """ 12 | distribution.return_value = mock.Mock( 13 | spec=versioning.metadata.Distribution, version="1.2.3" 14 | ) 15 | 16 | actual = versioning.get_installed_version( 17 | "my_package", "/path/to/my_package/__init__.py" 18 | ) 19 | 20 | assert actual == "1.2.3" 21 | distribution.assert_called_once_with("my_package") 22 | 23 | 24 | @patch("pyapp.versioning.metadata.distribution") 25 | def test_get_installed_version__not_found(distribution, monkeypatch): 26 | """ 27 | Package not found 28 | """ 29 | distribution.side_effect = versioning.metadata.PackageNotFoundError() 30 | 31 | actual = versioning.get_installed_version( 32 | "my_package", "/path/to/my_package/__init__.py" 33 | ) 34 | 35 | assert actual == "Please install my_package via a package." 36 | distribution.assert_called_once_with("my_package") 37 | -------------------------------------------------------------------------------- /tests/unit/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/testing/__init__.py -------------------------------------------------------------------------------- /tests/unit/testing/test_.py: -------------------------------------------------------------------------------- 1 | from pyapp import testing 2 | 3 | 4 | def test_settings_in_module(): 5 | from pyapp.conf import base_settings 6 | 7 | actual = testing.settings_in_module(base_settings) 8 | 9 | assert actual == { 10 | "DEBUG", 11 | "LOG_LOGGERS", 12 | "LOG_HANDLERS", 13 | "LOGGING", 14 | "CHECK_LOCATIONS", 15 | "FEATURE_FLAGS", 16 | "FEATURE_FLAG_PREFIX", 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/testing/test_pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from pyapp import feature_flags 4 | 5 | 6 | def test_settings_fixture(settings, patch_settings): 7 | patch_settings.FOO = "bar" 8 | 9 | assert settings.FOO == "bar" 10 | 11 | 12 | def test_feature_flags_fixture(patch_feature_flags): 13 | patch_feature_flags["foo"] = True 14 | 15 | assert feature_flags.get("foo") is True 16 | 17 | 18 | def test_injection_fixture(patch_injection, pyapp_factory_registry): 19 | class MyType(abc.ABC): # noqa B024 20 | pass 21 | 22 | mock = patch_injection.mock_type(MyType, allow_missing=True) 23 | 24 | assert pyapp_factory_registry[MyType]() is mock 25 | 26 | 27 | def test_check_registry(pytester): 28 | pytester.makepyfile( 29 | """ 30 | from pyapp import checks 31 | 32 | def test_sample(check_registry): 33 | assert checks.registry is check_registry 34 | """ 35 | ) 36 | 37 | result = pytester.runpytest() 38 | result.assert_outcomes(passed=1) 39 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-org/pyapp/6b18337271fbfd162d01b93fd93141766e73fc76/tests/unit/utils/__init__.py -------------------------------------------------------------------------------- /tests/unit/utils/test_.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyapp import utils 3 | 4 | 5 | class IteratorTest: 6 | def __iter__(self): 7 | yield 1 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("instance", "is_iterable"), 12 | ((None, False), (123, False), ("Foo", True), ([], True), (IteratorTest(), True)), 13 | ) 14 | def test_is_iterable(instance, is_iterable): 15 | assert utils.is_iterable(instance) == is_iterable 16 | 17 | 18 | class CachedPropertyTest: 19 | def __init__(self, a, b): 20 | self.backing_a = a 21 | 22 | @utils.cached_property 23 | def a(self): 24 | return self.backing_a 25 | 26 | 27 | def test_cached_property(): 28 | target = CachedPropertyTest("foo", 123) 29 | 30 | assert target.a == "foo" 31 | target.backing_a = "bar" 32 | assert target.a == "foo" 33 | del target.a 34 | assert target.a == "bar" 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "value, expected", 39 | ( 40 | ("yes", True), 41 | ("True", True), 42 | ("y", True), 43 | ("t", True), 44 | ("ON", True), 45 | ("1", True), 46 | ("", False), 47 | ("FALSE", False), 48 | ("Off", False), 49 | (None, False), 50 | ), 51 | ) 52 | def test_text_to_bool(value, expected): 53 | actual = utils.text_to_bool(value) 54 | 55 | assert actual == expected 56 | 57 | 58 | class TestAllowBlockFilter: 59 | @pytest.mark.parametrize( 60 | "allow_list, block_list, value, expected", 61 | ( 62 | # Allow all 63 | (None, None, "foo", True), 64 | # Allow/Block specific 65 | (["foo"], None, "foo", True), 66 | (["foo"], None, "bar", False), 67 | (None, ["foo"], "foo", False), 68 | (None, ["foo"], "bar", True), 69 | # Allow/Block glob 70 | (["foo*"], None, "foo", True), 71 | (["foo*"], None, "foobar", True), 72 | (["foo*"], None, "bar", False), 73 | (None, ["foo*"], "foo", False), 74 | (None, ["foo*"], "foobar", False), 75 | (None, ["foo*"], "bar", True), 76 | # Combined 77 | (["foo*"], ["bar*"], "foo", True), 78 | (["foo*"], ["bar*"], "foobar", True), 79 | (["foo*"], ["bar*"], "bar", False), 80 | (["foo*"], ["bar*"], "barfoo", False), 81 | (["foo*"], ["bar*"], "eek", False), 82 | ), 83 | ) 84 | def test_filtering(self, allow_list, block_list, value, expected): 85 | target = utils.AllowBlockFilter(allow_list=allow_list, block_list=block_list) 86 | 87 | assert target(value) is expected 88 | -------------------------------------------------------------------------------- /tests/unit/utils/test_compatibility.py: -------------------------------------------------------------------------------- 1 | from pyapp.utils import compatibility 2 | 3 | 4 | def test_deprecated__function(recwarn): 5 | @compatibility.deprecated("Gone in version x.y") 6 | def my_function(): 7 | return "abc" 8 | 9 | # Assert warning not raised 10 | assert len(recwarn) == 0 11 | 12 | value = my_function() 13 | assert value == "abc" 14 | 15 | # Assert warning raised 16 | assert len(recwarn) == 1 17 | assert recwarn.pop(DeprecationWarning) 18 | 19 | 20 | def test_deprecated__class(recwarn): 21 | @compatibility.deprecated("Gone in version x.y") 22 | class MyClass: 23 | pass 24 | 25 | # Assert warning not raised 26 | assert len(recwarn) == 0 27 | 28 | instance = MyClass() 29 | assert isinstance(instance, MyClass) 30 | 31 | # Assert warning raised 32 | assert len(recwarn) == 1 33 | assert recwarn.pop(DeprecationWarning) 34 | -------------------------------------------------------------------------------- /tests/unit/utils/test_inspect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import mock 3 | 4 | import pytest 5 | from pyapp.utils import inspect 6 | 7 | import tests 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "path, expected", 12 | ( 13 | (Path(inspect.__file__), "pyapp"), 14 | (Path(__file__), "tests"), 15 | (Path(__file__).parent, "tests"), 16 | ), 17 | ) 18 | def test_find_root_folder__in_package(path: Path, expected: str): 19 | actual = inspect.find_root_folder(path) 20 | assert actual.name == expected 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "path", (Path(__file__) / "../fixtures", Path(__file__) / "../fixtures/my_file.py") 25 | ) 26 | def test_find_root_folder__outside_package(path): 27 | with pytest.raises(ValueError, match="File not part of a package"): 28 | inspect.find_root_folder(path) 29 | 30 | 31 | def test_import_root_module__current_module(): 32 | actual = inspect.import_root_module(1) 33 | 34 | assert actual is tests 35 | 36 | 37 | def test_import_root_module__single_file(monkeypatch): 38 | stack_mock = mock.Mock() 39 | stack_mock.frame.f_globals = {"__name__": "__main__", "__file__": "/foo/bar.py"} 40 | monkeypatch.setattr( 41 | inspect.inspect, "stack", mock.Mock(return_value=[None, None, stack_mock]) 42 | ) 43 | monkeypatch.setattr( 44 | inspect, "find_root_folder", mock.Mock(side_effect=ValueError("EEK!")) 45 | ) 46 | 47 | actual = inspect.import_root_module() 48 | 49 | assert actual == __import__("__main__") 50 | 51 | 52 | def test_import_root_module__unknown(monkeypatch): 53 | stack_mock = mock.Mock() 54 | stack_mock.frame.f_globals = {"__name__": "foo", "__file__": "/foo/bar.py"} 55 | monkeypatch.setattr(inspect.inspect, "stack", mock.Mock(return_value=[stack_mock])) 56 | monkeypatch.setattr( 57 | inspect, "find_root_folder", mock.Mock(side_effect=ValueError("EEK!")) 58 | ) 59 | 60 | with pytest.raises(RuntimeError, match="Unable to determine root module"): 61 | inspect.import_root_module(-1) 62 | --------------------------------------------------------------------------------