├── .git_archival.txt ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── pypi-package.yml │ └── zizmor.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version-default ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── NOTICE ├── README.md ├── conftest.py ├── docs ├── Makefile ├── asyncio.md ├── conf.py ├── index.md ├── installation.md └── twisted.md ├── oldest-supported.txt ├── pyproject.toml ├── src └── prometheus_async │ ├── __init__.py │ ├── aio │ ├── __init__.py │ ├── _decorators.py │ ├── sd.py │ └── web.py │ ├── py.typed │ ├── tx │ ├── __init__.py │ └── _decorators.py │ └── types.py ├── tests ├── __init__.py ├── test_aio.py ├── test_packaging.py ├── test_tx.py └── typing │ └── api.py ├── tox.ini └── zizmor.yml /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: $Format:%H$ 2 | node-date: $Format:%cI$ 3 | describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ 4 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socioeconomic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | . 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | First off, thank you for considering contributing to *prometheus-async*! 4 | It's people like *you* who make it such a great tool for everyone. 5 | 6 | This document intends to make contribution more accessible by codifying tribal knowledge and expectations. 7 | Don't be afraid to open half-finished PRs, and ask questions if something is unclear! 8 | 9 | Please note that this project is released with a Contributor [Code of Conduct](https://github.com/hynek/prometheus-async/blob/main/.github/CODE_OF_CONDUCT.md). 10 | By participating in this project you agree to abide by its terms. 11 | Please report any harm to [Hynek Schlawack] in any way you find appropriate. 12 | 13 | 14 | ## Workflow 15 | 16 | - No contribution is too small! 17 | Please submit as many fixes for typos and grammar bloopers as you can! 18 | - Try to limit each pull request to *one* change only. 19 | - Since we squash on merge, it's up to you how you handle updates to the main branch. 20 | Whether you prefer to rebase on main or merge main into your branch, do whatever is more comfortable for you. 21 | - *Always* add tests and docs for your code. 22 | This is a hard rule; patches with missing tests or documentation can't be merged. 23 | - Make sure your changes pass our [CI]. 24 | You won't get any feedback until it's green unless you ask for it. 25 | - For the CI to pass, the coverage must be 100%. 26 | If you have problems to test something, open anyway and ask for advice. 27 | In some situations, we may agree to add an `# pragma: no cover`. 28 | - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. 29 | - Don’t break backwards-compatibility. 30 | 31 | 32 | ## Local Development Environment 33 | 34 | You can (and should) run our test suite using [*tox*]. 35 | However, you’ll probably want a more traditional environment as well. 36 | 37 | First, create a [virtual environment](https://virtualenv.pypa.io/) so you don't break your system-wide Python installation. 38 | We recommend using the Python version from the `.python-version-default` file in project's root directory. 39 | 40 | If you're using [*direnv*](https://direnv.net), you can automate the creation of a virtual environment with the correct Python version by adding the following `.envrc` to the project root: 41 | 42 | ```bash 43 | layout python python$(cat .python-version-default) 44 | ``` 45 | 46 | [Create a fork](https://github.com/hynek/prometheus-async/fork) of the repository and clone it: 47 | 48 | ```console 49 | $ git clone git@github.com:YOU/prometheus-async.git 50 | ``` 51 | 52 | Or if you prefer to use Git via HTTPS: 53 | 54 | ```console 55 | $ git clone https://github.com/YOU/prometheus-async.git 56 | ``` 57 | 58 | > [!WARNING] 59 | > - **Before** you start working on a new pull request, use the "*Sync fork*" button in GitHub's web UI to ensure your fork is up to date. 60 | > - **Always create a new branch off `main` for each new pull request.** 61 | > Yes, you can work on `main` in your fork and submit pull requests. 62 | > But this will *inevitably* lead to you not being able to synchronize your fork with upstream and having to start over. 63 | 64 | Change into the newly created directory and **after activating your virtual environment** install it with its tests requirements: 65 | 66 | ```console 67 | $ cd prometheus-async 68 | $ python -Im pip install --upgrade pip # PLEASE don't skip this step 69 | $ python -Im pip install --editable . --group dev 70 | ``` 71 | 72 | At this point, 73 | 74 | ```console 75 | $ python -Im pytest 76 | ``` 77 | 78 | should work and pass. 79 | 80 | When working on the documentation, use: 81 | 82 | ```console 83 | $ tox run -e docs-watch 84 | ``` 85 | 86 | ... to watch your files and automatically rebuild when a file changes. 87 | And use: 88 | 89 | ```console 90 | $ tox run -e docs 91 | ``` 92 | 93 | ... to build it once and run our doctests. 94 | 95 | The built documentation can then be found in `docs/_build/html/`. 96 | 97 | --- 98 | 99 | To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] and its hooks: 100 | 101 | ```console 102 | $ pre-commit install 103 | ``` 104 | 105 | This is not strictly necessary, because our [*tox*] file contains an environment that runs: 106 | 107 | ```console 108 | $ pre-commit run --all-files 109 | ``` 110 | 111 | and our CI has integration with `pre-commit.ci `_. 112 | But it's way more comfortable to run it locally and *git* catching avoidable errors. 113 | 114 | 115 | ## Code 116 | 117 | - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). 118 | We use the `"""`-on-separate-lines style for docstrings: 119 | 120 | ```python 121 | def func(x): 122 | """ 123 | Do something. 124 | 125 | :param str x: A very important parameter. 126 | 127 | :rtype: str 128 | """ 129 | ``` 130 | 131 | - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. 132 | 133 | - We use [Ruff](https://ruff.rs/) to sort our imports and format our code with a line length of 79 characters. 134 | As long as you run our full [*tox*] suite before committing, or install our [*pre-commit*] hooks (ideally you'll do both – see [*Local Development Environment*](#local-development-environment) above), you won't have to spend any time on formatting your code at all. 135 | If you don't, [CI] will catch it for you – but that seems like a waste of your time! 136 | 137 | 138 | ## Tests 139 | 140 | - Write your asserts as `expected == actual` to line them up nicely: 141 | 142 | ```python 143 | x = f() 144 | 145 | assert 42 == x.some_attribute 146 | assert "foo" == x._a_private_attribute 147 | ``` 148 | 149 | - To run the test suite, all you need is a recent [*tox*]. 150 | It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. 151 | If you lack some Python versions, you can can always limit the environments like `tox -e py38,py39`, or make it a non-failure using `tox --skip-missing-interpreters`. 152 | 153 | In that case you should look into [*asdf*](https://asdf-vm.com) or [*pyenv*](https://github.com/pyenv/pyenv), which make it very easy to install many different Python versions in parallel. 154 | - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). 155 | 156 | 157 | ## Documentation 158 | 159 | - Use [semantic newlines] in [*Markdown*](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) files (files ending in `.md`): 160 | 161 | ```markdown 162 | This is a sentence. 163 | This is another sentence. 164 | ``` 165 | 166 | 167 | ### Changelog 168 | 169 | If your change is noteworthy, there needs to be a changelog entry in `CHANGELOG.md`. 170 | 171 | - The changelog follows the [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) standard. 172 | Please add the best-fitting section if it's missing for the current release. 173 | We use the following order: `Security`, `Removed`, `Deprecated`, `Added`, `Changed`, `Fixed`. 174 | - As with other docs, please use [semantic newlines] in the changelog. 175 | - Make the last line a link to your pull request. 176 | You probably have to open it first to know the number. 177 | - Wrap symbols like modules, functions, or classes into backticks so they are rendered in a `monospace font`. 178 | - Wrap arguments into asterisks like in docstrings: 179 | `Added new argument *an_argument*.` 180 | - If you mention functions or other callables, add parentheses at the end of their names: 181 | `prometheus_async.func()` or `prometheus_async.Class.method()`. 182 | This makes the changelog a lot more readable. 183 | - Prefer simple past tense or constructions with "now". 184 | For example: 185 | 186 | * Added `prometheus_async.func()`. 187 | * `prometheus_async.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 188 | 189 | 190 | #### Example entries 191 | 192 | ```markdown 193 | Added `prometheus_async.func()`. 194 | The feature really *is* awesome. 195 | ``` 196 | 197 | or: 198 | 199 | ```markdown 200 | `prometheus_async.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 201 | The bug really *was* nasty. 202 | ``` 203 | 204 | 205 | [CI]: https://github.com/hynek/prometheus-async/actions 206 | [Hynek Schlawack]: https://hynek.me/about/ 207 | [*pre-commit*]: https://pre-commit.com/ 208 | [*tox*]: https://https://tox.wiki/ 209 | [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ 210 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: hynek 3 | tidelift: pypi/prometheus-async 4 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We are following [Calendar Versioning](https://calver.org) with generous backwards-compatibility guarantees. 6 | Therefore we only support the latest version. 7 | 8 | Put simply, you shouldn't ever be afraid to upgrade as long as you're only using our public APIs. 9 | Whenever there is a need to break compatibility, it is announced in the changelog, and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. 10 | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). 15 | Tidelift will coordinate the fix and disclosure. 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | workflow_dispatch: 10 | 11 | env: 12 | FORCE_COLOR: "1" # Make tools pretty. 13 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 14 | PIP_NO_PYTHON_VERSION_WARNING: "1" 15 | SETUPTOOLS_SCM_PRETEND_VERSION: "1.0" # avoid warnings about shallow checkout 16 | 17 | permissions: {} 18 | 19 | 20 | jobs: 21 | build-package: 22 | name: Build & verify package 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | persist-credentials: false 30 | 31 | - uses: hynek/build-and-inspect-python-package@v2 32 | id: baipp 33 | 34 | outputs: 35 | # Used to define the matrix for tests below. The value is based on 36 | # packaging metadata (trove classifiers). 37 | supported-python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} 38 | 39 | 40 | tests: 41 | name: Tests & Mypy API on ${{ matrix.python-version }} 42 | runs-on: ubuntu-latest 43 | needs: build-package 44 | 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | # Created by the build-and-inspect-python-package action above. 49 | python-version: ${{ fromJson(needs.build-package.outputs.supported-python-versions) }} 50 | 51 | env: 52 | PYTHON: ${{ matrix.python-version }} 53 | 54 | steps: 55 | - name: Download pre-built packages 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: Packages 59 | path: dist 60 | - run: | 61 | rm -rf src 62 | tar xf dist/*.tar.gz --strip-components=1 63 | - uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | allow-prereleases: true 67 | - uses: hynek/setup-cached-uv@v2 68 | 69 | - run: > 70 | uvx --with tox-uv 71 | tox run 72 | --installpkg dist/*.whl 73 | -f py$(echo $PYTHON | tr -d .) 74 | - run: > 75 | uvx --with tox-uv --python $PYTHON 76 | tox run 77 | --installpkg dist/*.whl 78 | -e mypy-api 79 | 80 | - name: Upload coverage data 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: coverage-data-${{ matrix.python-version }} 84 | path: .coverage.* 85 | include-hidden-files: true 86 | if-no-files-found: ignore 87 | 88 | 89 | coverage: 90 | name: Combine & check coverage 91 | needs: tests 92 | runs-on: ubuntu-latest 93 | 94 | steps: 95 | - uses: actions/checkout@v4 96 | with: 97 | persist-credentials: false 98 | - uses: actions/setup-python@v5 99 | with: 100 | python-version-file: .python-version-default 101 | - uses: hynek/setup-cached-uv@v2 102 | 103 | - uses: actions/download-artifact@v4 104 | with: 105 | pattern: coverage-data-* 106 | merge-multiple: true 107 | 108 | - name: Combine coverage & fail if it's <100%. 109 | run: | 110 | uv tool install coverage 111 | 112 | coverage combine 113 | coverage html --skip-covered --skip-empty 114 | 115 | # Report and write to summary. 116 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 117 | 118 | # Report again and fail if under 100%. 119 | coverage report --fail-under=100 120 | 121 | - name: Upload HTML report if check failed. 122 | uses: actions/upload-artifact@v4 123 | with: 124 | name: html-report 125 | path: htmlcov 126 | if: ${{ failure() }} 127 | 128 | 129 | mypy-pkg: 130 | name: Mypy Codebase 131 | runs-on: ubuntu-latest 132 | 133 | steps: 134 | - uses: actions/checkout@v4 135 | with: 136 | persist-credentials: false 137 | - uses: actions/setup-python@v5 138 | with: 139 | python-version-file: .python-version-default 140 | - uses: hynek/setup-cached-uv@v2 141 | 142 | - run: > 143 | uvx --with tox-uv 144 | tox run -e mypy-pkg 145 | 146 | 147 | docs: 148 | name: Run doctests 149 | runs-on: ubuntu-latest 150 | steps: 151 | - uses: actions/checkout@v4 152 | with: 153 | persist-credentials: false 154 | - uses: actions/setup-python@v5 155 | with: 156 | # Keep in sync with tox.ini/docs & .readthedocs.yaml 157 | python-version: "3.13" 158 | - uses: hynek/setup-cached-uv@v2 159 | 160 | - run: > 161 | uvx --with tox-uv 162 | tox run -e docs-doctests 163 | 164 | pyright: 165 | name: Check types using pyright 166 | runs-on: ubuntu-latest 167 | steps: 168 | - uses: actions/checkout@v4 169 | with: 170 | persist-credentials: false 171 | - uses: actions/setup-python@v5 172 | with: 173 | python-version-file: .python-version-default 174 | - uses: hynek/setup-cached-uv@v2 175 | 176 | - run: uvx --with=tox-uv tox run -e pyright 177 | 178 | install-dev: 179 | name: Verify dev env 180 | runs-on: ${{ matrix.os }} 181 | strategy: 182 | matrix: 183 | os: [ubuntu-latest, windows-latest] 184 | 185 | steps: 186 | - uses: actions/checkout@v4 187 | with: 188 | persist-credentials: false 189 | - uses: actions/setup-python@v5 190 | with: 191 | python-version-file: .python-version-default 192 | 193 | - run: python -Im pip install -e . --group dev 194 | - run: python -Ic 'import prometheus_async; print(prometheus_async.__version__)' 195 | 196 | 197 | required-checks-pass: 198 | name: Ensure everything required is passing for branch protection 199 | if: always() 200 | 201 | needs: 202 | - coverage 203 | - docs 204 | - install-dev 205 | - mypy-pkg 206 | 207 | runs-on: ubuntu-latest 208 | 209 | steps: 210 | - name: Decide whether the needed jobs succeeded or failed 211 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 212 | with: 213 | jobs: ${{ toJSON(needs) }} 214 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | on: 5 | schedule: 6 | - cron: "30 22 * * 4" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [python] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v3 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v3 41 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & upload PyPI package 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | 14 | jobs: 15 | # Always build & lint package. 16 | build-package: 17 | name: Build & verify package 18 | runs-on: ubuntu-latest 19 | permissions: 20 | attestations: write 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@v2 30 | with: 31 | attest-build-provenance-github: 'true' 32 | 33 | 34 | # Upload to Test PyPI on every commit on main. 35 | release-test-pypi: 36 | name: Publish in-dev package to test.pypi.org 37 | environment: release-test-pypi 38 | if: github.repository_owner == 'hynek' && github.event_name == 'push' && github.ref == 'refs/heads/main' 39 | runs-on: ubuntu-latest 40 | needs: build-package 41 | 42 | permissions: 43 | id-token: write 44 | 45 | steps: 46 | - name: Download packages built by build-and-inspect-python-package 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: Packages 50 | path: dist 51 | 52 | - name: Upload package to Test PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | with: 55 | attestations: true 56 | repository-url: https://test.pypi.org/legacy/ 57 | 58 | 59 | # Upload to real PyPI on GitHub Releases. 60 | release-pypi: 61 | name: Publish released package to pypi.org 62 | environment: release-pypi 63 | if: github.repository_owner == 'hynek' && github.event.action == 'published' 64 | runs-on: ubuntu-latest 65 | needs: build-package 66 | 67 | permissions: 68 | id-token: write 69 | 70 | steps: 71 | - name: Download packages built by build-and-inspect-python-package 72 | uses: actions/download-artifact@v4 73 | with: 74 | name: Packages 75 | path: dist 76 | 77 | - name: Upload package to PyPI 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | with: 80 | attestations: true 81 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/woodruffw/zizmor 2 | name: Zizmor 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["*"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | 14 | jobs: 15 | zizmor: 16 | name: Zizmor latest via PyPI 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | persist-credentials: false 25 | - uses: hynek/setup-cached-uv@v2 26 | 27 | - name: Run zizmor 🌈 28 | run: uvx zizmor --format sarif . > results.sarif 29 | env: 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Upload SARIF file 33 | uses: github/codeql-action/upload-sarif@v3 34 | with: 35 | # Path to SARIF file relative to the root of the repository 36 | sarif_file: results.sarif 37 | # Optional category for the results 38 | # Used to differentiate multiple results for one commit 39 | category: zizmor 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .cache 3 | .coverage* 4 | .direnv 5 | .envrc 6 | .mypy_cache 7 | .pytest_cache 8 | .tox 9 | .vscode 10 | dist 11 | docs/_build 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.11.12 8 | hooks: 9 | - id: ruff-check 10 | args: [--fix, --exit-non-zero-on-fix] 11 | - id: ruff-format 12 | 13 | - repo: https://github.com/econchick/interrogate 14 | rev: 1.7.0 15 | hooks: 16 | - id: interrogate 17 | args: [tests] 18 | 19 | - repo: https://github.com/codespell-project/codespell 20 | rev: v2.4.1 21 | hooks: 22 | - id: codespell 23 | 24 | - repo: https://github.com/pre-commit/pre-commit-hooks 25 | rev: v5.0.0 26 | hooks: 27 | - id: trailing-whitespace 28 | - id: end-of-file-fixer 29 | - id: check-toml 30 | - id: check-yaml 31 | -------------------------------------------------------------------------------- /.python-version-default: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-lts-latest 6 | tools: 7 | # Keep version in sync with tox.ini/docs and ci.yml/docs. 8 | python: "3.13" 9 | jobs: 10 | create_environment: 11 | # Need the tags to calculate the version (sometimes). 12 | - git fetch --tags 13 | 14 | - asdf plugin add uv 15 | - asdf install uv latest 16 | - asdf global uv latest 17 | 18 | build: 19 | html: 20 | - uvx --with tox-uv tox run -e docs-build -- $READTHEDOCS_OUTPUT 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Calendar Versioning](https://calver.org/). 6 | 7 | The **first number** of the version is the year. 8 | The **second number** is incremented with each release, starting at 1 for each year. 9 | The **third number** is when we need to start branches for older releases (only for emergencies). 10 | 11 | *prometheus-async* has a very strong backwards-compatibility policy. 12 | Generally speaking, you shouldn't ever be afraid of updating. 13 | 14 | Whenever breaking changes are needed, they are: 15 | 16 | 1. …announced here in the changelog. 17 | 2. …the old behavior raises a `DeprecationWarning` for a year (if possible). 18 | 3. …are done with another announcement in the changelog. 19 | 20 | 21 | 22 | 23 | ## [Unreleased](https://github.com/hynek/prometheus-async/compare/25.1.0...HEAD) 24 | 25 | 26 | ## [25.1.0](https://github.com/hynek/prometheus-async/compare/22.2.0...25.1.0) - 2025-02-08 27 | 28 | ### Removed 29 | 30 | - Python 3.7 support. 31 | [#75](https://github.com/hynek/prometheus-async/pull/75) 32 | 33 | 34 | ### Changed 35 | 36 | - The build backend has been switched to [Hatch](https://hatch.pypa.io/). 37 | 38 | 39 | ### Fixed 40 | 41 | - Typing on Pyright. 42 | [#77](https://github.com/hynek/prometheus-async/issues/77) 43 | 44 | 45 | ## [22.2.0](https://github.com/hynek/prometheus-async/compare/22.1.0...22.2.0) - 2022-05-14 46 | 47 | ### Deprecated 48 | 49 | - The `prometheus_async.types.IncDecrementer` `Protocol` is deprecated and will be removed in a year. 50 | It was never a public API. 51 | [#29] 52 | 53 | 54 | ### Changed 55 | 56 | - Due to improvements of `prometheus_client`'s type hints, we don't block them from Mypy anymore. 57 | 58 | 59 | ### Fixed 60 | 61 | - The type hints for `prometheus_async.track_inprogress()` now accept `prometheus_client.Gauge`s. 62 | [#29] 63 | 64 | 65 | [#29]: https://github.com/hynek/prometheus-async/pull/29 66 | 67 | 68 | ## [22.1.0](https://github.com/hynek/prometheus-async/compare/19.2.0...22.1.0) - 2022-02-15 69 | 70 | ### Removed 71 | 72 | - Support for Python 2.7, 3.5, and 3.6 has been dropped. 73 | - The *loop* argument has been removed from `prometheus_async.aio.start_http_server()`. 74 | 75 | 76 | ### Added 77 | 78 | - Added type hints for all APIs. 79 | [#21](https://github.com/hynek/prometheus-async/pull/21) 80 | - Added support for [OpenMetrics](https://openmetrics.io) exposition in `prometheus_async.aio.web.server_stats()` and thus `prometheus_async.aio.web.start_http_server_in_thread()`. 81 | [#23](https://github.com/hynek/prometheus-async/issues/23) 82 | 83 | 84 | ## [19.2.0](https://github.com/hynek/prometheus-async/compare/19.1.0...19.2.0) - 2019-01-17 85 | 86 | ### Fixed 87 | 88 | - Revert the switch to decorator.py since it turned out to be a very breaking change. 89 | Please note that the now-current release of *wrapt* 1.11.0 has a [memory leak](https://github.com/GrahamDumpleton/wrapt/issues/128) so you should block it in your lockfile. 90 | 91 | Sorry for the inconvenience this has caused! 92 | 93 | 94 | ## [19.1.0](https://github.com/hynek/prometheus-async/compare/18.4.0...19.1.0) - 2019-01-15 95 | 96 | ### Changed 97 | 98 | - Dropped most dependencies and switched to *decorator.py* to avoid a C dependency (*wrapt*) that produces functions that can't be pickled. 99 | 100 | 101 | ## [18.4.0](https://github.com/hynek/prometheus-async/compare/18.3.0...18.4.0) - 2018-12-07 102 | 103 | ### Removed 104 | 105 | - *prometheus_client* 0.0.18 or newer is now required. 106 | 107 | 108 | ### Fixed 109 | 110 | - Restored compatibility with *prometheus_client* 0.5. 111 | 112 | 113 | ## [18.3.0](https://github.com/hynek/prometheus-async/compare/18.2.0...18.3.0) - 2018-06-21 114 | 115 | ### Fixed 116 | 117 | - The HTTP access log when using `prometheus_async.start_http_server()` is disabled now. 118 | It was activated accidentally when moving to *aiohttp*'s application runner APIs. 119 | 120 | 121 | ## [18.2.0](https://github.com/hynek/prometheus-async/compare/18.1.0...18.2.0) - 2018-05-29 122 | 123 | ### Deprecated 124 | 125 | - Passing a *loop* argument to `prometheus_async.aio.start_http_server()` is a no-op and raises a `DeprecationWarning` now. 126 | 127 | 128 | ### Changed 129 | 130 | - Port to *aiohttp*'s application runner APIs to avoid those pesky deprecation warnings. 131 | As a consequence, the *loop* argument has been removed from internal APIs and became a no-op in public APIs. 132 | 133 | 134 | ## [18.1.0](https://github.com/hynek/prometheus-async/compare/17.5.0...18.1.0) - 2018-02-15 135 | 136 | ### Removed 137 | 138 | - Python 3.4 is no longer supported. 139 | - *aiohttp* 3.0 or later is now required for aio metrics exposure. 140 | 141 | 142 | ### Changed 143 | 144 | - *python-consul* is no longer required for asyncio Consul service discovery. 145 | A plain *aiohttp* is enough now. 146 | 147 | 148 | ## [17.5.0](https://github.com/hynek/prometheus-async/compare/17.4.0...17.5.0) - 2017-10-30 149 | 150 | ### Removed 151 | 152 | - `prometheus_async.aio.web` now requires *aiohttp* 2.0 or later. 153 | 154 | 155 | ### Added 156 | 157 | - The thread created by `prometheus_async.aio.start_http_server_in_thread()` has a human-readable name now. 158 | 159 | 160 | ### Fixed 161 | 162 | - Fixed compatibility with *aiohttp* 2.3. 163 | 164 | 165 | ## [17.4.0](https://github.com/hynek/prometheus-async/compare/17.3.0...17.4.0) - 2017-08-14 166 | 167 | ### Fixed 168 | 169 | - Set proper content type header for the root redirection page. 170 | 171 | 172 | ## [17.3.0](https://github.com/hynek/prometheus-async/compare/17.2.0...17.3.0) - 2017-06-01 173 | 174 | ### Fixed 175 | 176 | - `prometheus_async.aio.web.start_http_server()` now passes the *loop* argument to `aiohttp.web.Application.make_handler()` instead of `Application`'s initializer. 177 | This fixes a "loop argument is deprecated" warning. 178 | 179 | 180 | ## [17.2.0](https://github.com/hynek/prometheus-async/compare/17.1.0...17.2.0) - 2017-03-21 181 | 182 | ### Deprecated 183 | 184 | - Using *aiohttp* older than 0.21 is now deprecated. 185 | 186 | 187 | ### Fixed 188 | 189 | - `prometheus_async.aio.web` now supports *aiohttp* 2.0. 190 | 191 | 192 | ## [17.1.0](https://github.com/hynek/prometheus-async/compare/16.2.0...17.1.0) - 2017-01-14 193 | 194 | ### Fixed 195 | 196 | - Fix monotonic timer on Python 2. 197 | [#7](https://github.com/hynek/prometheus-async/issues/7) 198 | 199 | 200 | ## [16.2.0](https://github.com/hynek/prometheus-async/compare/16.1.0...16.2.0) - 2016-10-28 201 | 202 | ### Changed 203 | 204 | - When using the *aiohttp* metrics exporter, create the web application using an explicit loop argument. 205 | [#6](https://github.com/hynek/prometheus-async/pull/6) 206 | 207 | 208 | ## [16.1.0](https://github.com/hynek/prometheus-async/compare/16.0.0...16.1.0) - 2016-09-23 209 | 210 | ### Changed 211 | 212 | - Service discovery deregistration is optional now. 213 | 214 | 215 | ## [16.0.0](https://github.com/hynek/prometheus-async/releases/tag/16.0.0) - 2016-05-19 216 | 217 | ### Added 218 | 219 | - Initial release. 220 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Async Helpers for prometheus_client 2 | Copyright 2016 Hynek Schlawack 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prometheus-async 2 | 3 | Documentation 4 | License: Apache 2.0 5 | PyPI version 6 | Downloads / Month 7 | 8 | 9 | 10 | *prometheus-async* adds support for asynchronous frameworks to the official [Python client](https://github.com/prometheus/client_python) for the [Prometheus](https://prometheus.io/) metrics and monitoring system. 11 | 12 | Currently [*asyncio*](https://docs.python.org/3/library/asyncio.html) and [Twisted](https://twisted.org) are supported. 13 | 14 | It works by wrapping the metrics from the official client: 15 | 16 | ```python 17 | import asyncio 18 | 19 | from aiohttp import web 20 | from prometheus_client import Histogram 21 | from prometheus_async.aio import time 22 | 23 | REQ_TIME = Histogram("req_time_seconds", "time spent in requests") 24 | 25 | @time(REQ_TIME) 26 | async def req(request): 27 | await asyncio.sleep(1) 28 | return web.Response(body=b"hello") 29 | ``` 30 | 31 | 32 | Even for *synchronous* applications, the metrics exposure methods can be useful since they are more powerful than the one shipped with the official client. 33 | For that, helper functions have been added that run them in separate threads (*asyncio*-only). 34 | 35 | The source code is hosted on [GitHub](https://github.com/hynek/prometheus-async) and the documentation on [Read the Docs](https://prometheus-async.readthedocs.io/). 36 | 37 | 38 | ## Credits 39 | 40 | *prometheus-async* is written and maintained by [Hynek Schlawack](https://hynek.me/). 41 | 42 | The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *prometheus-async*’s [Tidelift subscribers][TL], and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). 43 | 44 | 45 | ## *prometheus-async* for Enterprise 46 | 47 | Available as part of the [Tidelift Subscription][TL]. 48 | 49 | The maintainers of *prometheus-async* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. 50 | Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. 51 | 52 | [TL]: https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek 53 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | from contextlib import suppress 19 | from importlib import metadata 20 | 21 | import pytest 22 | 23 | 24 | try: 25 | import twisted 26 | except ImportError: 27 | twisted = None 28 | collect_ignore = ["tests/test_tx.py"] 29 | 30 | 31 | def pytest_report_header(config): 32 | return f"prometheus_client: {metadata.version('prometheus-client')}" 33 | 34 | 35 | def mk_monotonic_timer(): 36 | """ 37 | Create a function that always returns the next integer beginning at 0. 38 | """ 39 | 40 | def timer(): 41 | timer.i += 1 42 | return timer.i 43 | 44 | timer.i = 0 45 | 46 | return timer 47 | 48 | 49 | class FakeObserver: 50 | """ 51 | A fake metric observer that saves all observed values in a list. 52 | """ 53 | 54 | def __init__(self): 55 | self._observed = [] 56 | 57 | def observe(self, value): 58 | self._observed.append(value) 59 | 60 | 61 | class FakeCounter: 62 | """ 63 | A fake counter metric. 64 | """ 65 | 66 | def __init__(self): 67 | self._val = 0 68 | 69 | def inc(self): 70 | self._val += 1 71 | 72 | 73 | class FakeGauge: 74 | """ 75 | A fake Gauge. 76 | """ 77 | 78 | def __init__(self): 79 | self._val = 0 80 | self._calls = 0 81 | 82 | def inc(self, val=1): 83 | self._val += val 84 | self._calls += 1 85 | 86 | def dec(self, val=1): 87 | self._val -= val 88 | self._calls += 1 89 | 90 | 91 | @pytest.fixture(autouse=True) 92 | def _reset_registry(monkeypatch): 93 | """ 94 | Ensures prometheus_client's CollectorRegistry is empty before each test. 95 | """ 96 | from prometheus_client import REGISTRY 97 | 98 | for c in list(REGISTRY._collector_to_names): 99 | REGISTRY.unregister(c) 100 | 101 | 102 | @pytest.fixture(name="fake_observer") 103 | def _fake_observer(): 104 | return FakeObserver() 105 | 106 | 107 | @pytest.fixture(name="fake_counter") 108 | def _fake_counter(): 109 | return FakeCounter() 110 | 111 | 112 | @pytest.fixture(name="fake_gauge") 113 | def _fake_gauge(): 114 | return FakeGauge() 115 | 116 | 117 | @pytest.fixture(name="patch_timer") 118 | def _patch_timer(monkeypatch): 119 | with suppress(ImportError): 120 | from prometheus_async.tx import _decorators 121 | 122 | monkeypatch.setattr(_decorators, "perf_counter", mk_monotonic_timer()) 123 | 124 | with suppress(ImportError): 125 | from prometheus_async.aio import _decorators 126 | 127 | monkeypatch.setattr(_decorators, "perf_counter", mk_monotonic_timer()) 128 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/prometheus_async.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/prometheus_async.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/prometheus_async" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/prometheus_async" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/asyncio.md: -------------------------------------------------------------------------------- 1 | (asyncio-api)= 2 | 3 | # asyncio Support 4 | 5 | ```{eval-rst} 6 | .. currentmodule:: prometheus_async.aio 7 | ``` 8 | 9 | The asyncio-related APIs can be found within the `prometheus_async.aio` package. 10 | 11 | 12 | ## Decorator Wrappers 13 | 14 | All of these functions take a *prometheus_client* metrics object and can either be applied as a decorator to functions and methods, or they can be passed an {class}`asyncio.Future` for a second argument. 15 | 16 | ```{eval-rst} 17 | .. autofunction:: time 18 | ``` 19 | 20 | The most common use case is using it as a decorator: 21 | 22 | ```python 23 | import asyncio 24 | 25 | from aiohttp import web 26 | from prometheus_client import Histogram 27 | from prometheus_async.aio import time 28 | 29 | REQ_TIME = Histogram("req_time_seconds", "time spent in requests") 30 | 31 | @time(REQ_TIME) 32 | async def req(request): 33 | await asyncio.sleep(1) 34 | return web.Response(body=b"hello") 35 | ``` 36 | 37 | ```{eval-rst} 38 | .. autofunction:: count_exceptions 39 | .. autofunction:: track_inprogress 40 | 41 | ``` 42 | 43 | 44 | (asyncio-web)= 45 | 46 | ## Metric Exposure 47 | 48 | ```{eval-rst} 49 | .. currentmodule:: prometheus_async.aio.web 50 | ``` 51 | 52 | *prometheus-async* offers methods to expose your metrics using [*aiohttp*](https://aiohttp.readthedocs.io/) under `prometheus_async.aio.web`: 53 | 54 | ```{eval-rst} 55 | .. autofunction:: start_http_server 56 | .. autofunction:: start_http_server_in_thread 57 | ``` 58 | 59 | :::{important} 60 | Please note that if you want to use [uWSGI](https://uwsgi-docs.readthedocs.io/) together with `start_http_server_in_thread()`, you have to tell uWSGI to enable threads using its [configuration option](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#enable-threads) or by passing it `--enable-threads`. 61 | 62 | Currently the recommended mode to run uWSGI with `--master` [is broken](https://github.com/unbit/uwsgi/issues/1609) if you want to clean up using {mod}`atexit` handlers. 63 | 64 | Therefore the usage of `prometheus_sync.aio.web` together with uWSGI is **strongly discouraged**. 65 | 66 | As of 2023, the uWSGI project declared to only do emergency maintenance, therefore it's a good idea to migrate away from it anyway. 67 | ::: 68 | 69 | ```{eval-rst} 70 | .. autofunction:: server_stats 71 | ``` 72 | 73 | Useful if you want to install your metrics within your own application: 74 | 75 | ```python 76 | from aiohttp import web 77 | from prometheus_async import aio 78 | 79 | app = web.Application() 80 | app.router.add_get("/metrics", aio.web.server_stats) 81 | # your other routes go here. 82 | ``` 83 | 84 | ```{eval-rst} 85 | .. autoclass:: MetricsHTTPServer 86 | :members: close 87 | 88 | .. autoclass:: ThreadedMetricsHTTPServer 89 | :members: close 90 | ``` 91 | 92 | 93 | (sd)= 94 | 95 | ## Service Discovery 96 | 97 | ```{eval-rst} 98 | .. currentmodule:: prometheus_async.aio.sd 99 | ``` 100 | 101 | Web exposure is much more useful if it comes with an easy way to integrate it with service discovery. 102 | 103 | Currently *prometheus-async* only ships integration with a local Consul agent using *aiohttp*. 104 | We do **not** plan add more. 105 | 106 | ```{eval-rst} 107 | .. autoclass:: ConsulAgent 108 | ``` 109 | 110 | 111 | ### Custom Service Discovery 112 | 113 | Adding own service discovery methods is simple: 114 | All you need is to provide an instance with a coroutine `register(self, metrics_server, loop)` that registers the passed `metrics_server` with the service of your choicer and returns another coroutine that is called for de-registration when the metrics server is shut down. 115 | 116 | Have a look at [our implementations](https://github.com/hynek/prometheus-async/blob/main/src/prometheus_async/aio/sd.py) if you need inspiration or check out the `ServiceDiscovery` {class}`typing.Protocol` in the [`types` module](https://github.com/hynek/prometheus-async/blob/main/src/prometheus_async/types.py) 117 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from importlib import metadata 18 | 19 | 20 | # Add any Sphinx extension module names here, as strings. They can be 21 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 22 | # ones. 23 | extensions = [ 24 | "myst_parser", 25 | "notfound.extension", 26 | "sphinx.ext.autodoc", 27 | "sphinx.ext.autodoc.typehints", 28 | "sphinx.ext.doctest", 29 | "sphinx.ext.intersphinx", 30 | "sphinx.ext.todo", 31 | ] 32 | 33 | myst_enable_extensions = [ 34 | "colon_fence", 35 | "smartquotes", 36 | "deflist", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ["_templates"] 41 | 42 | # The suffix of source filenames. 43 | source_suffix = ".rst" 44 | 45 | # The master toctree document. 46 | master_doc = "index" 47 | 48 | # General information about the project. 49 | project = "prometheus-async" 50 | copyright = "2016, Hynek Schlawack" 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | 56 | # The full version, including alpha/beta/rc tags. 57 | release = metadata.version("prometheus-async") 58 | # The short X.Y version. 59 | version = release.rsplit(".", 1)[0] 60 | 61 | if "dev" in release: 62 | release = version = "UNRELEASED" 63 | 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ["_build"] 68 | 69 | nitpick_ignore = [ 70 | ("py:class", "Gauge"), 71 | ("py:class", "Incrementer"), 72 | ("py:class", "Observer"), 73 | ("py:class", "ServiceDiscovery"), 74 | ("py:class", "P"), 75 | ("py:class", "T"), 76 | ("py:class", "R"), 77 | ("py:class", "D"), 78 | ("py:class", "C"), 79 | ("py:class", "twisted.web.resource.Resource"), 80 | ] 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | add_function_parentheses = True 84 | 85 | # Move type hints into the description block, instead of the func definition. 86 | autodoc_typehints = "description" 87 | autodoc_typehints_description_target = "documented" 88 | 89 | highlight_language = "python3" 90 | 91 | # -- Options for HTML output ---------------------------------------------- 92 | 93 | html_theme = "furo" 94 | html_theme_options = {"top_of_page_buttons": []} 95 | 96 | # Output file base name for HTML help builder. 97 | htmlhelp_basename = "prometheus_asyncdoc" 98 | 99 | linkcheck_ignore = [ 100 | # Rate limits 101 | r"https://github.com/.*/(issues|pull)/\d+", 102 | r"https://twitter.com/.*", 103 | # Anchors are a problem 104 | r"https://github.com/prometheus/client_python#twisted", 105 | r"https://github.com/prometheus/client_python#counter", 106 | # This breaks releases because of non-existent tags. 107 | r"https://github.com/hynek/prometheus-async/compare/.*", 108 | ] 109 | 110 | intersphinx_mapping = { 111 | "python": ("https://docs.python.org/3", None), 112 | "aiohttp": ("https://aiohttp.readthedocs.io/en/stable/", None), 113 | "twisted": ("https://docs.twistedmatrix.com/en/stable/", None), 114 | } 115 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # prometheus-async 2 | 3 | Release **{sub-ref}`release`** ([What's new?](https://github.com/hynek/prometheus-async/blob/main/CHANGELOG.md)) 4 | 5 | ```{include} ../README.md 6 | :start-after: 7 | :end-before: "## Credits" 8 | ``` 9 | 10 | 11 | ## User's Guide 12 | 13 | ```{toctree} 14 | :maxdepth: 1 15 | 16 | installation 17 | asyncio 18 | twisted 19 | ``` 20 | 21 | ```{toctree} 22 | :hidden: 23 | :caption: Meta 24 | 25 | PyPI 26 | GitHub 27 | Changelog 28 | Contributing 29 | Security Policy 30 | Funding 31 | ``` 32 | 33 | 34 | ## Indices and tables 35 | 36 | - {ref}`genindex` 37 | - {ref}`search` 38 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation and Requirements 2 | 3 | If you just want to instrument an *asyncio*-based application: 4 | 5 | ```console 6 | $ python -Im pip install -U pip 7 | $ python -Im pip install prometheus-async 8 | ``` 9 | 10 | If you want to expose metrics using *aiohttp*: 11 | 12 | ```console 13 | $ python -Im pip install -U pip 14 | $ python -Im pip install prometheus-async[aiohttp] 15 | ``` 16 | 17 | If you want to instrument a Twisted application: 18 | 19 | ```console 20 | $ python -Im pip install -U pip 21 | $ python -Im pip install prometheus-async[twisted] 22 | ``` 23 | 24 | ```{admonition} Warning 25 | :class: Warning 26 | 27 | Please do not skip the update of *pip*, because *prometheus-async* uses modern packaging features and the installation will most likely fail otherwise. 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/twisted.md: -------------------------------------------------------------------------------- 1 | (twisted-api)= 2 | 3 | # Twisted Support 4 | 5 | ```{eval-rst} 6 | .. currentmodule:: prometheus_async.tx 7 | ``` 8 | 9 | The Twisted-related APIs can be found within the `prometheus_async.tx` package. 10 | 11 | 12 | ## Decorator Wrappers 13 | 14 | ```{eval-rst} 15 | .. autofunction:: time 16 | ``` 17 | 18 | The fact it's accepting ``Deferred``s is useful in conjunction with [`twisted.web`] views that don't allow to return a ``Deferred``: 19 | 20 | ```python 21 | from prometheus_client import Histogram 22 | from prometheus_async.tx import time 23 | from twisted.internet.task import deferLater 24 | from twisted.web.resource import Resource 25 | from twisted.web.server import NOT_DONE_YET 26 | from twisted.internet import reactor 27 | 28 | REQ_TIME = Histogram("req_time_seconds", "time spent in requests") 29 | 30 | class DelayedResource(Resource): 31 | def _delayedRender(self, request): 32 | request.write("Sorry to keep you waiting.") 33 | request.finish() 34 | 35 | def render_GET(self, request): 36 | d = deferLater(reactor, 5, lambda: request) 37 | time(REQ_TIME, d.addCallback(self._delayedRender)) 38 | return NOT_DONE_YET 39 | ``` 40 | 41 | ```{eval-rst} 42 | .. autofunction:: count_exceptions 43 | .. autofunction:: track_inprogress 44 | ``` 45 | 46 | 47 | (twisted-web)= 48 | 49 | ## Metric Exposure 50 | 51 | [*prometheus_client*], the underlying Prometheus client library, exposes a {class}`twisted.web.resource.Resource` -- namely [`prometheus_client.twisted.MetricsResource`] -- that makes it extremely easy to expose your metrics. 52 | 53 | ```python 54 | from prometheus_client.twisted import MetricsResource 55 | from twisted.web.server import Site 56 | from twisted.web.resource import Resource 57 | from twisted.internet import reactor 58 | 59 | root = Resource() 60 | root.putChild(b"metrics", MetricsResource()) 61 | 62 | factory = Site(root) 63 | reactor.listenTCP(8000, factory) 64 | reactor.run() 65 | ``` 66 | 67 | As a slightly more in-depth example, the following exposes the application's metrics under `/metrics` and sets up a [`prometheus_client.Counter`] for inbound HTTP requests. 68 | It also uses [Klein] to set up the routes instead of relying directly on [`twisted.web`] for routing. 69 | 70 | ```python 71 | from prometheus_client.twisted import MetricsResource 72 | from twisted.web.server import Site 73 | from twisted.internet import reactor 74 | 75 | from klein import Klein 76 | 77 | from prometheus_client import Counter 78 | 79 | 80 | INBOUND_REQUESTS = Counter( 81 | "inbound_requests_total", 82 | "Counter (int) of inbound http requests", 83 | ["endpoint", "method"] 84 | ) 85 | 86 | app = Klein() 87 | 88 | @app.route("/metrics") 89 | def metrics(request): 90 | INBOUND_REQUESTS.labels("/metrics", "GET").inc() 91 | return MetricsResource() 92 | 93 | 94 | factory = Site(app.resource()) 95 | reactor.listenTCP(8000, factory) 96 | reactor.run() 97 | ``` 98 | 99 | [Klein]: https://github.com/twisted/klein 100 | [*prometheus_client*]: https://github.com/prometheus/client_python#twisted 101 | [`prometheus_client.Counter`]: https://github.com/prometheus/client_python#counter 102 | [`prometheus_client.twisted.metricsresource`]: https://github.com/prometheus/client_python/blob/master/prometheus_client/twisted/_exposition.py 103 | [`twisted.web`]: https://docs.twistedmatrix.com/en/stable/web/howto/web-in-60/index.html 104 | -------------------------------------------------------------------------------- /oldest-supported.txt: -------------------------------------------------------------------------------- 1 | prometheus-client==0.8.0 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | [build-system] 4 | requires = ["hatchling", "hatch-vcs"] 5 | build-backend = "hatchling.build" 6 | 7 | 8 | [project] 9 | name = "prometheus-async" 10 | dynamic = ["version"] 11 | authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] 12 | requires-python = ">=3.8" 13 | description = "Async helpers for prometheus_client." 14 | dependencies = [ 15 | # Keep min. version in-sync with constraints in oldest-supported.txt! 16 | "prometheus_client >= 0.8.0", 17 | "typing_extensions >= 3.10.0; python_version<'3.10'", 18 | "wrapt", 19 | ] 20 | license = "Apache-2.0" 21 | license-files = ["LICENSE", "NOTICE"] 22 | readme = { content-type = "text/markdown", file = "README.md" } 23 | keywords = ["metrics", "prometheus", "twisted", "asyncio", "async"] 24 | classifiers = [ 25 | "Development Status :: 5 - Production/Stable", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Typing :: Typed", 33 | ] 34 | 35 | [project.urls] 36 | Documentation = "https://prometheus-async.readthedocs.io/" 37 | Changelog = "https://prometheus-async.readthedocs.io/en/stable/changelog.html" 38 | GitHub = "https://github.com/hynek/prometheus-async/" 39 | Funding = "https://hynek.me/say-thanks/" 40 | 41 | [dependency-groups] 42 | aiohttp = ["aiohttp>=3"] 43 | consul = ["aiohttp>=3"] 44 | twisted = ["twisted"] 45 | tests = ["coverage[toml]", "pytest", "pytest-asyncio"] 46 | docs = [ 47 | { include-group = "aiohttp" }, 48 | { include-group = "twisted" }, 49 | "furo", 50 | "myst-parser", 51 | "sphinx-notfound-page", 52 | "sphinx", 53 | ] 54 | dev = [ 55 | { include-group = "tests" }, 56 | { include-group = "aiohttp" }, 57 | { include-group = "twisted" }, 58 | "mypy", 59 | "tox>4", 60 | ] 61 | 62 | 63 | [tool.hatch.version] 64 | source = "vcs" 65 | raw-options = { local_scheme = "no-local-version" } 66 | 67 | 68 | [tool.pytest.ini_options] 69 | addopts = ["-ra", "--strict-markers", "--strict-config"] 70 | xfail_strict = true 71 | testpaths = "tests" 72 | filterwarnings = [ 73 | "once::Warning", 74 | "ignore:::aiohttp[.*]", 75 | "ignore:::importlib[.*]", 76 | "ignore::DeprecationWarning:twisted.python.threadable", 77 | ] 78 | asyncio_mode = "auto" 79 | asyncio_default_fixture_loop_scope = "function" 80 | 81 | 82 | [tool.coverage.run] 83 | parallel = true 84 | branch = true 85 | source = ["prometheus_async"] 86 | 87 | [tool.coverage.paths] 88 | source = ["src", ".tox/py*/**/site-packages"] 89 | 90 | [tool.coverage.report] 91 | show_missing = true 92 | skip_covered = true 93 | exclude_lines = [ 94 | "pragma: no cover", 95 | "if TYPE_CHECKING:", 96 | "raise NotImplementedError", 97 | # typing-related code 98 | "^if (False|TYPE_CHECKING):", 99 | ": \\.\\.\\.$", 100 | "^ +\\.\\.\\.$", 101 | "-> ['\"]?NoReturn['\"]?:", 102 | ] 103 | omit = ["src/prometheus_async/types.py"] 104 | 105 | 106 | [tool.interrogate] 107 | omit-covered-files = true 108 | verbose = 2 109 | fail-under = 100 110 | whitelist-regex = ["test_.*"] 111 | 112 | 113 | [tool.mypy] 114 | strict = true 115 | pretty = true 116 | 117 | show_error_codes = true 118 | enable_error_code = ["ignore-without-code"] 119 | ignore_missing_imports = true 120 | 121 | warn_return_any = false 122 | disallow_any_generics = false 123 | disallow_untyped_decorators = false 124 | 125 | [[tool.mypy.overrides]] 126 | module = "tests.*" 127 | ignore_errors = true 128 | 129 | [[tool.mypy.overrides]] 130 | module = "tests.typing.*" 131 | ignore_errors = false 132 | 133 | [[tool.mypy.overrides]] 134 | module = "conftest.*" 135 | ignore_errors = true 136 | 137 | 138 | [tool.ruff] 139 | line-length = 79 140 | src = ["src", "tests", "docs/conf.py"] 141 | 142 | [tool.ruff.lint] 143 | select = ["ALL"] 144 | ignore = [ 145 | "A", # shadowing is fine 146 | "ANN", # Mypy is better at this 147 | "ARG", # unused arguments are common w/ interfaces 148 | "C901", # sometimes you trade complexity for performance 149 | "COM", # formatter takes care of our commas 150 | "D", # We prefer our own docstring style. 151 | "E501", # leave line-length enforcement to formatter 152 | "EM101", # simple strings are fine 153 | "FBT", # bools are our friends 154 | "FIX", # Yes, we want XXX as a marker. 155 | "INP001", # sometimes we want Python files outside of packages 156 | "ISC001", # conflicts with formatter 157 | "N802", # some names are non-pep8 due to stdlib logging / Twisted 158 | "N803", # ditto 159 | "N806", # ditto 160 | "PLR0913", # leave complexity to me 161 | "PLR2004", # numbers are sometimes fine 162 | "PLW2901", # overwriting a loop var can be useful 163 | "RUF001", # leave my smart characters alone 164 | "SLF001", # private members are accessed by friendly functions 165 | "T201", # prints are fine 166 | "TID252", # relative imports all the way 167 | "TC", # TYPE_CHECKING blocks break autodocs 168 | "TD", # we don't follow other people's todo style 169 | "TRY003", # simple strings are fine 170 | "TRY004", # too many false negatives 171 | "TRY300", # else blocks are nice, but code-locality is nicer 172 | "PTH", # pathlib can be slow, so no point to rewrite 173 | ] 174 | 175 | [tool.ruff.lint.per-file-ignores] 176 | "tests/*" = [ 177 | "B018", # "useless" expressions can be useful in tests 178 | "BLE", # tests have different rules around exceptions 179 | "EM", # tests have different rules around exceptions 180 | "PLC1901", # empty strings are falsey, but are less specific in tests 181 | "PT011", # broad exceptions are fine 182 | "RUF012", # no type hints in tests 183 | "S", # it's test; chill out security 184 | "S101", # assert 185 | "S301", # I know pickle is bad, but people like it. 186 | "SIM300", # Yoda rocks in tests 187 | "TRY", # tests have different rules around exceptions 188 | ] 189 | 190 | [tool.ruff.lint.isort] 191 | lines-between-types = 1 192 | lines-after-imports = 2 193 | -------------------------------------------------------------------------------- /src/prometheus_async/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | __title__ = "prometheus_async" 18 | 19 | __author__ = "Hynek Schlawack" 20 | 21 | __license__ = "Apache License, Version 2.0" 22 | __copyright__ = f"Copyright (c) 2016 {__author__}" 23 | 24 | 25 | from . import aio 26 | 27 | 28 | __all__ = ["aio"] 29 | 30 | try: 31 | from . import tx 32 | 33 | __all__ += ["tx"] 34 | except ImportError: 35 | pass 36 | 37 | 38 | def __getattr__(name: str) -> str: 39 | if name != "__version__": 40 | msg = f"module {__name__} has no attribute {name}" 41 | raise AttributeError(msg) 42 | 43 | from importlib.metadata import version 44 | 45 | return version("prometheus-async") 46 | -------------------------------------------------------------------------------- /src/prometheus_async/aio/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | asyncio-related functionality. 19 | """ 20 | 21 | from . import sd 22 | from ._decorators import count_exceptions, time, track_inprogress 23 | 24 | 25 | __all__ = ["count_exceptions", "sd", "time", "track_inprogress"] 26 | 27 | try: 28 | from . import web 29 | 30 | __all__ += ["web"] 31 | except ImportError: 32 | pass 33 | -------------------------------------------------------------------------------- /src/prometheus_async/aio/_decorators.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | Decorators for asyncio. 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | from time import perf_counter 24 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, overload 25 | 26 | 27 | if TYPE_CHECKING: 28 | from prometheus_client import Gauge 29 | 30 | from ..types import Incrementer, Observer, P, R, T 31 | 32 | from wrapt import decorator 33 | 34 | 35 | @overload 36 | def time(metric: Observer) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 37 | 38 | 39 | @overload 40 | def time(metric: Observer, future: Awaitable[T]) -> Awaitable[T]: ... 41 | 42 | 43 | def time( 44 | metric: Observer, future: Awaitable[T] | None = None 45 | ) -> Awaitable[T] | Callable[[Callable[P, R]], Callable[P, R]]: 46 | r""" 47 | Call ``metric.observe(time)`` with the runtime in seconds. 48 | 49 | Works as a decorator as well as on :class:`asyncio.Future`\ s. 50 | 51 | :returns: coroutine function (if decorator) or coroutine. 52 | """ 53 | 54 | def observe(start_time: float) -> None: 55 | metric.observe(perf_counter() - start_time) 56 | 57 | if future is None: 58 | 59 | @decorator 60 | async def time_decorator( 61 | wrapped: Callable[..., R], _: Any, args: Any, kw: Any 62 | ) -> R: 63 | start_time = perf_counter() 64 | try: 65 | return await wrapped(*args, **kw) 66 | finally: 67 | observe(start_time) 68 | 69 | return time_decorator 70 | 71 | f = future 72 | 73 | async def measure(start_time: float) -> T: 74 | try: 75 | return await f 76 | finally: 77 | observe(start_time) 78 | 79 | start_time = perf_counter() 80 | return measure(start_time) 81 | 82 | 83 | @overload 84 | def count_exceptions( 85 | metric: Incrementer, *, exc: type[BaseException] = BaseException 86 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 87 | 88 | 89 | @overload 90 | def count_exceptions( 91 | metric: Incrementer, 92 | future: Awaitable[T], 93 | *, 94 | exc: type[BaseException] = BaseException, 95 | ) -> Awaitable[T]: ... 96 | 97 | 98 | def count_exceptions( 99 | metric: Incrementer, 100 | future: Awaitable[T] | None = None, 101 | *, 102 | exc: type[BaseException] = BaseException, 103 | ) -> Callable[[Callable[P, R]], Callable[P, R]] | Awaitable[T]: 104 | r""" 105 | Call ``metric.inc()`` whenever *exc* is caught. 106 | 107 | Works as a decorator as well as on :class:`asyncio.Future`\ s. 108 | 109 | :returns: coroutine function (if decorator) or coroutine. 110 | """ 111 | if future is None: 112 | 113 | @decorator 114 | async def count( 115 | wrapped: Callable[..., R], _: Any, args: Any, kw: Any 116 | ) -> R: 117 | try: 118 | rv = await wrapped(*args, **kw) 119 | except exc: 120 | metric.inc() 121 | raise 122 | return rv 123 | 124 | return count 125 | 126 | else: # noqa: RET505 -- prevents redefinition of "count". 127 | f = future 128 | 129 | async def count() -> T: 130 | try: 131 | rv = await f 132 | except exc: 133 | metric.inc() 134 | raise 135 | return rv 136 | 137 | return count() 138 | 139 | 140 | @overload 141 | def track_inprogress( 142 | metric: Gauge, 143 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 144 | 145 | 146 | @overload 147 | def track_inprogress(metric: Gauge, future: Awaitable[T]) -> Awaitable[T]: ... 148 | 149 | 150 | def track_inprogress( 151 | metric: Gauge, future: Awaitable[T] | None = None 152 | ) -> Callable[[Callable[P, R]], Callable[P, R]] | Awaitable[T]: 153 | r""" 154 | Call ``metrics.inc()`` on entry and ``metric.dec()`` on exit. 155 | 156 | Works as a decorator, as well on :class:`asyncio.Future`\ s. 157 | 158 | :returns: coroutine function (if decorator) or coroutine. 159 | """ 160 | if future is None: 161 | 162 | @decorator 163 | async def track( 164 | wrapped: Callable[..., R], _: Any, args: Any, kw: Any 165 | ) -> R: 166 | metric.inc() 167 | try: 168 | rv = await wrapped(*args, **kw) 169 | finally: 170 | metric.dec() 171 | 172 | return rv 173 | 174 | return track 175 | 176 | else: # noqa: RET505 -- prevents redefinition of "track". 177 | f = future 178 | metric.inc() 179 | 180 | async def track() -> T: 181 | try: 182 | rv = await f 183 | finally: 184 | metric.dec() 185 | return rv 186 | 187 | return track() 188 | -------------------------------------------------------------------------------- /src/prometheus_async/aio/sd.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | Service discovery for web exposure. 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | from functools import partial 24 | from typing import TYPE_CHECKING 25 | 26 | 27 | try: 28 | import aiohttp 29 | import yarl 30 | except ImportError: 31 | pass 32 | 33 | if TYPE_CHECKING: 34 | from ..types import Deregisterer 35 | from .web import MetricsHTTPServer 36 | 37 | __all__ = ["ConsulAgent"] 38 | 39 | 40 | class ConsulAgent: 41 | """ 42 | Service discovery via a local Consul agent. 43 | 44 | Pass as ``service_discovery`` into 45 | :func:`prometheus_async.aio.web.start_http_server`/ 46 | :func:`prometheus_async.aio.web.start_http_server_in_thread`. 47 | 48 | :param str name: Application name that is used for the name and the service 49 | ID if not set. 50 | :param str service_id: Consul Service ID. If not set, *name* is used. 51 | :param tuple tags: Tags to use in Consul registration. 52 | :param str token: A consul access token. 53 | :param bool deregister: Whether to deregister when the HTTP server is 54 | closed. 55 | """ 56 | 57 | def __init__( 58 | self, 59 | *, 60 | name: str = "app-metrics", 61 | service_id: str | None = None, 62 | tags: tuple[str, ...] = (), 63 | token: str | None = None, 64 | deregister: bool = True, 65 | ): 66 | self.name = name 67 | self.service_id = service_id or name 68 | self.tags = tags 69 | self.token = token 70 | self.deregister = deregister 71 | self.consul = _LocalConsulAgentClient(token=token) 72 | 73 | async def register( 74 | self, metrics_server: MetricsHTTPServer 75 | ) -> Deregisterer | None: 76 | """ 77 | :return: A coroutine callable to deregister or ``None``. 78 | """ 79 | resp = await self.consul.register_service( 80 | name=self.name, 81 | service_id=self.service_id, 82 | tags=list(self.tags) or None, 83 | metrics_server=metrics_server, 84 | ) 85 | if resp is None: 86 | return None 87 | 88 | async def deregister() -> None: 89 | if self.deregister is True: 90 | await self.consul.deregister_service(self.service_id) 91 | 92 | return deregister 93 | 94 | 95 | class _LocalConsulAgentClient: # pragma: no cover -- needs local consul client 96 | """ 97 | Minimal client to speak to a Consul agent on localhost:8500. 98 | """ 99 | 100 | def __init__(self, token: str | None) -> None: 101 | self.agent_url = yarl.URL.build( 102 | scheme="http", host="127.0.0.1", port=8500, path="/v1/agent" 103 | ) 104 | 105 | if token: 106 | self.headers = {"X-Consul-Token": token} 107 | else: 108 | self.headers = {} 109 | 110 | self.session_factory = partial( 111 | aiohttp.ClientSession, headers=self.headers 112 | ) 113 | 114 | async def get_services(self) -> dict: 115 | async with self.session_factory() as session: 116 | resp = await session.get(self.agent_url / "services") 117 | return await resp.json() 118 | 119 | async def register_service( 120 | self, 121 | name: str, 122 | service_id: str, 123 | tags: list[str] | None, 124 | metrics_server: MetricsHTTPServer, 125 | ) -> aiohttp.ClientResponse | None: 126 | async with self.session_factory() as session: 127 | resp = await session.put( 128 | self.agent_url / "service/register", 129 | json={ 130 | "Name": name, 131 | "ID": service_id, 132 | "Tags": tags, 133 | "Address": metrics_server.socket.addr, 134 | "Port": metrics_server.socket.port, 135 | "Check": {"HTTP": metrics_server.url, "Interval": "10s"}, 136 | }, 137 | ) 138 | if resp.status == 200: 139 | return resp 140 | 141 | return None 142 | 143 | async def deregister_service( 144 | self, service_id: str 145 | ) -> aiohttp.ClientResponse: 146 | async with self.session_factory() as session: 147 | return await session.put( 148 | self.agent_url / "service/deregister" / service_id 149 | ) 150 | -------------------------------------------------------------------------------- /src/prometheus_async/aio/web.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | aiohttp-based metrics exposure. 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | import asyncio 24 | import queue 25 | import threading 26 | 27 | from typing import TYPE_CHECKING, NamedTuple 28 | 29 | from aiohttp import web 30 | from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest 31 | from prometheus_client.openmetrics import exposition as openmetrics 32 | 33 | 34 | if TYPE_CHECKING: 35 | import ssl 36 | 37 | from typing import Callable 38 | 39 | from ..types import Deregisterer, ServiceDiscovery 40 | 41 | 42 | def _choose_generator(accept_header: str | None) -> tuple[Callable, str]: 43 | """ 44 | Return the correct generate function according to *accept_header*. 45 | 46 | Default to the old style. 47 | """ 48 | accept_header = accept_header or "" 49 | for accepted in accept_header.split(","): 50 | if accepted.split(";")[0].strip() == "application/openmetrics-text": 51 | return ( 52 | openmetrics.generate_latest, 53 | openmetrics.CONTENT_TYPE_LATEST, 54 | ) 55 | 56 | return generate_latest, CONTENT_TYPE_LATEST 57 | 58 | 59 | async def server_stats(request: web.Request) -> web.Response: 60 | """ 61 | Return a web response with the plain text version of the metrics. 62 | 63 | :rtype: :class:`aiohttp.web.Response` 64 | """ 65 | generate, content_type = _choose_generator(request.headers.get("Accept")) 66 | 67 | rsp = web.Response(body=generate(REGISTRY)) 68 | # This is set separately because aiohttp complains about `;` in 69 | # content_type thinking it means there's also a charset. 70 | # cf. https://github.com/aio-libs/aiohttp/issues/2197 71 | rsp.content_type = content_type 72 | 73 | return rsp 74 | 75 | 76 | _REF = 'Metrics' 77 | 78 | 79 | async def _cheap(request: web.Request) -> web.Response: 80 | """ 81 | A view that links to metrics. 82 | 83 | Useful for cheap health checks. 84 | """ 85 | return web.Response(text=_REF, content_type="text/html") 86 | 87 | 88 | async def start_http_server( 89 | *, 90 | addr: str = "", 91 | port: int = 0, 92 | ssl_ctx: ssl.SSLContext | None = None, 93 | service_discovery: ServiceDiscovery | None = None, 94 | ) -> MetricsHTTPServer: 95 | """ 96 | Start an HTTP(S) server on *addr*:*port*. 97 | 98 | If *ssl_ctx* is set, use TLS. 99 | 100 | :param str addr: Interface to listen on. Leaving empty will listen on all 101 | interfaces. 102 | :param int port: Port to listen on. 103 | :param ssl.SSLContext ssl_ctx: TLS settings 104 | :param service_discovery: see :ref:`sd` 105 | 106 | :rtype: MetricsHTTPServer 107 | 108 | .. deprecated:: 18.2.0 109 | 110 | The *loop* argument is a no-op now and will be removed in one year by 111 | the earliest. 112 | .. versionchanged:: 21.1.0 The *loop* argument has been removed. 113 | """ 114 | app = web.Application() 115 | app.router.add_get("/", _cheap) 116 | app.router.add_get("/metrics", server_stats) 117 | 118 | runner = web.AppRunner(app, access_log=None) 119 | await runner.setup() 120 | site = web.TCPSite(runner, addr, port, ssl_context=ssl_ctx) 121 | await site.start() 122 | 123 | ms = MetricsHTTPServer.from_server( 124 | runner=runner, app=app, https=ssl_ctx is not None 125 | ) 126 | if service_discovery is not None: 127 | ms._deregister = await service_discovery.register(ms) 128 | 129 | return ms 130 | 131 | 132 | class MetricsHTTPServer: 133 | """ 134 | A stoppable metrics HTTP server. 135 | 136 | Returned by :func:`start_http_server`. Do *not* instantiate it yourself. 137 | 138 | :ivar socket: Socket the server is listening on. namedtuple of 139 | either (:class:`ipaddress.IPv4Address`, port) or 140 | (:class:`ipaddress.IPv6Address`, port). 141 | :ivar bool https: Whether the server uses SSL/TLS. 142 | :ivar str url: A valid URL to the metrics endpoint. 143 | :ivar bool is_registered: Is the web endpoint registered with a 144 | service discovery system? 145 | """ 146 | 147 | socket: Socket 148 | https: bool 149 | _runner: web.AppRunner 150 | _app: web.Application 151 | _deregister: Deregisterer | None 152 | 153 | def __init__( 154 | self, 155 | socket: Socket, 156 | runner: web.AppRunner, 157 | app: web.Application, 158 | https: bool, 159 | ): 160 | self._app = app 161 | self._runner = runner 162 | self._deregister = None 163 | 164 | self.socket = socket 165 | self.https = https 166 | 167 | @classmethod 168 | def from_server( 169 | cls, runner: web.AppRunner, app: web.Application, https: bool 170 | ) -> MetricsHTTPServer: 171 | return cls( 172 | socket=Socket(*runner.addresses[0][:2]), 173 | runner=runner, 174 | app=app, 175 | https=https, 176 | ) 177 | 178 | @property 179 | def is_registered(self) -> bool: 180 | """ 181 | Is the web endpoint registered with a service discovery system? 182 | """ 183 | return self._deregister is not None 184 | 185 | @property 186 | def url(self) -> str: 187 | addr = self.socket.addr 188 | return "http{s}://{host}:{port}/".format( 189 | s="s" if self.https else "", 190 | host=addr if ":" not in addr else f"[{addr}]", 191 | port=self.socket.port, 192 | ) 193 | 194 | async def close(self) -> None: 195 | """ 196 | Stop the server and clean up. 197 | """ 198 | if self._deregister is not None: 199 | await self._deregister() 200 | await self._runner.cleanup() 201 | 202 | 203 | class Socket(NamedTuple): 204 | addr: str 205 | port: int 206 | 207 | 208 | class ThreadedMetricsHTTPServer: 209 | """ 210 | A stoppable metrics HTTP server that runs in a separate thread. 211 | 212 | Returned by :func:`start_http_server_in_thread`. Do *not* instantiate it 213 | yourself. 214 | 215 | :ivar socket: Socket the server is listening on. namedtuple of 216 | ``Socket(addr, port)``. 217 | :ivar bool https: Whether the server uses SSL/TLS. 218 | :ivar str url: A valid URL to the metrics endpoint. 219 | :ivar bool is_registered: Is the web endpoint registered with a 220 | service discovery system? 221 | """ 222 | 223 | def __init__( 224 | self, 225 | http_server: MetricsHTTPServer, 226 | thread: threading.Thread, 227 | loop: asyncio.AbstractEventLoop, 228 | ) -> None: 229 | self._http_server = http_server 230 | self._thread = thread 231 | self._loop = loop 232 | 233 | def close(self) -> None: 234 | """ 235 | Stop the server, close the event loop, and join the thread. 236 | """ 237 | self._loop.call_soon_threadsafe(self._loop.stop) 238 | 239 | self._thread.join() 240 | self._loop.close() 241 | 242 | @property 243 | def https(self) -> bool: 244 | return self._http_server.https 245 | 246 | @property 247 | def socket(self) -> Socket: 248 | return self._http_server.socket 249 | 250 | @property 251 | def url(self) -> str: 252 | return self._http_server.url 253 | 254 | @property 255 | def is_registered(self) -> bool: 256 | return self._http_server.is_registered 257 | 258 | 259 | def start_http_server_in_thread( 260 | *, 261 | port: int = 0, 262 | addr: str = "", 263 | ssl_ctx: ssl.SSLContext | None = None, 264 | service_discovery: ServiceDiscovery | None = None, 265 | ) -> ThreadedMetricsHTTPServer: 266 | """ 267 | Start an asyncio HTTP(S) server in a new thread with an own event loop. 268 | 269 | Ideal to expose your metrics in non-asyncio Python 3 applications. 270 | 271 | For arguments see :func:`start_http_server`. 272 | 273 | :rtype: ThreadedMetricsHTTPServer 274 | """ 275 | q: queue.Queue = queue.Queue() 276 | loop = asyncio.new_event_loop() 277 | 278 | def server() -> None: 279 | asyncio.set_event_loop(loop) 280 | http = loop.run_until_complete( 281 | start_http_server( 282 | port=port, 283 | addr=addr, 284 | ssl_ctx=ssl_ctx, 285 | service_discovery=service_discovery, 286 | ) 287 | ) 288 | q.put(http) 289 | loop.run_forever() 290 | loop.run_until_complete(http.close()) 291 | 292 | t = threading.Thread( 293 | target=server, name="PrometheusAsyncWebEndpoint", daemon=True 294 | ) 295 | t.start() 296 | 297 | return ThreadedMetricsHTTPServer(q.get(), t, loop) 298 | -------------------------------------------------------------------------------- /src/prometheus_async/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hynek/prometheus-async/0da41f49e4865c36019fc6da22f57e3a6aee7b64/src/prometheus_async/py.typed -------------------------------------------------------------------------------- /src/prometheus_async/tx/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | Twisted-related functionality. 19 | """ 20 | 21 | from ._decorators import count_exceptions, time, track_inprogress 22 | 23 | 24 | __all__ = ["count_exceptions", "time", "track_inprogress"] 25 | -------------------------------------------------------------------------------- /src/prometheus_async/tx/_decorators.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """ 18 | Decorators for Twisted. 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | from time import perf_counter 24 | from typing import TYPE_CHECKING, Callable, overload 25 | 26 | from twisted.internet.defer import Deferred 27 | from wrapt import decorator 28 | 29 | 30 | if TYPE_CHECKING: 31 | from typing import Any 32 | 33 | from prometheus_client import Gauge 34 | 35 | from ..types import C, D, F, Incrementer, Observer, P, T 36 | 37 | 38 | @overload 39 | def time(metric: Observer) -> Callable[[Callable[P, D]], Callable[P, D]]: ... 40 | 41 | 42 | @overload 43 | def time(metric: Observer, deferred: D) -> D: ... 44 | 45 | 46 | def time(metric: Observer, deferred: D | None = None) -> D | C: 47 | r""" 48 | Call ``metric.observe(time)`` with runtime in seconds. 49 | 50 | Can be used as a decorator as well as on ``Deferred``\ s. 51 | 52 | Works with both sync and async results. 53 | 54 | :returns: function or ``Deferred``. 55 | """ 56 | if deferred is None: 57 | 58 | @decorator 59 | def time_decorator(f: C, _: Any, args: Any, kw: Any) -> C | D: 60 | def observe(value: T) -> T: 61 | metric.observe(perf_counter() - start_time) 62 | return value 63 | 64 | start_time = perf_counter() 65 | rv = f(*args, **kw) 66 | if isinstance(rv, Deferred): 67 | return rv.addBoth(observe) # type: ignore[return-value] 68 | 69 | return observe(rv) 70 | 71 | return time_decorator 72 | 73 | def observe(value: T) -> T: 74 | metric.observe(perf_counter() - start_time) 75 | return value 76 | 77 | start_time = perf_counter() 78 | return deferred.addBoth(observe) # type: ignore[return-value] 79 | 80 | 81 | @overload 82 | def count_exceptions( 83 | metric: Incrementer, *, exc: type[BaseException] = ... 84 | ) -> Callable[P, C]: ... 85 | 86 | 87 | @overload 88 | def count_exceptions( 89 | metric: Incrementer, 90 | deferred: D, 91 | *, 92 | exc: type[BaseException] = ..., 93 | ) -> D: ... 94 | 95 | 96 | def count_exceptions( 97 | metric: Incrementer, 98 | deferred: D | None = None, 99 | *, 100 | exc: type[BaseException] = BaseException, 101 | ) -> D | Callable[P, C]: 102 | """ 103 | Call ``metric.inc()`` whenever *exc* is caught. 104 | 105 | Can be used as a decorator or on a ``Deferred``. 106 | 107 | :returns: function (if decorator) or ``Deferred``. 108 | """ 109 | 110 | def inc(fail: F) -> F: 111 | fail.trap(exc) # type: ignore[no-untyped-call] 112 | metric.inc() 113 | return fail 114 | 115 | if deferred is None: 116 | 117 | @decorator 118 | def count_exceptions_decorator( 119 | f: C, _: Any, args: Any, kw: Any 120 | ) -> C | D: 121 | try: 122 | rv = f(*args, **kw) 123 | except exc: 124 | metric.inc() 125 | raise 126 | 127 | if isinstance(rv, Deferred): 128 | return rv.addErrback(inc) # type: ignore[return-value] 129 | 130 | return rv 131 | 132 | return count_exceptions_decorator 133 | 134 | return deferred.addErrback(inc) # type: ignore[return-value] 135 | 136 | 137 | @overload 138 | def track_inprogress(metric: Gauge) -> Callable[P, C]: ... 139 | 140 | 141 | @overload 142 | def track_inprogress(metric: Gauge, deferred: D) -> D: ... 143 | 144 | 145 | def track_inprogress( 146 | metric: Gauge, deferred: D | None = None 147 | ) -> D | Callable[P, C]: 148 | """ 149 | Call ``metrics.inc()`` on entry and ``metric.dec()`` on exit. 150 | 151 | Can be used as a decorator or on a ``Deferred``. 152 | 153 | :returns: function (if decorator) or ``Deferred``. 154 | """ 155 | 156 | def dec(rv: T) -> T: 157 | metric.dec() 158 | return rv 159 | 160 | if deferred is None: 161 | 162 | @decorator 163 | def track_inprogress_decorator( 164 | f: C, _: Any, args: Any, kw: Any 165 | ) -> C | D: 166 | metric.inc() 167 | try: 168 | rv = f(*args, **kw) 169 | finally: 170 | if isinstance(rv, Deferred): 171 | return rv.addBoth(dec) # type: ignore[return-value] # noqa: B012 172 | 173 | metric.dec() 174 | return rv # noqa: B012 175 | 176 | return track_inprogress_decorator 177 | 178 | metric.inc() 179 | return deferred.addBoth(dec) # type: ignore[return-value] 180 | -------------------------------------------------------------------------------- /src/prometheus_async/types.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from __future__ import annotations 18 | 19 | import sys 20 | 21 | from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar 22 | 23 | 24 | if TYPE_CHECKING: 25 | from prometheus_async.aio.web import MetricsHTTPServer 26 | 27 | try: 28 | from twisted.internet.defer import Deferred 29 | from twisted.python.failure import Failure 30 | 31 | # Having these types here results in nicer API docs without 32 | # private modules in identifiers. 33 | D = TypeVar("D", bound=Deferred) 34 | F = TypeVar("F", bound=Failure) 35 | except ImportError: 36 | pass 37 | 38 | # This construct works with Mypy. 39 | # Doing the obvious ImportError route leads to an 'Incompatible import of 40 | # "Protocol"' error. 41 | from typing import Protocol 42 | 43 | 44 | if sys.version_info >= (3, 10): 45 | from typing import ParamSpec 46 | else: 47 | from typing_extensions import ParamSpec 48 | 49 | __all__ = [ 50 | "Deregisterer", 51 | "IncDecrementer", 52 | "Incrementer", 53 | "Observer", 54 | "ParamSpec", 55 | "ServiceDiscovery", 56 | ] 57 | 58 | P = ParamSpec("P") 59 | R = TypeVar("R", bound=Awaitable) 60 | T = TypeVar("T") 61 | C = TypeVar("C", bound=Callable) 62 | 63 | 64 | Deregisterer = Callable[[], Awaitable[None]] 65 | 66 | 67 | class ServiceDiscovery(Protocol): 68 | async def register( 69 | self, metrics_server: MetricsHTTPServer 70 | ) -> Deregisterer | None: ... 71 | 72 | 73 | class Observer(Protocol): 74 | def observe(self, value: float, /) -> None: ... 75 | 76 | 77 | class Incrementer(Protocol): 78 | def inc( 79 | self, amount: float = 1, exemplar: dict[str, str] | None = None 80 | ) -> None: ... 81 | 82 | 83 | class IncDecrementer(Protocol): 84 | """ 85 | Not used anymore! 86 | 87 | .. deprecated:: 22.2.0 88 | """ 89 | 90 | def inc( 91 | self, amount: float = 1, exemplar: dict[str, str] | None = None 92 | ) -> None: ... 93 | 94 | def dec(self, amount: float = 1) -> None: ... 95 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /tests/test_aio.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import http.client 19 | import inspect 20 | import sys 21 | import uuid 22 | 23 | from types import SimpleNamespace 24 | from unittest import mock 25 | 26 | import pytest 27 | import wrapt 28 | 29 | from prometheus_client import CONTENT_TYPE_LATEST, Counter 30 | from prometheus_client.openmetrics import exposition as openmetrics 31 | 32 | from prometheus_async import aio 33 | from prometheus_async.aio.sd import ConsulAgent, _LocalConsulAgentClient 34 | 35 | 36 | try: 37 | import aiohttp 38 | 39 | from multidict import CIMultiDict 40 | except ImportError: 41 | aiohttp = None 42 | CIMultiDict = dict 43 | 44 | 45 | ve = ValueError("foo") 46 | 47 | 48 | async def coro(): 49 | await asyncio.sleep(0) 50 | return 42 51 | 52 | 53 | async def coro_w_argument(x: int) -> str: 54 | await asyncio.sleep(0) 55 | return str(x) 56 | 57 | 58 | async def raiser(): 59 | await asyncio.sleep(0) 60 | raise ve 61 | 62 | 63 | class C: 64 | async def coro(self): 65 | await asyncio.sleep(0) 66 | return 42 67 | 68 | async def coro_w_argument(self, x: int) -> str: 69 | await asyncio.sleep(0) 70 | return str(x) 71 | 72 | async def raiser(self): 73 | await asyncio.sleep(0) 74 | raise ve 75 | 76 | 77 | @pytest.mark.asyncio 78 | class TestTime: 79 | @pytest.mark.parametrize("coro", [coro, C().coro]) 80 | async def test_still_coroutine_function(self, fake_observer, coro): 81 | """ 82 | It's ensured that a decorated function still passes as a coroutine 83 | function. Otherwise PYTHONASYNCIODEBUG=1 breaks. 84 | """ 85 | func = aio.time(fake_observer)(coro) 86 | new_coro = func() 87 | 88 | assert inspect.iscoroutine(new_coro) 89 | assert inspect.iscoroutinefunction(func) 90 | 91 | await new_coro 92 | 93 | @pytest.mark.usefixtures("patch_timer") 94 | async def test_decorator_sync(self, fake_observer): 95 | """ 96 | time works with sync results functions. 97 | """ 98 | 99 | @aio.time(fake_observer) 100 | async def func(): 101 | if True: 102 | return 42 103 | 104 | await asyncio.sleep(0) # noqa: RET503 105 | 106 | assert 42 == await func() 107 | assert [1] == fake_observer._observed 108 | 109 | @pytest.mark.usefixtures("patch_timer") 110 | @pytest.mark.parametrize("coro", [coro, C().coro]) 111 | async def test_decorator(self, fake_observer, coro): 112 | """ 113 | time works with asyncio results functions. 114 | """ 115 | 116 | func = aio.time(fake_observer)(coro) 117 | 118 | rv = func() 119 | 120 | assert asyncio.iscoroutine(rv) 121 | assert [] == fake_observer._observed 122 | 123 | rv = await rv 124 | 125 | assert [1] == fake_observer._observed 126 | assert 42 == rv 127 | 128 | @pytest.mark.usefixtures("patch_timer") 129 | @pytest.mark.parametrize("coro", [raiser, C().raiser]) 130 | async def test_decorator_exc(self, fake_observer, coro): 131 | """ 132 | Does not swallow exceptions. 133 | """ 134 | func = aio.time(fake_observer)(coro) 135 | 136 | with pytest.raises(ValueError) as e: 137 | await func() 138 | 139 | assert ve is e.value 140 | assert [1] == fake_observer._observed 141 | 142 | @pytest.mark.usefixtures("patch_timer") 143 | async def test_future(self, fake_observer): 144 | """ 145 | time works with a asyncio.Future. 146 | """ 147 | fut = asyncio.Future() 148 | coro = aio.time(fake_observer, fut) 149 | 150 | assert [] == fake_observer._observed 151 | 152 | fut.set_result(42) 153 | 154 | assert 42 == await coro 155 | assert [1] == fake_observer._observed 156 | 157 | @pytest.mark.usefixtures("patch_timer") 158 | async def test_future_exc(self, fake_observer): 159 | """ 160 | Does not swallow exceptions. 161 | """ 162 | fut = asyncio.Future() 163 | coro = aio.time(fake_observer, fut) 164 | v = ValueError("foo") 165 | 166 | assert [] == fake_observer._observed 167 | 168 | fut.set_exception(v) 169 | 170 | with pytest.raises(ValueError) as e: 171 | await coro 172 | 173 | assert [1] == fake_observer._observed 174 | assert v is e.value 175 | 176 | @pytest.mark.usefixtures("patch_timer") 177 | async def test_decorator_wrapt(self, fake_observer): 178 | """ 179 | Our decorator doesn't break wrapt-based decorators further down. 180 | 181 | A naive decorator using functools.wraps would add `self` to args and 182 | zero out `instance`. Potentially breaking signatures. 183 | """ 184 | before_sig = before_instance = before_kw = before_args = None 185 | after_sig = after_instance = after_kw = after_args = None 186 | 187 | @wrapt.decorator 188 | def before(wrapped, instance, args, kw): 189 | assert instance is not None 190 | nonlocal before_args, before_kw, before_instance, before_sig 191 | 192 | before_args = args 193 | before_kw = kw 194 | before_instance = instance 195 | before_sig = inspect.signature(wrapped) 196 | 197 | return wrapped(*args, **kw) 198 | 199 | @wrapt.decorator 200 | def after(wrapped, instance, args, kw): 201 | assert instance is not None 202 | nonlocal after_args, after_kw, after_instance, after_sig 203 | 204 | after_args = args 205 | after_kw = kw 206 | after_instance = instance 207 | after_sig = inspect.signature(wrapped) 208 | 209 | return wrapped(*args, **kw) 210 | 211 | class C: 212 | @after 213 | @aio.time(fake_observer) 214 | @before 215 | async def coro(self, x): 216 | await asyncio.sleep(0) 217 | return str(x) 218 | 219 | i1 = C() 220 | i2 = C() 221 | 222 | assert "5" == await i1.coro(5) 223 | assert "42" == await i2.coro(42) 224 | 225 | assert after_instance is before_instance 226 | assert after_instance is not None 227 | assert after_args == before_args is not None 228 | assert after_kw == before_kw is not None 229 | assert before_sig == after_sig 230 | assert [1, 1] == fake_observer._observed 231 | 232 | 233 | @pytest.mark.asyncio 234 | class TestCountExceptions: 235 | async def test_decorator_no_exc(self, fake_counter): 236 | """ 237 | If no exception is raised, the counter does not change. 238 | """ 239 | 240 | @aio.count_exceptions(fake_counter) 241 | async def func(): 242 | await asyncio.sleep(0.0) 243 | return 42 244 | 245 | assert 42 == await func() 246 | assert 0 == fake_counter._val 247 | 248 | async def test_decorator_wrong_exc(self, fake_counter): 249 | """ 250 | If a wrong exception is raised, the counter does not change. 251 | """ 252 | 253 | @aio.count_exceptions(fake_counter, exc=TypeError) 254 | async def func(): 255 | await asyncio.sleep(0.0) 256 | raise ValueError 257 | 258 | with pytest.raises(ValueError): 259 | await func() 260 | 261 | assert 0 == fake_counter._val 262 | 263 | async def test_decorator_exc(self, fake_counter): 264 | """ 265 | If the correct exception is raised, count it. 266 | """ 267 | 268 | @aio.count_exceptions(fake_counter, exc=ValueError) 269 | async def func(): 270 | await asyncio.sleep(0.0) 271 | raise ValueError 272 | 273 | with pytest.raises(ValueError): 274 | await func() 275 | 276 | assert 1 == fake_counter._val 277 | 278 | async def test_future_no_exc(self, fake_counter): 279 | """ 280 | If no exception is raised, the counter does not change. 281 | """ 282 | fut = asyncio.Future() 283 | coro = aio.count_exceptions(fake_counter, future=fut) 284 | 285 | fut.set_result(42) 286 | 287 | assert 42 == await coro 288 | assert 0 == fake_counter._val 289 | 290 | async def test_future_wrong_exc(self, fake_counter): 291 | """ 292 | If a wrong exception is raised, the counter does not change. 293 | """ 294 | fut = asyncio.Future() 295 | coro = aio.count_exceptions(fake_counter, exc=TypeError, future=fut) 296 | 297 | fut.set_exception(ValueError()) 298 | 299 | with pytest.raises(ValueError): 300 | assert 42 == await coro 301 | assert 0 == fake_counter._val 302 | 303 | async def test_future_exc(self, fake_counter): 304 | """ 305 | If the correct exception is raised, count it. 306 | """ 307 | fut = asyncio.Future() 308 | coro = aio.count_exceptions(fake_counter, exc=ValueError, future=fut) 309 | 310 | fut.set_exception(ValueError()) 311 | 312 | with pytest.raises(ValueError): 313 | assert 42 == await coro 314 | assert 1 == fake_counter._val 315 | 316 | 317 | @pytest.mark.asyncio 318 | class TestTrackInprogress: 319 | async def test_async_decorator(self, fake_gauge): 320 | """ 321 | Works as a decorator of async functions. 322 | """ 323 | 324 | @aio.track_inprogress(fake_gauge) 325 | async def f(): 326 | await asyncio.sleep(0) 327 | 328 | await f() 329 | 330 | assert 0 == fake_gauge._val 331 | assert 2 == fake_gauge._calls 332 | 333 | async def test_coroutine(self, fake_gauge): 334 | """ 335 | Incs and decs. 336 | """ 337 | f = aio.track_inprogress(fake_gauge)(coro) 338 | 339 | await f() 340 | 341 | assert 0 == fake_gauge._val 342 | assert 2 == fake_gauge._calls 343 | 344 | async def test_future(self, fake_gauge): 345 | """ 346 | Incs and decs. 347 | """ 348 | fut = asyncio.Future() 349 | 350 | wrapped = aio.track_inprogress(fake_gauge, fut) 351 | 352 | assert 1 == fake_gauge._val 353 | 354 | fut.set_result(42) 355 | 356 | await wrapped 357 | 358 | assert 0 == fake_gauge._val 359 | 360 | 361 | class FakeSD: 362 | """ 363 | Fake Service Discovery. 364 | """ 365 | 366 | registered_ms = None 367 | 368 | async def register(self, metrics_server): 369 | self.registered_ms = metrics_server 370 | 371 | async def deregister(): 372 | return True 373 | 374 | return deregister 375 | 376 | 377 | @pytest.mark.skipif(aiohttp is None, reason="Needs aiohttp.") 378 | @pytest.mark.asyncio 379 | class TestWeb: 380 | async def test_server_stats_old(self): 381 | """ 382 | Returns a response with the current stats in the old format. 383 | """ 384 | Counter("test_server_stats_total", "cnt").inc() 385 | rv = await aio.web.server_stats(SimpleNamespace(headers=CIMultiDict())) 386 | 387 | body = rv.body.decode() 388 | 389 | assert CONTENT_TYPE_LATEST == rv.headers["Content-Type"] 390 | assert body.startswith( 391 | """\ 392 | # HELP test_server_stats_total cnt 393 | # TYPE test_server_stats_total counter 394 | test_server_stats_total 1.0 395 | # HELP test_server_stats_created cnt 396 | # TYPE test_server_stats_created gauge 397 | test_server_stats_created """ 398 | ) 399 | 400 | async def test_server_stats_openmetrics(self): 401 | """ 402 | Returns a response with the current stats in the open metrics format. 403 | """ 404 | Counter("test_server_stats_total", "cnt").inc() 405 | rv = await aio.web.server_stats( 406 | SimpleNamespace( 407 | headers=CIMultiDict( 408 | Accept="application/openmetrics-text; version=0.0.1," 409 | "text/plain;version=0.0.4;q=0.5,*/*;q=0.1" 410 | ) 411 | ) 412 | ) 413 | 414 | body = rv.body.decode() 415 | 416 | assert openmetrics.CONTENT_TYPE_LATEST == rv.headers["Content-Type"] 417 | assert body.startswith( 418 | """\ 419 | # HELP test_server_stats cnt 420 | # TYPE test_server_stats counter 421 | test_server_stats_total 1.0 422 | test_server_stats_created """ 423 | ) 424 | assert body.endswith("EOF\n") 425 | 426 | async def test_cheap(self): 427 | """ 428 | Returns a simple string. 429 | """ 430 | rv = await aio.web._cheap(None) 431 | 432 | assert ( 433 | b'Metrics' 434 | == rv.body 435 | ) 436 | assert "text/html" == rv.content_type 437 | 438 | @pytest.mark.parametrize("sd", [None, FakeSD()]) 439 | async def test_start_http_server(self, sd): 440 | """ 441 | Integration test: server gets started, is registered, and serves stats. 442 | """ 443 | server = await aio.web.start_http_server( 444 | addr="127.0.0.1", service_discovery=sd 445 | ) 446 | 447 | assert isinstance(server, aio.web.MetricsHTTPServer) 448 | assert server.is_registered is (sd is not None) 449 | if sd is not None: 450 | assert sd.registered_ms is server 451 | 452 | addr, port = server.socket 453 | Counter("test_start_http_server_total", "cnt").inc() 454 | 455 | async with aiohttp.ClientSession() as s: 456 | rv = await s.request( 457 | "GET", 458 | f"http://{addr}:{port}/metrics", 459 | ) 460 | body = await rv.text() 461 | 462 | assert ( 463 | "# HELP test_start_http_server_total cnt\n# " 464 | "TYPE test_start_http_server_total" 465 | " counter\ntest_start_http_server_total 1.0\n" in body 466 | ) 467 | await server.close() 468 | 469 | @pytest.mark.parametrize("sd", [None, FakeSD()]) 470 | def test_start_in_thread(self, sd): 471 | """ 472 | Threaded version starts and exits properly, passes on service 473 | discovery. 474 | """ 475 | Counter("test_start_http_server_in_thread_total", "cnt").inc() 476 | t = aio.web.start_http_server_in_thread( 477 | addr="127.0.0.1", service_discovery=sd 478 | ) 479 | 480 | assert isinstance(t, aio.web.ThreadedMetricsHTTPServer) 481 | assert "PrometheusAsyncWebEndpoint" == t._thread.name 482 | assert t.url.startswith("http") 483 | assert False is t.https 484 | assert t.is_registered is (sd is not None) 485 | if sd is not None: 486 | assert sd.registered_ms is t._http_server 487 | 488 | s = t.socket 489 | h = http.client.HTTPConnection(s.addr, port=s[1]) 490 | h.request("GET", "/metrics") 491 | rsp = h.getresponse() 492 | body = rsp.read().decode() 493 | rsp.close() 494 | h.close() 495 | 496 | assert "HELP test_start_http_server_in_thread_total cnt" in body 497 | 498 | t.close() 499 | 500 | assert False is t._thread.is_alive() 501 | 502 | @pytest.mark.parametrize(("addr", "url"), [("127.0.0.1", "127.0.0.1:")]) 503 | async def test_url(self, addr, url): 504 | """ 505 | The URL of a MetricsHTTPServer is correctly computed. 506 | """ 507 | server = await aio.web.start_http_server(addr=addr) 508 | sock = server.socket 509 | 510 | part = url + str(sock.port) + "/" 511 | assert "http://" + part == server.url 512 | 513 | server.https = True 514 | assert "https://" + part == server.url 515 | 516 | await server.close() 517 | 518 | 519 | @pytest.mark.skipif(aiohttp is None, reason="Needs aiohttp.") 520 | @pytest.mark.asyncio 521 | class TestConsulAgent: 522 | @pytest.mark.parametrize("deregister", [True, False]) 523 | async def test_integration(self, deregister): 524 | """ 525 | Integration test with a real consul agent. Start a service, register 526 | it, close it, verify it's deregistered. 527 | """ 528 | tags = ("foo", "bar") 529 | service_id = str(uuid.uuid4()) # allow for parallel tests 530 | 531 | con = _LocalConsulAgentClient(token=None) 532 | ca = ConsulAgent( 533 | name="test-metrics", 534 | service_id=service_id, 535 | tags=tags, 536 | deregister=deregister, 537 | ) 538 | 539 | try: 540 | server = await aio.web.start_http_server( 541 | addr="127.0.0.1", service_discovery=ca 542 | ) 543 | except aiohttp.ClientOSError: 544 | pytest.skip("Missing consul agent.") 545 | 546 | svc = (await con.get_services())[service_id] 547 | 548 | assert "test-metrics" == svc["Service"] 549 | assert sorted(tags) == sorted(svc["Tags"]) 550 | assert server.socket.addr == svc["Address"] 551 | assert server.socket.port == svc["Port"] 552 | 553 | await server.close() 554 | 555 | services = await con.get_services() 556 | 557 | if deregister: 558 | # Assert service is gone iff we are supposed to deregister. 559 | assert service_id not in services 560 | else: 561 | assert service_id in services 562 | 563 | # Clean up behind ourselves. 564 | resp = await con.deregister_service(service_id) 565 | assert 200 == resp.status 566 | 567 | @pytest.mark.parametrize("deregister", [True, False]) 568 | @pytest.mark.skipif(sys.version_info < (3, 8), reason="AsyncMock is 3.8+") 569 | async def test_mocked(self, deregister): 570 | """ 571 | Same as test_integration, but using mocks instead of a real consul 572 | agent. 573 | """ 574 | tags = ("foo", "bar") 575 | service_id = str(uuid.uuid4()) # allow for parallel tests 576 | 577 | con = mock.AsyncMock(auto_spec=_LocalConsulAgentClient) 578 | ca = ConsulAgent( 579 | name="test-metrics", 580 | service_id=service_id, 581 | tags=tags, 582 | deregister=deregister, 583 | ) 584 | ca.consul = con 585 | 586 | server = await aio.web.start_http_server( 587 | addr="127.0.0.1", service_discovery=ca 588 | ) 589 | 590 | con.register_service.assert_awaited_once() 591 | reg = con.register_service.await_args.kwargs 592 | 593 | assert service_id == reg["service_id"] 594 | assert "test-metrics" == reg["name"] 595 | assert sorted(tags) == sorted(reg["tags"]) 596 | assert ( 597 | f"http://{server.socket.addr}:{server.socket.port}/" 598 | == reg["metrics_server"].url 599 | ) 600 | 601 | await server.close() 602 | 603 | if deregister: 604 | # Assert service is gone iff we are supposed to deregister. 605 | con.deregister_service.assert_awaited_once_with(service_id) 606 | else: 607 | con.deregister_service.assert_not_called() 608 | 609 | async def test_none_if_register_fails(self): 610 | """ 611 | If register fails, return None. 612 | """ 613 | 614 | class FakeMetricsServer: 615 | socket = mock.Mock(addr="127.0.0.1", port=12345) 616 | url = "http://127.0.0.1:12345/metrics" 617 | 618 | class FakeSession: 619 | async def __aexit__(self, exc_type, exc_value, traceback): 620 | pass 621 | 622 | async def __aenter__(self): 623 | class FakeConnection: 624 | async def put(self, *args, **kw): 625 | return mock.Mock(status=400) 626 | 627 | return FakeConnection() 628 | 629 | ca = ConsulAgent() 630 | ca.consul.session_factory = FakeSession 631 | 632 | assert None is (await ca.register(FakeMetricsServer())) 633 | 634 | 635 | @pytest.mark.skipif(aiohttp is None, reason="Needs aiohttp.") 636 | class TestLocalConsulAgentClient: 637 | def test_sets_headers(self): 638 | """ 639 | If a token is passed, "X-Consul-Token" header is set. 640 | """ 641 | con = _LocalConsulAgentClient(token="token42") 642 | 643 | assert "token42" == con.headers["X-Consul-Token"] 644 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | from importlib import metadata 19 | 20 | import pytest 21 | 22 | import prometheus_async 23 | 24 | 25 | class TestLegacyMetadataHack: 26 | def test_version(self): 27 | """ 28 | prometheus_async.__version__ returns the correct version. 29 | """ 30 | assert ( 31 | metadata.version("prometheus-async") 32 | == prometheus_async.__version__ 33 | ) 34 | 35 | def test_does_not_exist(self): 36 | """ 37 | Asking for unsupported dunders raises an AttributeError. 38 | """ 39 | with pytest.raises( 40 | AttributeError, 41 | match="module prometheus_async has no attribute __yolo__", 42 | ): 43 | prometheus_async.__yolo__ 44 | -------------------------------------------------------------------------------- /tests/test_tx.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | # 3 | # Copyright 2016 Hynek Schlawack 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import functools 18 | 19 | import pytest 20 | 21 | from twisted.internet.defer import Deferred, Failure, fail, succeed 22 | 23 | from prometheus_async import tx 24 | 25 | 26 | def _from_async_fn(async_fn): 27 | # this code is based on 28 | # https://docs.twisted.org/en/twisted-22.8.0/api/twisted.trial._synctest._Assertions.html#successResultOf 29 | # except it takes a coroutine function and wraps it in a Deferred first 30 | @functools.wraps(async_fn) 31 | def wrapper(*args, **kwargs): 32 | results = [] 33 | d = Deferred.fromCoroutine(async_fn(*args, **kwargs)).addBoth( 34 | results.append 35 | ) 36 | try: 37 | if not results: 38 | # none of the tests here use the reactor and so 39 | # the Deferred should always immediately complete 40 | # and so this branch will never execute 41 | raise RuntimeError( 42 | f"Success result expected on {d!r}, " 43 | "found no result instead" 44 | ) 45 | 46 | if isinstance(results[0], Failure): 47 | results[0].raiseException() 48 | 49 | return results[0] 50 | finally: 51 | # remove a reference cycle via the Deferred[Failure[E]] 52 | # or list[Failure[E]] 53 | del d 54 | del results 55 | 56 | return wrapper 57 | 58 | 59 | class TestFromAsyncFn: 60 | def test_no_result(self): 61 | """ 62 | Missing results are caught. 63 | """ 64 | 65 | @_from_async_fn 66 | async def demo(): 67 | return await Deferred() 68 | 69 | with pytest.raises( 70 | RuntimeError, 71 | match=r"Success result expected on , " 72 | "found no result instead", 73 | ): 74 | demo() 75 | 76 | def test_failure_result(self): 77 | """ 78 | If an async function fails, the error is propagated. 79 | """ 80 | 81 | class SentinelError(Exception): 82 | pass 83 | 84 | sentinel_exception = SentinelError("sentinel exception") 85 | 86 | @_from_async_fn 87 | async def demo(): 88 | return await fail(sentinel_exception) 89 | 90 | with pytest.raises( 91 | SentinelError, match=r"sentinel exception" 92 | ) as exc_info: 93 | demo() 94 | 95 | assert exc_info.value is sentinel_exception 96 | 97 | def test_success_result(self): 98 | """ 99 | If an async function succeeds, the success and result are propagated. 100 | """ 101 | sentinel = object() 102 | 103 | @_from_async_fn 104 | async def demo(): 105 | return await succeed(sentinel) 106 | 107 | assert demo() is sentinel 108 | 109 | 110 | class TestTime: 111 | @pytest.mark.usefixtures("patch_timer") 112 | def test_decorator_sync(self, fake_observer): 113 | """ 114 | time works with sync results functions. 115 | """ 116 | 117 | @tx.time(fake_observer) 118 | def func(): 119 | return 42 120 | 121 | assert 42 == func() 122 | assert [1] == fake_observer._observed 123 | 124 | @pytest.mark.usefixtures("patch_timer") 125 | @_from_async_fn 126 | async def test_decorator(self, fake_observer): 127 | """ 128 | time works with functions returning Deferreds. 129 | """ 130 | 131 | @tx.time(fake_observer) 132 | def func(): 133 | return succeed(42) 134 | 135 | rv = func() 136 | 137 | # Twisted runs fires callbacks immediately. 138 | assert [1] == fake_observer._observed 139 | assert 42 == (await rv) 140 | assert [1] == fake_observer._observed 141 | 142 | @pytest.mark.usefixtures("patch_timer") 143 | @_from_async_fn 144 | async def test_decorator_exc(self, fake_observer): 145 | """ 146 | Does not swallow exceptions. 147 | """ 148 | v = ValueError("foo") 149 | 150 | @tx.time(fake_observer) 151 | def func(): 152 | return fail(v) 153 | 154 | with pytest.raises(ValueError) as e: 155 | await func() 156 | 157 | assert v is e.value 158 | 159 | @pytest.mark.usefixtures("patch_timer") 160 | @_from_async_fn 161 | async def test_deferred(self, fake_observer): 162 | """ 163 | time works with Deferreds. 164 | """ 165 | d = tx.time(fake_observer, Deferred()) 166 | 167 | assert [] == fake_observer._observed 168 | 169 | d.callback(42) 170 | 171 | assert 42 == (await d) 172 | assert [1] == fake_observer._observed 173 | 174 | 175 | class TestCountExceptions: 176 | @_from_async_fn 177 | async def test_decorator_no_exc(self, fake_counter): 178 | """ 179 | If no exception is raised, the counter does not change. 180 | """ 181 | 182 | @tx.count_exceptions(fake_counter) 183 | def func(): 184 | return succeed(42) 185 | 186 | assert 42 == (await func()) 187 | assert 0 == fake_counter._val 188 | 189 | def test_decorator_no_exc_sync(self, fake_counter): 190 | """ 191 | If no exception is raised, the counter does not change. 192 | """ 193 | 194 | @tx.count_exceptions(fake_counter) 195 | def func(): 196 | return 42 197 | 198 | assert 42 == func() 199 | assert 0 == fake_counter._val 200 | 201 | @_from_async_fn 202 | async def test_decorator_wrong_exc(self, fake_counter): 203 | """ 204 | If a wrong exception is raised, the counter does not change. 205 | """ 206 | 207 | @tx.count_exceptions(fake_counter, exc=ValueError) 208 | def func(): 209 | return fail(TypeError()) 210 | 211 | with pytest.raises(TypeError): 212 | await func() 213 | 214 | assert 0 == fake_counter._val 215 | 216 | @_from_async_fn 217 | async def test_decorator_exc(self, fake_counter): 218 | """ 219 | If the correct exception is raised, count it. 220 | """ 221 | 222 | @tx.count_exceptions(fake_counter, exc=TypeError) 223 | def func(): 224 | return fail(TypeError()) 225 | 226 | with pytest.raises(TypeError): 227 | await func() 228 | 229 | assert 1 == fake_counter._val 230 | 231 | def test_decorator_exc_sync(self, fake_counter): 232 | """ 233 | If the correct synchronous exception is raised, count it. 234 | """ 235 | 236 | @tx.count_exceptions(fake_counter) 237 | def func(): 238 | if True: 239 | raise TypeError("foo") 240 | return succeed(42) 241 | 242 | with pytest.raises(TypeError): 243 | func() 244 | 245 | assert 1 == fake_counter._val 246 | 247 | @_from_async_fn 248 | async def test_deferred_no_exc(self, fake_counter): 249 | """ 250 | If no exception is raised, the counter does not change. 251 | """ 252 | d = succeed(42) 253 | 254 | assert 42 == (await tx.count_exceptions(fake_counter, d)) 255 | assert 0 == fake_counter._val 256 | 257 | 258 | class TestTrackInprogress: 259 | @_from_async_fn 260 | async def test_deferred(self, fake_gauge): 261 | """ 262 | Incs and decs if its passed a Deferred. 263 | """ 264 | d = tx.track_inprogress(fake_gauge, Deferred()) 265 | 266 | assert 1 == fake_gauge._val 267 | 268 | d.callback(42) 269 | rv = await d 270 | 271 | assert 42 == rv 272 | assert 0 == fake_gauge._val 273 | 274 | @_from_async_fn 275 | async def test_decorator_deferred(self, fake_gauge): 276 | """ 277 | Incs and decs if the decorated function returns a Deferred. 278 | """ 279 | d = Deferred() 280 | 281 | @tx.track_inprogress(fake_gauge) 282 | def func(): 283 | return d 284 | 285 | rv = func() 286 | 287 | assert 1 == fake_gauge._val 288 | 289 | d.callback(42) 290 | rv = await rv 291 | 292 | assert 42 == rv 293 | assert 0 == fake_gauge._val 294 | 295 | def test_decorator_value(self, fake_gauge): 296 | """ 297 | Incs and decs if the decorated function returns a value. 298 | """ 299 | 300 | @tx.track_inprogress(fake_gauge) 301 | def func(): 302 | return 42 303 | 304 | rv = func() 305 | 306 | assert 42 == rv 307 | assert 0 == fake_gauge._val 308 | assert 2 == fake_gauge._calls 309 | -------------------------------------------------------------------------------- /tests/typing/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some examples of prometheus-async typing integration. 3 | """ 4 | 5 | from asyncio import Future 6 | 7 | from prometheus_client.metrics import Gauge, Summary 8 | from twisted.internet.defer import Deferred 9 | 10 | from prometheus_async import aio, tx 11 | 12 | 13 | REQ_DURATION = Summary("REQ_DUR", "Request duration") 14 | IN_PROG = Gauge("IN_PROG", "In progress") 15 | 16 | 17 | @aio.time(REQ_DURATION) 18 | async def func(i: int) -> str: 19 | return str(i) 20 | 21 | 22 | @aio.track_inprogress(IN_PROG) 23 | async def func2(i: int) -> str: 24 | return str(i) 25 | 26 | 27 | class C: 28 | @aio.time(REQ_DURATION) 29 | async def method(self, i: int) -> str: 30 | return str(i) 31 | 32 | 33 | @aio.time(REQ_DURATION) 34 | def future_func(i: int) -> Future[str]: 35 | return Future() 36 | 37 | 38 | # `time` can also be applied to futures directly. 39 | future = Future[str]() 40 | aio.time(REQ_DURATION, future) 41 | 42 | 43 | async def coro() -> None: 44 | pass 45 | 46 | 47 | # `time` can also be applied to coroutines 48 | aio.time(REQ_DURATION, coro()) 49 | 50 | # `time` errors on coroutine fns 51 | aio.time(REQ_DURATION, coro) # type: ignore[call-overload] 52 | # `time` errors on non-futures 53 | aio.time(REQ_DURATION, int) # type: ignore[call-overload] 54 | 55 | 56 | @aio.time(REQ_DURATION) # type: ignore[type-var] 57 | def should_be_async_func(i: int) -> str: 58 | return str(i) 59 | 60 | 61 | # 62 | # Twisted 63 | # 64 | tx.time(REQ_DURATION, Deferred()) 65 | 66 | 67 | @tx.time(REQ_DURATION) 68 | def returns_deferred(param: int) -> Deferred: 69 | return Deferred() 70 | 71 | 72 | returns_deferred(1) 73 | 74 | # Invalid, takes an int. 75 | returns_deferred("str") # type: ignore[arg-type] 76 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4 3 | env_list = 4 | pre-commit, 5 | mypy-{pkg,api}, 6 | pyright-api, 7 | py3{8,9,10,11,12,13}{,-twisted,-aiohttp,-oldest}, 8 | docs-doctests, 9 | coverage-report 10 | isolated_build = true 11 | 12 | 13 | [testenv] 14 | description = Run tests. 15 | package = wheel 16 | wheel_build_env = .pkg 17 | pass_env = 18 | FORCE_COLOR 19 | NO_COLOR 20 | dependency_groups = 21 | tests 22 | twisted: twisted 23 | aiohttp: aiohttp 24 | oldest: aiohttp 25 | deps = 26 | oldest: . 27 | oldest: -coldest-supported.txt 28 | commands = coverage run -m pytest {posargs} 29 | 30 | 31 | [testenv:coverage-report] 32 | description = Report coverage over all measured test runs. 33 | deps = coverage 34 | skip_install = true 35 | commands = 36 | coverage combine 37 | coverage report 38 | 39 | 40 | [testenv:pre-commit] 41 | description = Run all pre-commit hooks. 42 | skip_install = true 43 | deps = pre-commit-uv 44 | commands = pre-commit run --all-files 45 | 46 | 47 | [testenv:mypy-pkg] 48 | description = Check own code and API. 49 | dependency_groups = dev 50 | commands = mypy src 51 | 52 | 53 | [testenv:mypy-api] 54 | description = Check only API types. 55 | deps = mypy 56 | commands = mypy tests/typing/api.py 57 | 58 | 59 | [testenv:pyright-api] 60 | description = Check API with Pyright. 61 | deps = 62 | pyright 63 | twisted 64 | commands = pyright tests/typing/api.py 65 | 66 | 67 | [testenv:docs-{build,doctests,linkcheck}] 68 | # Keep base_python in sync with ci.yml/docs and .readthedocs.yaml. 69 | base_python = py313 70 | dependency_groups = docs 71 | commands = 72 | build: sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 73 | doctests: sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 74 | linkcheck: sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/html 75 | 76 | [testenv:docs-watch] 77 | package = editable 78 | base_python = {[testenv:docs-build]base_python} 79 | dependency_groups = {[testenv:docs-build]dependency_groups} 80 | deps = watchfiles 81 | commands = 82 | watchfiles \ 83 | --ignore-paths docs/_build/ \ 84 | 'sphinx-build -W -n --jobs auto -b html -d {envtmpdir}/doctrees docs docs/_build/html' \ 85 | src \ 86 | docs 87 | -------------------------------------------------------------------------------- /zizmor.yml: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | unpinned-uses: 4 | config: 5 | policies: 6 | # We trust GitHub, the PyPA, and ourselves. 7 | "actions/*": ref-pin 8 | "github/*": ref-pin 9 | "pypa/*": ref-pin 10 | "hynek/*": ref-pin 11 | --------------------------------------------------------------------------------