diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..a325df0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: sync + run: uv sync --all-extras + - name: ruff check + run: uv run ruff check . + - name: ruff format + run: uv run ruff format --check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: sync + run: uv sync --all-extras + - name: pyright + run: uv run pyright measure + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: set up python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: sync + run: uv sync --all-extras --python ${{ matrix.python-version }} + - name: pytest + run: uv run --python ${{ matrix.python-version }} pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: build + run: uv build --out-dir dist + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f7a387a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + branches: [main, master] + paths: + - pyproject.toml + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + # PyPI trusted publishing (recommended). Configure at: + # https://pypi.org/manage/account/publishing/ + # Project name: measure Workflow: release.yaml Environment: pypi + environment: + name: pypi + url: https://pypi.org/p/measure + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: jdx/mise-action@v2 + with: + experimental: true + + - name: version info + id: ver + run: | + set -euo pipefail + local_version=$(uv version --short) + published_version=$(uvx get_pypi_latest_version measure 2>/dev/null || echo "none") + echo "local=$local_version" >> "$GITHUB_OUTPUT" + echo "published=$published_version" >> "$GITHUB_OUTPUT" + echo "measure: local=$local_version published=$published_version" + + - name: skip if already published + if: steps.ver.outputs.local == steps.ver.outputs.published + run: echo "::notice::measure ${{ steps.ver.outputs.local }} already on PyPI — skipping" + + - name: build + if: steps.ver.outputs.local != steps.ver.outputs.published + run: uv build --out-dir dist + + - name: publish (trusted publishing via OIDC) + if: steps.ver.outputs.local != steps.ver.outputs.published + run: uv publish dist/* + + - name: verify + if: steps.ver.outputs.local != steps.ver.outputs.published + run: | + set -euo pipefail + for i in $(seq 1 60); do + got=$(uvx get_pypi_latest_version measure 2>/dev/null || echo "") + if [ "$got" = "${{ steps.ver.outputs.local }}" ]; then + echo "::notice::published measure ${{ steps.ver.outputs.local }}" + exit 0 + fi + sleep 2 + done + echo "::warning::did not see measure ${{ steps.ver.outputs.local }} on PyPI after 120s" + + - name: tag release + if: steps.ver.outputs.local != steps.ver.outputs.published + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="v${{ steps.ver.outputs.local }}" + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "tag $tag already exists, skipping" + exit 0 + fi + git tag "$tag" + git push origin "$tag" + + - name: create github release + if: steps.ver.outputs.local != steps.ver.outputs.published + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="v${{ steps.ver.outputs.local }}" + gh release create "$tag" \ + --title "$tag" \ + --generate-notes \ + dist/* || echo "::warning::release creation failed" diff --git a/.gitignore b/.gitignore index 0b91846..183f1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,direnv + +### direnv ### +.direnv +.envrc + ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ @@ -9,7 +15,6 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -21,13 +26,14 @@ lib64/ parts/ sdist/ var/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -38,12 +44,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ # Translations *.mo @@ -51,11 +62,40 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal # Sphinx documentation docs/_build/ -# PyBuilder -target/ +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre / pytype +.pyre/ +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDEs +.idea/ +.vscode/ + +# ruff +.ruff_cache/ -.ropeproject/ +# LSP config files +pyrightconfig.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 56e9699..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: python -python: -- '2.7' -script: python setup.py test -deploy: - provider: pypi - password: - secure: xPVFsDoLHrVPFQC1vDYgvkTNv02ZB4qOh3Kf27lPDOfpLywenDw3qqd3r6cfcF5UmDS/3U3ouJ75av0sVpFeJtnL26MfMfgxtQq4pywPHcL38msBDTL2sIHGEkUFBo7pKeTl1VyL+Y4+2REHGU6C7ZMzGSkhRCld8k7dkV2QRMjKC0O/LTyolkiC3YyJhZ84mglVrtHqqq/zlMOw45EEX8A81tlfuaWSFNPVf8uMk9caqI59qVUAidOlfBsmfLnZdT1++9J0WA32YxGvSGuYiHaSzPsUrnHB/yrrXIr4lV6jJb+qn9QXVMFn5HUJMEejuCHQ9KxlkdhQ1ThoYObmj/1CXf4unt9EszGaQRJftUkQ65UjIAnv2VA2Wy9QAnHgQOGDrBb6jwDeUzf2oET9QO1pAvG14Z1xuZhqQjj8qoTKWYWS6wbC0YYa2CmctztAEDG+Emhn7SwsDIxd8GhVLisC1Tqag7rgRoYJdnt9QtoH5p3UxRnEhLVTYhH27OjEe+5B7y06t/W620Molmp2iu9KnfGC8700kWF63BdS6AWLTahZIa91OUrEWjsc2rfDV4aW2owTKT8AMa9H6kZfCxIzRHr1sfCtYWZcqSH0eFBPu6gvhjFRblfqY3Luh7dhw4gDJpOgGJ5ydXZp8eHirRHOPdDH2yZlDaSkTVsml9A= - username: - secure: yGUz4VtY7vtUfgggJQ3AQUHTZI28fvfpZuYvBpzmLvnd22dQpD3nhu+JY1qRerapS2T9sXaRiuwOzXkmvrjOAbrIfs+opZe5CBFuedpwwspu49S3sb0KiRNgH8fCN96l+qUTtkeU0Iv4sqNh0sVXvUOavK1Jxs7nEVvcSHAUDjAh6cRCa2Y1Xe1YMIC5VwkxHr9bjtXfj+8RSwwt9gPnwgBpB+ESN2fn88j1Ir8AbLkd32bYFr11FJaEPXoYpGJRYezKi/Q0DCeoAZjzuQ93+DY0S6HZCRFx/ZjLrewt/EOcuyNSjKHbA10e0DOZNVPnhGzBtMBbU/CpKLB9+Vfw6w3+w5BdXFwju2NDAvkAKC6n7jSwWuNazHhfR4xRwwHryEP/5hqQKLtcwrvwM1jRgfKRTMh3xEyOJy1t+JnxA1LWbooMDLKGHLapKOoNsrmLY+9gGFb9yV4N7uKGB0s/pV5Awp+uEmANpWvqekGIcF3L7bFL1BmDRxQwpo9lne/U6vEeee/l4iHkIsLV4CwBPLCkMSIztwfLmJBPPXKnon01/RamCTp60eKghlOklIiiDknW/VweGhUIR8MmA2yxdB9Nc/t8YvPpiio1384pCtEj9XrMFcRPe51ist809rg1GR+idjw48BgXbgT4S+AsTsn5IB4vim+9SP9eJaJqP3A= diff --git a/README.md b/README.md index f9d25fe..4921944 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/NorthIsUp/measure.svg)](https://travis-ci.org/NorthIsUp/measure) +[![CI](https://github.com/NorthIsUp/measure/actions/workflows/ci.yaml/badge.svg)](https://github.com/NorthIsUp/measure/actions/workflows/ci.yaml) [![PyPI](https://img.shields.io/pypi/v/measure.svg)](https://pypi.python.org/pypi/measure) # Measure @@ -11,11 +11,8 @@ Measure is a metrics library that allows the user to swap metrics provider ie. ( ```python import measure -stat = measure.stats.Stats( - "homepage", - measure.Meter("pageviews", "Pageview on homepage"), - client=measure.client.PyStatsdClient() -) + +stat = measure.stats.Stats("homepage", measure.Meter("pageviews", "Pageview on homepage"), client=measure.client.PyStatsdClient()) stat.pageviews.mark() ``` @@ -36,3 +33,45 @@ stat.pageviews.mark() - `SetDict` - `FakeStat` +## Development + +This project uses [uv](https://docs.astral.sh/uv/) and [mise](https://mise.jdx.dev/) for tooling. + +```bash +# install tooling and create the virtualenv +mise install +mise run sync + +# run tests +mise run test + +# lint and format +mise run lint +mise run fmt + +# type check +mise run typecheck + +# build sdist + wheel into dist/ +mise run build +``` + +## Releases + +Releases are published automatically to PyPI by `.github/workflows/release.yaml` +whenever the version in `pyproject.toml` changes on the default branch. The +workflow uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) +via OIDC, so no API tokens are needed — configure trusted publishing on PyPI for +the `measure` project, pointing at this repo, the `release.yaml` workflow, and +the `pypi` GitHub environment. + +To cut a release: + +```bash +mise run bump-patch # or bump-minor / bump-major +git commit -am "release: vX.Y.Z" +git push +``` + +The workflow will detect the new version, build, publish to PyPI, push a `vX.Y.Z` +tag, and create a GitHub release. diff --git a/measure/__init__.py b/measure/__init__.py index cec78f3..b56851d 100644 --- a/measure/__init__.py +++ b/measure/__init__.py @@ -1,24 +1,16 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import - -from .stats import ( - Counter, - CounterDict, - FakeStat, - FakeStatDict, - Gauge, - GaugeDict, - Meter, - MeterDict, - Stat, - StatDict, - Stats, - Timer, - TimerDict, -) - -from .client import ( - PyStatsdClient, - Boto3Client -) +from .client import Boto3Client as Boto3Client +from .client import PyStatsdClient as PyStatsdClient +from .client import TestStatsdClient as TestStatsdClient +from .stats import Counter as Counter +from .stats import CounterDict as CounterDict +from .stats import FakeStat as FakeStat +from .stats import FakeStatDict as FakeStatDict +from .stats import Gauge as Gauge +from .stats import GaugeDict as GaugeDict +from .stats import Meter as Meter +from .stats import MeterDict as MeterDict +from .stats import Stat as Stat +from .stats import StatDict as StatDict +from .stats import Stats as Stats +from .stats import Timer as Timer +from .stats import TimerDict as TimerDict diff --git a/measure/client/__init__.py b/measure/client/__init__.py index 56dde47..f3665dd 100644 --- a/measure/client/__init__.py +++ b/measure/client/__init__.py @@ -1,13 +1,3 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import - - -from .boto3 import Boto3Client -from .pystatsd import PyStatsdClient -from .test import TestStatsdClient - -# remove clients that don't exist -for name, client in globals().items(): - if isinstance(client, type) and issubclass(client, NotImplementedError): - del name +from .boto3 import Boto3Client as Boto3Client +from .pystatsd import PyStatsdClient as PyStatsdClient +from .test import TestStatsdClient as TestStatsdClient diff --git a/measure/client/base.py b/measure/client/base.py index f4b6349..3c33ef4 100644 --- a/measure/client/base.py +++ b/measure/client/base.py @@ -1,18 +1,17 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import Any -class BaseClient(object): +class BaseClient: + def timing(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: + raise NotImplementedError("timing must be implemented in client") - def timing(self, *args, **kwargs): - raise NotImplementedError('timing must be implemented in client') + def update_stats(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: + raise NotImplementedError("update_stats must be implemented in client") - def update_stats(self, *args, **kwargs): - raise NotImplementedError('update_stats must be implemented in client') + def gauge(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: + raise NotImplementedError("gauge must be implemented in client") - def gauge(self, *args, **kwargs): - raise NotImplementedError('gauge must be implemented in client') - - def send(self, *args, **kwargs): - raise NotImplementedError('send must be implemented in client') + def send(self, prefix_name: str, value: Any, sample_rate: float | None = None) -> None: + raise NotImplementedError("send must be implemented in client") diff --git a/measure/client/boto3.py b/measure/client/boto3.py index ddcfe6f..924c507 100644 --- a/measure/client/boto3.py +++ b/measure/client/boto3.py @@ -1,76 +1,70 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import +from __future__ import annotations -# Standard Library from os import environ +from typing import Any -# External Libraries from measure.client.base import BaseClient - try: import boto3 except ImportError: - boto3_Client = NotImplementedError + boto3 = None class Boto3Client(BaseClient): def __init__( self, - aws_access_key_id=None, - aws_secret_access_key=None, - region_name=None - ): + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + region_name: str | None = None, + ) -> None: if not any([aws_access_key_id, aws_secret_access_key]): try: - aws_access_key_id = environ['AWS_ACCESS_KEY_ID'] - aws_secret_access_key = environ['AWS_SECRET_ACCESS_KEY'] - except KeyError: - raise Exception("You must provide AWS keys either in Env or App") + aws_access_key_id = environ["AWS_ACCESS_KEY_ID"] + aws_secret_access_key = environ["AWS_SECRET_ACCESS_KEY"] + except KeyError as err: + raise Exception("You must provide AWS keys either in Env or App") from err + + region_name = region_name or environ.get("AWS_DEFAULT_REGION") or "us-east-1" - region_name = region_name or environ.get('AWS_DEFAULT_REGION', None) or 'us-east-1' + if boto3 is None: + raise RuntimeError("boto3 is not installed; install measure[boto3]") session = boto3.Session( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, - region_name=region_name + region_name=region_name, ) - self.client = session.client('cloudwatch') + self.client = session.client("cloudwatch") - def split_prefix_name(self, prefix_name): - parts = prefix_name.split('.') + def split_prefix_name(self, prefix_name: str) -> tuple[str, str]: + parts = prefix_name.split(".") prefix = parts[:-1] name = parts[-1:][0] return ".".join(prefix), name - def submit_metric(self, namespace, metric_name, value, unit='None'): + def submit_metric(self, namespace: str, metric_name: str, value: float, unit: str = "None") -> None: self.client.put_metric_data( Namespace=namespace, - MetricData=[ - { - 'MetricName': metric_name, - 'Value': value, - 'Unit': unit - } - ] + MetricData=[{"MetricName": metric_name, "Value": value, "Unit": unit}], ) - def timing(self, prefix_name, value, sample_rate=None): + def timing(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: namespace, metric_name = self.split_prefix_name(prefix_name) - self.submit_metric(namespace, metric_name, value, unit='Seconds') + self.submit_metric(namespace, metric_name, value, unit="Seconds") - def update_stats(self, prefix_name, value, sample_rate=None): + def update_stats(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: namespace, metric_name = self.split_prefix_name(prefix_name) - self.submit_metric(namespace, metric_name, value, unit='None') + self.submit_metric(namespace, metric_name, value, unit="None") - def guage(self, prefix_name, value, sample_rate=None): + def guage(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: namespace, metric_name = self.split_prefix_name(prefix_name) - self.submit_metric(namespace, metric_name, value, unit='None') + self.submit_metric(namespace, metric_name, value, unit="None") - def send(self, prefix_name, value, sample_rate=None): + def send(self, prefix_name: str, value: Any, sample_rate: float | None = None) -> None: namespace, metric_name = self.split_prefix_name(prefix_name) - self.submit_metric(namespace, metric_name, value, unit='None') + self.submit_metric(namespace, metric_name, value, unit="None") - def mark(self, prefix_name, value, sample_rate=None): + def mark(self, prefix_name: str, value: float, sample_rate: float | None = None) -> None: namespace, metric_name = self.split_prefix_name(prefix_name) - self.submit_metric(namespace, metric_name, value, unit='None') + self.submit_metric(namespace, metric_name, value, unit="None") diff --git a/measure/client/pystatsd.py b/measure/client/pystatsd.py index 2fe980f..714fa65 100644 --- a/measure/client/pystatsd.py +++ b/measure/client/pystatsd.py @@ -1,29 +1,32 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import TYPE_CHECKING, Any -# External Libraries from measure.client.base import BaseClient -try: - from pystatsd import Client as pystatsd_Client -except ImportError: - pystatsd_Client = NotImplementedError +if TYPE_CHECKING: + pystatsd_Client: type[Any] +else: + try: + from pystatsd import Client as pystatsd_Client + except ImportError: + pystatsd_Client = None class PyStatsdClient(BaseClient): - - def __init__(self, host='localhost', port=8125, prefix=None): + def __init__(self, host: str = "localhost", port: int = 8125, prefix: str | None = None) -> None: + if pystatsd_Client is None: + raise RuntimeError("pystatsd is not installed; install it to use PyStatsdClient") self.client = pystatsd_Client(host, port, prefix) - def timing(self, *args, **kwargs): + def timing(self, *args: Any, **kwargs: Any) -> None: self.client.timing(*args, **kwargs) - def update_stats(self, *args, **kwargs): + def update_stats(self, *args: Any, **kwargs: Any) -> None: self.client.update_stats(*args, **kwargs) - def gauge(self, *args, **kwargs): + def gauge(self, *args: Any, **kwargs: Any) -> None: self.client.gauge(*args, **kwargs) - def send(self, *args, **kwargs): + def send(self, *args: Any, **kwargs: Any) -> None: self.client.send(*args, **kwargs) diff --git a/measure/client/test.py b/measure/client/test.py index f46bae8..a2dd9d4 100644 --- a/measure/client/test.py +++ b/measure/client/test.py @@ -1,8 +1,7 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import Any, Self -# External Libraries from measure.client.base import BaseClient @@ -11,23 +10,23 @@ class TestStatsdClient(BaseClient): Client for testing with that does not use sockets """ - def __init__(*args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: pass - def __call__(*args, **kwargs): + def __call__(self, *args: Any, **kwargs: Any) -> None: pass - def __getattr__(self, item): + def __getattr__(self, item: str) -> Self: return self - def timing(self, *args, **kwargs): + def timing(self, *args: Any, **kwargs: Any) -> None: pass - def update_stats(self, *args, **kwargs): + def update_stats(self, *args: Any, **kwargs: Any) -> None: pass - def gauge(self, *args, **kwargs): + def gauge(self, *args: Any, **kwargs: Any) -> None: pass - def send(self, *args, **kwargs): + def send(self, *args: Any, **kwargs: Any) -> None: pass diff --git a/measure/stats/__init__.py b/measure/stats/__init__.py index af3be9a..0d27c0c 100644 --- a/measure/stats/__init__.py +++ b/measure/stats/__init__.py @@ -1,31 +1,19 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import - -# Standard Library import logging - -from .counter import ( - Counter, - CounterDict, -) -from .gauge import ( - Gauge, - GaugeDict, -) -from .meter import ( - Meter, - MeterDict, -) -from .stat import ( - Stat, - StatDict, - Stats, -) -from .timer import ( - Timer, - TimerDict, -) +from typing import Any, ClassVar + +from .counter import Counter as Counter +from .counter import CounterDict as CounterDict +from .gauge import Gauge as Gauge +from .gauge import GaugeDict as GaugeDict +from .meter import Meter as Meter +from .meter import MeterDict as MeterDict +from .stat import Stat as Stat # noqa: TC001 # public re-export +from .stat import StatDict as StatDict +from .stat import Stats as Stats +from .timer import Timer as Timer +from .timer import TimerDict as TimerDict logger = logging.getLogger(__name__) @@ -33,14 +21,20 @@ class FakeStat(Timer, Meter, Counter, Gauge, TimerDict, CounterDict, GaugeDict): # Meter must precede Counter for the MRO to resolve - def apply(self, *args, **kwargs): - logger.error('stat <%s> does not exist', self.name) + # `FakeStatDict = FakeStat` (below), so when used as a dict the substats it + # autocreates must also be FakeStats — otherwise `_stat_class` would be + # inherited from TimerDict and `FakeStat[k]` would produce a Timer. + _stat_class: ClassVar[type[Stat]] + + def apply(self, *args: Any, **kwargs: Any) -> None: + logger.error("stat <%s> does not exist", self.name) - def decrement(self, *args, **kwargs): + def decrement(self, *args: Any, **kwargs: Any) -> None: """ override the meter decrement. """ self.apply(*args, **kwargs) +FakeStat._stat_class = FakeStat FakeStatDict = FakeStat diff --git a/measure/stats/counter.py b/measure/stats/counter.py index 55431d8..495d471 100644 --- a/measure/stats/counter.py +++ b/measure/stats/counter.py @@ -1,11 +1,8 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import ClassVar -from .stat import ( - Stat, - StatDict, -) +from .stat import Stat, StatDict class Counter(Stat): @@ -13,22 +10,22 @@ class Counter(Stat): A stat that represents a count over time. """ - _function = 'update_stats' - _alias = 'increment' + _function: ClassVar[str] = "update_stats" + _alias: ClassVar[str] = "increment" - def __add__(self, n): + def __add__(self, n: float) -> None: """ >>> stat += 42 """ self.increment(n) - def __sub__(self, n): + def __sub__(self, n: float) -> None: """ >>> stat -= 42 """ self.decrement(n) - def increment(self, n=1): + def increment(self, n: float = 1) -> None: """ >>> stat.increment(42) >>> stat.increment(-42) # will decriment the value @@ -38,7 +35,7 @@ def increment(self, n=1): else: self.apply(n) - def decrement(self, n=1): + def decrement(self, n: float = 1) -> None: """ >>> stat.decrement(42) >>> stat.decrement(-42) # has the same effect @@ -47,4 +44,4 @@ def decrement(self, n=1): class CounterDict(StatDict): - _stat_class = Counter + _stat_class: ClassVar[type[Stat]] = Counter diff --git a/measure/stats/gauge.py b/measure/stats/gauge.py index d55c1da..83657dd 100644 --- a/measure/stats/gauge.py +++ b/measure/stats/gauge.py @@ -1,23 +1,21 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import ClassVar -from .stat import ( - Stat, - StatDict, -) +from .stat import Stat, StatDict class Gauge(Stat): """ A discrete number, i.e. not a rate. """ - _function = 'gauge' - _alias = 'set' - def set(self, n): + _function: ClassVar[str] = "gauge" + _alias: ClassVar[str] = "set" + + def set(self, n: float) -> None: self.apply(n) class GaugeDict(StatDict): - _stat_class = Gauge + _stat_class: ClassVar[type[Stat]] = Gauge diff --git a/measure/stats/meter.py b/measure/stats/meter.py index 12c8a02..bdb8c17 100644 --- a/measure/stats/meter.py +++ b/measure/stats/meter.py @@ -1,24 +1,28 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar from .counter import Counter from .stat import StatDict +if TYPE_CHECKING: + from .stat import Stat + class Meter(Counter): """ A positive counter that directly represents a rate. """ - def mark(self, n=1): + def mark(self, n: float = 1) -> None: """ stat.mark() """ self.increment(n) - def decrement(self, n=None): + def decrement(self, n: float = 1) -> None: raise NotImplementedError("Meters do not have the ability to decrement.") class MeterDict(StatDict): - _stat_class = Meter + _stat_class: ClassVar[type[Stat]] = Meter diff --git a/measure/stats/set.py b/measure/stats/set.py index 2d95278..e23e553 100644 --- a/measure/stats/set.py +++ b/measure/stats/set.py @@ -1,17 +1,17 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from __future__ import absolute_import +from typing import Any, ClassVar from .stat import Stat, StatDict class Set(Stat): - _function = 'send' - _alias = 'set' + _function: ClassVar[str] = "send" + _alias: ClassVar[str] = "set" - def set(self, n): + def set(self, n: Any) -> None: self.apply(n) class SetDict(StatDict): - _stat_class = Set + _stat_class: ClassVar[type[Stat]] = Set diff --git a/measure/stats/stat.py b/measure/stats/stat.py index 1d40ff5..bab9795 100644 --- a/measure/stats/stat.py +++ b/measure/stats/stat.py @@ -1,34 +1,41 @@ +from __future__ import annotations -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -# Standard Library +import importlib from logging import getLogger +from typing import TYPE_CHECKING, Any, ClassVar -# External Libraries from measure.client.base import BaseClient +if TYPE_CHECKING: + from collections.abc import Callable logger = getLogger(__name__) -class Stat(object): +class Stat: """ Base stat object. """ # XXX: make an ABC - _function = '' - _alias = '' - - def __init__(self, name, doc, parent=None, sample_rate=1, *args, **kwargs): + _function: ClassVar[str] = "" + _alias: ClassVar[str] = "" + + def __init__( + self, + name: str, + doc: str, + parent: Stats | None = None, + sample_rate: float = 1, + *args: Any, + **kwargs: Any, + ) -> None: """ - :param str name: the name the stat will report under. - :param str doc: a human readable description of the stat. - :param str prefix: a prefix for the stat to report with e.g. hostname. - :param Stats parent: the stats container. - :param float sample_rate: the rate the stat is being sampled at. + :param name: the name the stat will report under. + :param doc: a human readable description of the stat. + :param parent: the stats container. + :param sample_rate: the rate the stat is being sampled at. """ self.__doc__ = doc self.name = name @@ -36,9 +43,9 @@ def __init__(self, name, doc, parent=None, sample_rate=1, *args, **kwargs): self.set_parent(parent) # return a nop function if there is no alias - self.__alias = getattr(self, self._alias, lambda *args: None) + self.__alias: Callable[..., Any] = getattr(self, self._alias, lambda *args: None) - def __call__(self, *args, **kwargs): + def __call__(self, *args: Any, **kwargs: Any) -> None: """ A shortcut to allow a default functionality on each stat. @@ -51,23 +58,26 @@ def __call__(self, *args, **kwargs): """ self.__alias(*args, **kwargs) - def set_parent(self, parent): + def set_parent(self, parent: Stats | None) -> None: self.parent = parent - def apply(self, value): + def apply(self, value: Any) -> None: """ Apply a statsd function to a value """ + if self.parent is None: + return self.parent.apply(self, value) -class StatDict(Stat, dict): +class StatDict(Stat, dict[Any, Stat]): """ Allows for a dictionary of a specific stat type. """ - _stat_class = Stat - def __init__(self, *args, **kwargs): + _stat_class: ClassVar[type[Stat]] = Stat + + def __init__(self, *args: Any, **kwargs: Any) -> None: """ Args: key_format (str): @@ -77,16 +87,15 @@ def __init__(self, *args, **kwargs): Function called to get the name for substats. Default value is `self.key_format.format`. The function is called with `key_func(statdict_name, key)` """ - super(StatDict, self).__init__(*args, **kwargs) - - self.key_format = kwargs.pop('key_format', '{name}.{key}') - self.key_func = kwargs.pop('key_func', self.key_format.format) + super().__init__(*args, **kwargs) - def __missing__(self, key): + self.key_format: str = kwargs.pop("key_format", "{name}.{key}") + self.key_func: Callable[..., str] = kwargs.pop("key_func", self.key_format.format) + def __missing__(self, key: Any) -> Stat: default = self._stat_class( self.key_func(name=self.name, key=key), - self.__doc__, + self.__doc__ or "", parent=self.parent, sample_rate=self.sample_rate, ) @@ -95,7 +104,7 @@ def __missing__(self, key): return default -class Stats(object): +class Stats: """ example usage: >>> stats = Stats( @@ -108,78 +117,78 @@ class Stats(object): >>> with stats.listPromoted_latency.time(): >>> # time some stuff - >>> print 'a' + >>> print('a') >>> @stats.listPromoted_latency.time >>> def foo(): - >>> print 'b' + >>> print('b') """ - def __init__(self, prefix, *stats, **kwargs): + def __init__(self, prefix: str, *stats: Stat, **kwargs: Any) -> None: + client = kwargs.pop("client", None) - client = kwargs.pop('client', None) - - if not isinstance(prefix, basestring): + if not isinstance(prefix, str): raise TypeError("first argument must be a prefix string") if not isinstance(client, BaseClient): - raise TypeError('the client should be an instance of BaseClient') + raise TypeError("the client should be an instance of BaseClient") - self.client = client - self.prefix = prefix or '' - self.stats = stats + self.client: BaseClient = client + self.prefix: str = prefix or "" + self.stats: tuple[Stat, ...] = stats for stat in stats: self.add_stat(stat) - def add_stat(self, stat): + def add_stat(self, stat: Stat) -> None: stat.set_parent(self) setattr(self, stat.name, stat) - def __getitem__(self, key): + def __getitem__(self, key: str) -> Stat: return getattr(self, key) - def __getattr__(self, key): + def __getattr__(self, key: str) -> Stat: from measure.stats import FakeStat - return FakeStat(key, 'the best laid plans often go astray', prefix=self.prefix) - def apply(self, stat, value): - func = getattr(self.client, stat._function, None) + return FakeStat(key, "the best laid plans often go astray") + + def apply(self, stat: Stat, value: Any) -> None: + func: Callable[..., Any] | None = getattr(self.client, stat._function, None) - name = self.prefix + '.' + stat.name + name = self.prefix + "." + stat.name - if func: + if func is not None: func(name, value, sample_rate=stat.sample_rate) else: - logger.error('stat %s does not have function %s', name, stat._function) + logger.error("stat %s does not have function %s", name, stat._function) class DjangoStats(Stats): - def __init__(self, prefix, *args, **kwargs): + def __init__(self, prefix: str, *args: Stat, **kwargs: Any) -> None: """ - Sublcass of Stats that will read django settings for host, port, and class info. + Subclass of Stats that will read django settings for host, port, and class info. STATS_CLIENT classpath to the client to use, useful for setting a different client in tests STATSD_HOST the host for the client to connect to STATSD_PORT the port for the client to connect to """ - _client = kwargs.get('client') + client = kwargs.get("client") - if not _client: + if not client: from django.conf import settings - host = kwargs.get('host') or settings.STATSD_HOST - port = kwargs.get('port') or settings.STATSD_PORT + host = kwargs.get("host") or settings.STATSD_HOST + port = kwargs.get("port") or settings.STATSD_PORT client_class = self.import_class(settings.STATS_CLIENT) - kwargs['client'] = client_class(host, port) + kwargs["client"] = client_class(host, port) - super(DjangoStats, self).__init__(prefix, *args, **kwargs) + super().__init__(prefix, *args, **kwargs) @staticmethod - def import_class(klass_path): + def import_class(klass_path: str) -> type[Any]: """ Helper to import a class by string @@ -187,9 +196,7 @@ def import_class(klass_path): :returns klass: the class object :raises ImportError: """ - import importlib - - module_path, klass_name = klass_path.rsplit('.', 1) + module_path, klass_name = klass_path.rsplit(".", 1) module = importlib.import_module(module_path) klass = getattr(module, klass_name) - return klass \ No newline at end of file + return klass diff --git a/measure/stats/timer.py b/measure/stats/timer.py index c50fa58..5d0fac8 100644 --- a/measure/stats/timer.py +++ b/measure/stats/timer.py @@ -1,53 +1,48 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import +from __future__ import annotations -# Standard Library from contextlib import contextmanager from functools import wraps from time import time +from typing import TYPE_CHECKING, Any, ClassVar -from .stat import ( - Stat, - StatDict, -) +from .stat import Stat, StatDict + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + from contextlib import AbstractContextManager class Timer(Stat): """ Time based stat that is usable via direct call, decorator, or context manager """ - _function = 'timing' - _alias = 'time' - def time(self, *args): - """ - Time a function as a decorator or a block as a context manager. - >>> stat = Timer('foo_latency', 'times latency of foo') + _function: ClassVar[str] = "timing" + _alias: ClassVar[str] = "time" - >>> start = time.time() - >>> # do work - >>> stat.time(time.time() - start) + def time(self, *args: Any) -> AbstractContextManager[None] | Callable[..., Any] | None: + """ + Time a function as a decorator, time a value directly, or open a + context manager when called with no arguments. - >>> @stat.time - >>> def foo(): - >>> # do work - >>> pass + >>> stat = Timer('foo_latency', 'times latency of foo') + >>> stat.time(0.42) # raw value - >>> with stat.time(): - >>> # do work - >>> pass + >>> @stat.time # decorator + >>> def foo(): ... + >>> with stat.time(): # context manager + >>> ... """ if len(args) == 1: arg = args[0] if callable(arg): return self.time_decorator(arg) - else: - self.apply(arg) - else: - return self.time_contextmanager() + self.apply(arg) + return None + return self.time_contextmanager() - def time_decorator(self, f): + def time_decorator(self, f: Callable[..., Any]) -> Callable[..., Any]: """ Allows for the following syntax: @@ -58,14 +53,14 @@ def time_decorator(self, f): """ @wraps(f) - def decorator(*args, **kwargs): - with self.time(): + def decorator(*args: Any, **kwargs: Any) -> Any: + with self.time_contextmanager(): return f(*args, **kwargs) return decorator @contextmanager - def time_contextmanager(self): + def time_contextmanager(self) -> Generator[None]: """ Allows for the following syntax: @@ -80,4 +75,4 @@ def time_contextmanager(self): class TimerDict(StatDict): - _stat_class = Timer + _stat_class: ClassVar[type[Stat]] = Timer diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..fa3c573 --- /dev/null +++ b/mise.toml @@ -0,0 +1,91 @@ +[tools] +python = "3.13" +uv = "latest" +ruff = "latest" +pre-commit = "latest" + +[settings] +experimental = true + +[env] +UV_PREVIEW = "1" + +[tasks.sync] +description = "Sync uv environment" +run = "uv sync --all-extras" + +[tasks.test] +description = "Run tests" +run = "uv run pytest" + +[tasks.lint] +description = "Run ruff lint and format checks" +run = """#!/usr/bin/env bash +set -euo pipefail +uv run ruff check . +uv run ruff format --check . +""" + +[tasks.fmt] +description = "Format code with ruff" +run = """#!/usr/bin/env bash +set -euo pipefail +uv run ruff check --fix . +uv run ruff format . +""" + +[tasks.typecheck] +description = "Run pyright type checker" +run = "uv run pyright measure" + +[tasks.build] +description = "Build sdist and wheel into dist/" +run = "uv build --out-dir dist" + +[tasks.current-version] +description = "Print the current local package version" +run = "uv version --short" + +[tasks.published-version] +description = "Print the latest version published to PyPI" +run = """#!/usr/bin/env bash +uvx get_pypi_latest_version measure 2>/dev/null || echo "none" +""" + +[tasks.bump-patch] +description = "Bump the patch version" +run = "uv version --bump patch" + +[tasks.bump-minor] +description = "Bump the minor version" +run = "uv version --bump minor" + +[tasks.bump-major] +description = "Bump the major version" +run = "uv version --bump major" + +[tasks.publish] +description = "Build and publish the current version to PyPI if it isn't already there" +run = """#!/usr/bin/env bash +set -euo pipefail +local_version=$(uv version --short) +published_version=$(uvx get_pypi_latest_version measure 2>/dev/null || echo "none") + +echo "local: $local_version" +echo "published: $published_version" + +if [ "$local_version" = "$published_version" ]; then + echo "✓ measure $local_version is already on PyPI, nothing to do" + exit 0 +fi + +rm -rf dist +uv build --out-dir dist +uv publish dist/* +""" + +[tasks.clean] +description = "Remove build artefacts and caches" +run = """#!/usr/bin/env bash +rm -rf dist .venv .pytest_cache .ruff_cache build *.egg-info +""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..298bf37 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,180 @@ +[project] +name = "measure" +version = "0.5.1" +description = "A metrics library that allows the user to swap metrics provider (statsd, cloudwatch, etc.) and provides an abstraction for creating metrics." +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +authors = [ + { name = "Adam Hitchcock", email = "adam@northisup.com" }, +] +keywords = ["metrics", "statsd", "cloudwatch", "measurement"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Monitoring", +] +dependencies = [] + +[project.optional-dependencies] +boto3 = ["boto3"] +all = ["boto3"] + +[project.urls] +Homepage = "https://github.com/NorthIsUp/measure" +Repository = "https://github.com/NorthIsUp/measure" +Issues = "https://github.com/NorthIsUp/measure/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["measure"] + +[tool.hatch.build.targets.sdist] +include = [ + "measure", + "tests", + "README.md", + "pyproject.toml", +] + +[dependency-groups] +dev = [ + "pytest", + "pytest-cov", + "mock", + "django", + "boto3", + "ruff", + "pyright", +] + +[tool.ruff] +line-length = 140 +preview = true +target-version = "py313" +extend-exclude = ["**/examples/**"] +# specific rules to enable +lint.extend-select = [ + "A", # shadowing + "ANN", # annotations + "ASYNC", # async rules + "B", # bugbear common python bugs + "BLE", # blind except + "COM", # comma rules + "E", # errors + "F", # pyflakes + "FA", # future annotations + "FURB", # refurb rules + "I", # imports + isort + "ISC", # implicit string concatenation + "N", # pep8-naming + "PLR", # refactor rules + "RUF", # ruff rules + "TC", # type checking + "UP", # upgrades + "YTT", # modern syntax +] +# specific rules to exclude +lint.ignore = [ + "A002", # Function argument {name} is shadowing a Python builtin + "A005", # Module {name} shadows a Python standard-library module + "ANN401", # any-type — explicit Any is sometimes correct + "BLE001", # Blind Exception + "COM812", # Trailing comma missing + "COM819", # prohibited-trailing-comma + "E501", # line too long + "E741", # Ambiguous variable name + "N806", # Variable in function should be lowercase + "N816", # mixedCase variable in global scope (used for fallback import aliases) + "N999", # Invalid module name + "PLR0911", # too many return statements for method + "PLR0912", # too many branches + "PLR0913", # too many arguments for method + "PLR0914", # too many local variables + "PLR0915", # too many statements + "PLR0916", # too many Boolean expressions + "PLR0917", # too many positional arguments for method + "PLR2004", # Magic value used in comparison + "PLR6301", # could be a function, class method, or static method + "RET504", # Unnecessary assignment to {name} before return statement + "RUF001", # Ambiguous unicode characters in strings + "RUF067", # __init__ module should only contain docstrings and re-exports + "TC006", # Type checking: Add quotes to type expression in typing.cast() + "TC007", # Type checking: Add quotes to type alias + "TRY002", # Create your own exception + "TRY401", # Redundant exception object in logging.exception + "UP009", # UTF-8 encoding declaration is unnecessary +] + +# specific fixes to be more aggressive with +lint.extend-safe-fixes = [ + "FA", + "FA102", # Add 'from __future__ import annotations' import + "TC", + "UP", +] + +lint.future-annotations = true + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["ANN", "PLR", "S101", "N802", "COM818"] + +[tool.pyproject-fmt] +column_width = 1 # always wrap lists +indent = 4 +keep_full_version = false # remove unnecessary trailing ``.0``'s from version specifiers + +[tool.pytest.ini_options] +addopts = [ + "-vv", + "-ra", +] +filterwarnings = [ + "error::pytest.PytestUnknownMarkWarning", + "ignore::DeprecationWarning", + "ignore::ResourceWarning", +] +log_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_level = "DEBUG" +testpaths = ["tests"] + +[tool.pyright] +venv = ".venv" +venvPath = "." +exclude = [ + "**/__pycache__", + "**/.venv", + "tests", +] +pythonVersion = "3.13" +reportConstantRedefinition = "error" +reportDeprecated = "warning" +reportDuplicateImport = "error" +reportImportCycles = "none" +reportIncompatibleMethodOverride = "error" +reportInconsistentConstructor = "warning" +reportInvalidTypeVarUse = "error" +reportMatchNotExhaustive = "warning" +reportMissingImports = "error" +reportMissingModuleSource = "warning" +reportMissingTypeStubs = false +reportPrivateImportUsage = "error" +reportSelfClsParameterName = "error" +reportUnnecessaryTypeIgnoreComment = "warning" +reportUntypedBaseClass = "error" +reportUntypedClassDecorator = "error" +reportUntypedFunctionDecorator = "warning" +reportUnusedImport = "error" +strictDictionaryInference = true +strictListInference = true +strictSetInference = true +typeCheckingMode = "basic" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8ec51e9..0000000 --- a/setup.cfg +++ /dev/null @@ -1,30 +0,0 @@ -[flake8] -ignore = W292,W503,E501 -# E121,E123,E126,E226,E24,E704,W503,W504 -select = - # defaults - E,F,W,C90 - # added - E504 - -max-line-length = 130 - -[pep8] -ignore = W292,W503,E501 - -[isort] -default_section = THIRDPARTY -import_heading_firstparty = Project Library -import_heading_stdlib = Standard Library -import_heading_thirdparty = External Libraries -indent = ' ' -known_standard_library=httplib -known_future_library=future,pies -known_first_party = tests -known_third_party = django -add_imports = __future__.absolute_import -multi_line_output = 3 -force_grid_wrap = true -include_trailing_comma = true -line_length = 9999 -not_skip = __init__.py diff --git a/setup.py b/setup.py deleted file mode 100755 index d3a7a72..0000000 --- a/setup.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python -import sys -from itertools import chain - -from setuptools import find_packages, setup -from setuptools.command.test import test as TestCommand - - -PACKAGE_NAME = 'measure' -VERSION = '0.5.1' - -requires = { - 'global': [ - 'pystatsd', - 'boto3', - ], - 'tests': [ - 'mock', - 'pytest', - 'django', - ], - 'develop': [ - 'flake8', - 'autopep8', - ] -} - - -class PyTest(TestCommand): - user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] - - def initialize_options(self): - TestCommand.initialize_options(self) - self.pytest_args = [] - - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self): - # import here, cause outside the eggs aren't loaded - import pytest - errno = pytest.main(self.pytest_args) - sys.exit(errno) - - -requires['all'] = list(set(chain.from_iterable(requires.values()))), - -setup( - author='adam hitchcock', - author_email='adam@northisup.com', - cmdclass={'test': PyTest}, - url='http://github.com/disqus/measure', - extras_require=requires, - name=PACKAGE_NAME, - packages=find_packages(), - requires=requires['global'], - test_suite='nose.collector', - tests_require=requires['all'], - version=VERSION, - zip_safe=False, -) diff --git a/tests/test_boto3_client.py b/tests/test_boto3_client.py index 93e5ea6..cc6122f 100644 --- a/tests/test_boto3_client.py +++ b/tests/test_boto3_client.py @@ -6,16 +6,16 @@ @pytest.fixture def boto3client_mock(): return Boto3Client( - aws_access_key_id='FOOBARBAZ', - aws_secret_access_key='BAZBARFOO', + aws_access_key_id="FOOBARBAZ", + aws_secret_access_key="BAZBARFOO", ) @pytest.fixture( params=[ - ('foo.bar.baz', ('foo.bar', 'baz')), - ('disqus.awesome.important_stat', ('disqus.awesome', 'important_stat')), - ('cat.dog', ('cat', 'dog')) + ("foo.bar.baz", ("foo.bar", "baz")), + ("disqus.awesome.important_stat", ("disqus.awesome", "important_stat")), + ("cat.dog", ("cat", "dog")), ] ) def namespaces(request): diff --git a/tests/test_django.py b/tests/test_django.py index b1d9cd1..8dd8251 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,25 +1,24 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import # Standard Library import os +from unittest.mock import Mock # External Libraries from measure import Meter from measure.client.base import BaseClient from measure.stats.stat import DjangoStats -from mock import Mock # Django settings -SECRET_KEY = 'hi' +SECRET_KEY = "hi" CACHES = {} -ALLOWED_HOSTS = ['*'] -SECURE_PROXY_SSL_HEADER = ('SSL_ON', '1') +ALLOWED_HOSTS = ["*"] +SECURE_PROXY_SSL_HEADER = ("SSL_ON", "1") -STATSD_HOST = 'localhost', -STATSD_PORT = '1235', -STATS_CLIENT = __name__ + '.StatsClient' +STATSD_HOST = ("localhost",) +STATSD_PORT = ("1235",) +STATS_CLIENT = __name__ + ".StatsClient" stats_client = Mock(spec=BaseClient) @@ -30,13 +29,13 @@ def StatsClient(*args, **kwargs): def test_django_client(): - os.environ['DJANGO_SETTINGS_MODULE'] = __name__ + os.environ["DJANGO_SETTINGS_MODULE"] = __name__ stats = DjangoStats( - 'hi', - Meter('ho', doc='ho'), + "hi", + Meter("ho", doc="ho"), ) stats.ho.mark(5) - stats.client.update_stats.assert_called_with('hi.ho', 5, sample_rate=1) + stats.client.update_stats.assert_called_with("hi.ho", 5, sample_rate=1) diff --git a/tests/test_misc.py b/tests/test_misc.py index 9aee3e9..76e8713 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import # External Libraries from measure import Stats @@ -12,9 +11,8 @@ def test_subclassing(): # error if the superclas __init__ has client=None as a kwarg. class SuperStats(Stats): - def __init__(self, *args, **kwargs): - kwargs['client'] = BaseClient() - super(SuperStats, self).__init__(*args, **kwargs) + kwargs["client"] = BaseClient() + super().__init__(*args, **kwargs) - SuperStats('prefix') + SuperStats("prefix") diff --git a/tests/test_stats.py b/tests/test_stats.py index 20e47fa..e2aa6d7 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import # Standard Library from contextlib import contextmanager - # Project Library from functools import partial +from unittest.mock import MagicMock + +import pytest from measure import ( Counter, @@ -24,44 +25,40 @@ TimerDict, ) from measure.client.base import BaseClient -from mock import MagicMock -import pytest - BASE_STATS = frozenset((Stat, StatDict)) -class ClientTest(): - +class ClientTest: @pytest.fixture def client(self): return MagicMock(spec=BaseClient) @pytest.fixture def parent(self, client): - return Stats('tests_parent_prefix', client=client) + return Stats("tests_parent_prefix", client=client) class StatMixin(ClientTest): stat_class = Stat sample_rate = 1 - stat_dict_key = '' - stat_name = 'test_stat' + stat_dict_key = "" + stat_name = "test_stat" @pytest.fixture def stat(self, parent): - stat = self.stat_class(self.stat_name, 'testing this stat', sample_rate=self.sample_rate) + stat = self.stat_class(self.stat_name, "testing this stat", sample_rate=self.sample_rate) stat.set_parent(parent) return stat @pytest.fixture def expected_stat_name_with_prefix(self, parent, expected_stat_name): - return parent.prefix + '.' + expected_stat_name + return parent.prefix + "." + expected_stat_name @pytest.fixture def expected_stat_name(self): - return '.'.join(k for k in (self.stat_name, str(self.stat_dict_key)) if k) + return ".".join(k for k in (self.stat_name, str(self.stat_dict_key)) if k) def assertMockCalledWith(self, mock, *values, **kwvalues): mock.assert_called_with(*values, **kwvalues) @@ -73,12 +70,7 @@ def test___call__(self, client, stat, expected_stat_name_with_prefix): """ if self.stat_class not in BASE_STATS: stat(1) - self.assertMockCalledWith( - getattr(client, stat._function), - expected_stat_name_with_prefix, - 1, - sample_rate=self.sample_rate - ) + self.assertMockCalledWith(getattr(client, stat._function), expected_stat_name_with_prefix, 1, sample_rate=self.sample_rate) def test_apply(self, client, stat, expected_stat_name_with_prefix): """ @@ -86,36 +78,31 @@ def test_apply(self, client, stat, expected_stat_name_with_prefix): """ if self.stat_class not in BASE_STATS: stat.apply(42) - self.assertMockCalledWith( - getattr(client, stat._function), - expected_stat_name_with_prefix, - 42, - sample_rate=self.sample_rate - ) + self.assertMockCalledWith(getattr(client, stat._function), expected_stat_name_with_prefix, 42, sample_rate=self.sample_rate) class StatMixinDict(StatMixin): stat_class = StatDict stat_dict_key = 200 - @pytest.fixture(params=['default', 'key_func', 'key_format']) + @pytest.fixture(params=["default", "key_func", "key_format"]) def statdict(self, request, parent): key_type = request.param - statdict = partial(self.stat_class, self.stat_name, 'testing this stat', sample_rate=self.sample_rate, parent=parent) + statdict = partial(self.stat_class, self.stat_name, "testing this stat", sample_rate=self.sample_rate, parent=parent) - if key_type == 'default': + if key_type == "default": # normal case statdict = statdict() - elif key_type == 'key_func': + elif key_type == "key_func": # a custom key function is specified def key_func(name, key): - return 'key_func.{name}.{key}'.format(name=name, key=key) + return f"key_func.{name}.{key}" statdict = statdict(key_func=key_func) - elif key_type == 'key_format': + elif key_type == "key_format": # a custom key format is specified - statdict = statdict(key_format='key_foramt.{name}.{key}') + statdict = statdict(key_format="key_foramt.{name}.{key}") return statdict @@ -130,17 +117,18 @@ def stat(self, statdict): def test_key_func_and_key_format(self, statdict): # tests both key_func and key_format attributes, key_format is tested via key_func based on statdict parameter - stat = statdict['hi'] - assert stat.name == statdict.key_func(name=statdict.name, key='hi') + stat = statdict["hi"] + assert stat.name == statdict.key_func(name=statdict.name, key="hi") def test_object_key_lookup(self, statdict): from collections import namedtuple - tup = namedtuple('hi', 'x,y') - statdict[(1, 2)].apply(1) + tup = namedtuple("hi", "x,y") + + statdict[1, 2].apply(1) statdict[2].apply(1) statdict[object()].apply(1) - statdict[tup('1', 3)].apply(1) + statdict[tup("1", 3)].apply(1) class TestCounterStat(StatMixin): @@ -160,8 +148,7 @@ def test_decrement(self, client, stat, expected_stat_name_with_prefix): stat.decrement() else: stat.decrement() - self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -1, - sample_rate=self.sample_rate) + self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -1, sample_rate=self.sample_rate) def test_decrement_n(self, client, stat, expected_stat_name_with_prefix): if self.stat_class in (Meter, MeterDict): @@ -169,8 +156,7 @@ def test_decrement_n(self, client, stat, expected_stat_name_with_prefix): stat.decrement(5) else: stat.decrement(5) - self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -5, - sample_rate=self.sample_rate) + self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -5, sample_rate=self.sample_rate) def test___add__(self, client, stat, expected_stat_name_with_prefix): stat += 42 @@ -190,8 +176,7 @@ def test___add__neg(self, client, stat, expected_stat_name_with_prefix): stat += -42 else: stat += -42 - self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -42, - sample_rate=self.sample_rate) + self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -42, sample_rate=self.sample_rate) def test___sub__(self, client, stat, expected_stat_name_with_prefix): if self.stat_class in (Meter, MeterDict): @@ -199,8 +184,7 @@ def test___sub__(self, client, stat, expected_stat_name_with_prefix): stat -= 42 else: stat -= 42 - self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -42, - sample_rate=self.sample_rate) + self.assertMockCalledWith(client.update_stats, expected_stat_name_with_prefix, -42, sample_rate=self.sample_rate) class TestCounterStatDict(StatMixinDict, TestCounterStat): @@ -250,14 +234,14 @@ def check_time(self, client, almost=0): yield # checks only against the FIRST timing call in a given test run for call in client.timing.mock_calls: - if call[0] != '__nonzero__': + if call[0] != "__nonzero__": time = call[1][1] break else: assert self.stat_class == FakeStat time = 0 - assert abs(time - almost) < 0.001, 'should be basically 0' + assert abs(time - almost) < 0.001, "should be basically 0" def test_decorator(self, client, stat): with self.check_time(client): @@ -265,9 +249,8 @@ def test_decorator(self, client, stat): func() def test_contextmanager(self, client, stat): - with self.check_time(client): - with stat.time(): - pass + with self.check_time(client), stat.time(): + pass def test_absolute(self, client, stat): with self.check_time(client): @@ -306,7 +289,12 @@ class TestGaugeStatSampleRateDict(StatMixinDict, TestGaugeStatSampleRate): stat_class = GaugeDict -class TestFakeStat(TestMeterStat, TestCounterStat, TestTimerStat, TestGaugeStat, ): +class TestFakeStat( + TestMeterStat, + TestCounterStat, + TestTimerStat, + TestGaugeStat, +): stat_class = FakeStat def assertMockCalledWith(self, mock, *values, **kwvalues): @@ -328,15 +316,11 @@ class TestFakeStatSampleRateDict(TestFakeStatSampleRate, StatMixinDict): class StatMixins(ClientTest): @pytest.fixture def stats(self, client): - return Stats( - 'prefix', - Meter('m', 'mdoc'), - client=client - ) + return Stats("prefix", Meter("m", "mdoc"), client=client) def test_raises_type_error(self, client): with pytest.raises(TypeError): - Stats(FakeStat('m', 'mdoc'), client=client) + Stats(FakeStat("m", "mdoc"), client=client) def test_missing_stat___getattr__(self, stats): # should not raise an exception @@ -344,4 +328,4 @@ def test_missing_stat___getattr__(self, stats): def test_missing_stat___getitem__(self, stats): # should not raise an exception - stats['q'].mark() + stats["q"].mark() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 2df298c..0000000 --- a/tox.ini +++ /dev/null @@ -1,24 +0,0 @@ -[flake8] -ignore = E265,E501 -max-line-length = 130 -max-complexity = 10 - -[tox] -envlist = - py27, - py33, - py34, - py35, - py36, - -[testenv] -deps = - py{27,33,34,35}: coverage == 4.0.3 - flake8 == 2.5.4 -usedevelop = True -commands = - - flake8 measure - coverage run setup.py test - -[testenv:py3*] -ignore_errors = True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2a53c69 --- /dev/null +++ b/uv.lock @@ -0,0 +1,374 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.96" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.96" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "django" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "measure" +version = "0.5.1" +source = { editable = "." } + +[package.optional-dependencies] +all = [ + { name = "boto3" }, +] +boto3 = [ + { name = "boto3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "boto3" }, + { name = "django" }, + { name = "mock" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", marker = "extra == 'all'" }, + { name = "boto3", marker = "extra == 'boto3'" }, +] +provides-extras = ["boto3", "all"] + +[package.metadata.requires-dev] +dev = [ + { name = "boto3" }, + { name = "django" }, + { name = "mock" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]