diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 1fcbb89..0000000 --- a/.coveragerc +++ /dev/null @@ -1,13 +0,0 @@ -[run] -branch = True -source = - iotserver -omit = - *migrations* - *tests* - *asgi* - *wsgi* - *apps.py - -[report] -show_missing = True diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d5f477f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.env +.python-version +.gitignore diff --git a/.env.example b/.env.example index 581ca06..a0fb57d 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,58 @@ +# Project Environment Variables IOTSERVER_SECRET_KEY='insecure-secret' IOTSERVER_CORS_ORIGIN_WHITELIST='http://localhost:3000' -IOTSERVER_OPENWEATHER_URL='https://api.openweathermap.org/data/2.5/onecall' +IOTSERVER_CORS_ALLOW_ALL_ORIGINS='0' + +IOTSERVER_POSTGRES_USER='postgres' +IOTSERVER_POSTGRES_PASSWORD='insecure-password' +IOTSERVER_POSTGRES_DBNAME='iotserver' + +IOTSERVER_SONOFF_REGION='eu' +IOTSERVER_SONOFF_APP_ID='sonoff-app-id' +IOTSERVER_SONOFF_APP_SECRET='sonoff-app-secret' +IOTSERVER_SONOFF_REDIRECT_URL='http://localhost:8000/integrations/sonoff/callback/' +IOTSERVER_SONOFF_AUTHORIZE_URL='https://c2ccdn.coolkit.cc/oauth/index.html' +IOTSERVER_SONOFF_TOKEN_URL='https://eu-apia.coolkit.cc/v2/user/oauth/token' +IOTSERVER_SONOFF_REFRESH_URL='https://eu-apia.coolkit.cc/v2/user/refresh' +IOTSERVER_SONOFF_DEVICE_URL='https://eu-apia.coolkit.cc/v2/device/thing/status' +IOTSERVER_SONOFF_CACHE_TIMEOUT='300' + +IOTSERVER_SOLARMAN_BASE_URL='https://globalapi.solarmanpv.com' +IOTSERVER_SOLARMAN_APP_ID='solarman-app-id' +IOTSERVER_SOLARMAN_APP_SECRET='solarman-app-secret' +IOTSERVER_SOLARMAN_EMAIL='solarman-email@example.com' +IOTSERVER_SOLARMAN_PASSWORD='solarman-password' +IOTSERVER_SOLARMAN_CACHE_TIMEOUT='300' + +IOTSERVER_OPENWEATHER_URL='https://api.openweathermap.org/data/3.0/onecall' IOTSERVER_OPENWEATHER_APIKEY='openweather-key' + +IOTSERVER_OLARM_BASE_URL='https://api.olarm.com' +IOTSERVER_OLARM_API_KEY='olarm-api-key' +IOTSERVER_OLARM_DEVICE_ID='olarm-device-id' +IOTSERVER_OLARM_CACHE_TIMEOUT='60' + +IOTSERVER_RAINPOINT_EMAIL='rainpoint-api@example.com' +IOTSERVER_RAINPOINT_PASSWORD='rainpoint-password' +IOTSERVER_RAINPOINT_APP_CODE='2' +IOTSERVER_RAINPOINT_AREA_CODE='27' +IOTSERVER_RAINPOINT_HUB_ID='361481' +IOTSERVER_RAINPOINT_RAIN_GAUGE_ADDRESS='1' +IOTSERVER_RAINPOINT_POOL_THERMOMETER_ADDRESS='2' +IOTSERVER_RAINPOINT_TEMPERATURE_SENSOR_ADDRESS='3' +IOTSERVER_RAINPOINT_CACHE_TIMEOUT='60' + IOTSERVER_GOOGLEMAPS_APIKEY='googlemaps-key' + IOTSERVER_WEBREPL_PORT='8266' IOTSERVER_WEBREPL_PASSWORD='insecure-password' +IOTSERVER_AUTO_SYNC_DEVICE=1 + +IOTSERVER_DEVICE_HEALTH_URL='http://localhost:8000/health/{identifier}/' + +# Build Environment Variables +DOCKER_POSTGIS_IMAGE='kartoza/postgis:15' + +# GDAL local path +IOTSERVER_GDAL_LIBRARY_PATH='/path/to/your/libgdal.so' +IOTSERVER_GEOS_LIBRARY_PATH='/path/to/your/libgeos_c.so' diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 25847e0..0000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] - -extend-ignore = E203,E501,W503 -select = B,C,E,F,W,T4,B9 -max-line-length = 80 -max-complexity = 18 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8c183c6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,79 @@ +name: Python test runner + +on: + pull_request: + branches: + - '**' + push: + branches: + - master + - develop + +jobs: + run_tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgis/postgis:15-3.5 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_iotserver + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends gdal-bin libgdal-dev + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + + - name: Run tests with coverage + env: + IOTSERVER_POSTGRES_DBNAME: test_iotserver + IOTSERVER_POSTGRES_USER: postgres + IOTSERVER_POSTGRES_PASSWORD: postgres + run: poetry run pytest --cov --cov-branch --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 1f09735..27493d2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ celerybeat-schedule .env .venv .keys/ +.backup/ + +# docker +/docker/ diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index a3d60a1..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[settings] - -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -ensure_newline_before_comments = True -line_length = 88 -known_first_party = iotserver diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b510ad6..74d24a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,23 +1,14 @@ repos: -- repo: https://github.com/PyCQA/isort - rev: 5.9.3 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.9 hooks: - - id: isort - -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 - hooks: - - id: flake8 - additional_dependencies: [flake8-bugbear] + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.4.0 hooks: - id: check-ast - id: check-added-large-files diff --git a/Brewfile b/Brewfile index bdee12f..03eba8e 100644 --- a/Brewfile +++ b/Brewfile @@ -1,6 +1,7 @@ brew "openssl" brew "mosquitto" brew "postgis" +brew "geos" brew "gdal" brew "postgresql" brew "memcached" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fca7e90 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-bookworm + +WORKDIR /usr/src/app + +RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client gdal-bin libgdal-dev +RUN curl -sSL https://install.python-poetry.org | python3 - + +ENV PATH="${PATH}:/root/.local/bin" + +COPY . /usr/src/app + +RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi + +EXPOSE 8000 diff --git a/README.md b/README.md index 6a89feb..ee782ad 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# IoT Server (v0.9.0) +# IoT Server (v1.0.0) Simple IoT Server, Configuration Tool & Dashboard +[![codecov](https://codecov.io/gh/hacklabza/iotserver/graph/badge.svg?token=BSVBAN7ZQ5)](https://codecov.io/gh/hacklabza/iotserver) + ## Requirements -- Python 3.10+ +- Python 3.13+ - Git - Mosquitto - PostgreSQL @@ -14,23 +16,26 @@ Simple IoT Server, Configuration Tool & Dashboard ## Installation ```bash -$ git clone git@github.com:hacklabza/iotserver.git -$ cd iotserver/ -pyenv local 3.10.* +git clone https://github.com/hacklabza/iotserver.git +cd iotserver/ +brew bundle install # On MacOS, see Brewfile - if you're not using docker compose +pyenv local 3.13.* +curl -sSL https://install.python-poetry.org | python3 - poetry install +poetry run pre-commit install ``` ### Run the server to test your installation ```bash poetry run ./manage.py migrate -$ poetry run ./manage.py runserver +poetry run ./manage.py runserver ``` ## Testing ```bash -poetry run pytest . +poetry run pytest -s . --cov ``` ## Getting Started @@ -40,3 +45,128 @@ To create a super user which you can use to populate your devices and users, exe ```bash ./manage.py createsuperuser ``` + +## Deployment (Docker) + +The recommended way to install the API and it's service dependancies is with docker, however the docker compose config can also be used in development. I've found that this is best done if you're using a PC or Server. + +**Doesn't play well with RPi!** + +### System Dependancies + +```bash +sudo apt update && sudo apt upgrade +sudo apt install libffi-dev libssl-dev python3-dev python3 python3-pip git +``` + +### Docker Compose Setup + +#### Setting up docker on raspberry pi + +```bash +curl -fsSL test.docker.com -o get-docker.sh && sh get-docker.sh +sudo usermod -aG docker ${USER} +sudo systemctl enable docker +sudo reboot now # or logout of the pi user account +``` + +#### Setting up docker compose, the project and environment + +```bash +pip3 install docker-compose +git clone https://github.com/hacklabza/iotserver.git +cd iotserver/ +curl -#fLo- 'https://raw.githubusercontent.com/hyperupcall/autoenv/master/scripts/install.sh' | sh # install autoenv - optional +cp .env.example .env # update as required +mkdir -p docker/mqtt +cp mosquitto.conf.example docker/mqtt/mosquitto.conf +docker compose -f docker-compose.yml up +``` + +## Deployment (Manual) + +Recommend for 64bit/32bit rasbian installation, in my case a raspberrypi zero. + +### System Dependancies + +```bash +sudo apt update && sudo apt upgrade +sudo apt install -y --no-install-recommends git vim python3-pip python3-dev gdal-bin libgdal-dev libffi-dev openssl +``` + +### PostGIS Setup + +```bash +sudo apt install -y postgresql +sudo chown postgres:postgres /var/lib/postgresql/13/main +sudo apt install -y postgresql-13-postgis-3-scripts +sudo su - postgres +createdb iotserver +psql -d iotserver +$postgres-# CREATE EXTENSION postgis; +$postgres-# ALTER USER postgres WITH PASSWORD ''; +``` + +### Memcached Setup + +```bash +sudo apt install -y --no-install-recommends memcached +``` + +### MQTT Setup + +```bash +sudo apt install -y --no-install-recommends mosquitto +``` + +Add the following lines to `/etc/mosquitto/mosquitto.conf` and restart the service + +```bash +persistence true +persistence_location /mosquitto/data/ +log_dest file /mosquitto/log/mosquitto.log +listener 1883 +max_keepalive 0 # Remove if this causes startup issues +allow_anonymous true +``` + +### API Setup + +You may need to install the rust toolchain for RPi 32 bit OS versions due to `cryptography`. + +```bash +git clone https://github.com/hacklabza/iotserver.git +cd iotserver/ + +pip install --upgrade pip +curl -sSL https://install.python-poetry.org | python3 - +poetry install + +curl -#fLo- 'https://raw.githubusercontent.com/hyperupcall/autoenv/master/scripts/install.sh' | sh # install autoenv - optional but recommended +cp .env.example .env # update as required +cp manage.py ~/.local/bin/ +chmod +x ~/.local/bin/manage.py + +poetry run manage.py migrate +poetry run manage.py collectstatic +poetry run manage.py createsuperuser + +sudo cp systemd/iot.api.service /etc/systemd/system/iot.api.service +sudo cp systemd/iot.mqttsubscriber.service /etc/systemd/system/iot.mqttsubscriber.service +sudo cp systemd/iot.rulesengine.service /etc/systemd/system/iot.rulesengine.service + +sudo systemctl start iot.api.service +sudo systemctl start iot.mqttsubscriber.service +sudo systemctl start iot.rulesengine.service + +sudo systemctl enable iot.api.service +sudo systemctl enable iot.mqttsubscriber.service +sudo systemctl enable iot.rulesengine.service +``` + +### Make a SD Card Backup + +Read more here: https://howchoo.com/pi/create-a-backup-image-of-your-raspberry-pi-sd-card-in-mac-osx. this pretty useful as cards can become corrupt if power is cut or the device is not powered down correctly. + +## API Documentation +The API documentation is available at `/api/schema/ui/` (Swagger) and `/api/schema/docs/` (Redoc) when the server is running. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..91e4cc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +services: + + postgis: + image: ${DOCKER_POSTGIS_IMAGE} + restart: unless-stopped + volumes: + - iotserver_pg_data:/var/lib/postgresql/data + container_name: iotserver-postgis + ports: + - "5432:5432" + env_file: + - .env + environment: + - POSTGRES_USER=${IOTSERVER_POSTGRES_USER} + - POSTGRES_PASS=${IOTSERVER_POSTGRES_PASSWORD} + - POSTGRES_DB=${IOTSERVER_POSTGRES_DBNAME} + - POSTGRES_MULTIPLE_EXTENSIONS=postgis + - ALLOW_IP_RANGE=0.0.0.0/0 + networks: + services-network: + aliases: + - postgis + healthcheck: + test: "pg_isready -h 127.0.0.1 -U ${IOTSERVER_POSTGRES_USER} -d ${IOTSERVER_POSTGRES_DBNAME}" + interval: 5s + timeout: 5s + retries: 5 + + memcached: + image: memcached + restart: unless-stopped + container_name: iotserver-memcached + ports: + - "11211:11211" + networks: + services-network: + aliases: + - memcached + + mqtt: + image: eclipse-mosquitto + restart: unless-stopped + volumes: + - ./mosquitto.conf.example:/mosquitto/config/mosquitto.conf:ro + container_name: iotserver-mqtt + ports: + - "1883:1883" + networks: + services-network: + aliases: + - mqtt + + api: + build: . + restart: unless-stopped + volumes: + - ./:/usr/src/app + container_name: iotserver-api + env_file: + - .env + environment: + - IOTSERVER_DJANGO_DEBUG=1 + - IOTSERVER_GDAL_LIBRARY_PATH= + - IOTSERVER_GEOS_LIBRARY_PATH= + - IOTSERVER_POSTGRES_HOST=postgis + - IOTSERVER_POSTGRES_PASSWORD=${IOTSERVER_POSTGRES_PASSWORD} + - IOTSERVER_MQTT_HOST=mqtt + - IOTSERVER_MEMCACHED_LOCATION=memcached:11211 + depends_on: + postgis: + condition: service_healthy + links: + - postgis + - memcached + - mqtt + ports: + - "8000:8000" + networks: + services-network: + aliases: + - api + command: ["./entrypoint.sh"] + +volumes: + iotserver_pg_data: + external: true + +networks: + services-network: + name: services-network + driver: bridge diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..b94a9db --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +./manage.py migrate --noinput +./manage.py collectstatic --noinput +./manage.py mqtt & +./manage.py rules & +gunicorn iotserver.wsgi:application -w 2 -b :8000 --reload diff --git a/iotserver/apps/device/admin.py b/iotserver/apps/device/admin.py index e9f39be..a9f02d7 100644 --- a/iotserver/apps/device/admin.py +++ b/iotserver/apps/device/admin.py @@ -1,19 +1,52 @@ -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.gis.db import models as gis_models -from mapwidgets.widgets import GooglePointFieldWidget +from django.db.models import JSONField +from django.utils.safestring import mark_safe +from mapwidgets.widgets import GoogleMapPointFieldWidget -from iotserver.apps.device import models +from iotserver.apps.device import exceptions, models, widgets + + +def toggle_devices_on(modeladmin, request, queryset): + for obj in queryset: + obj.mqtt_toggle('on') + + message = 'All selected device have been toggled on.' + messages.add_message(request, level=messages.SUCCESS, message=message) + + +def toggle_devices_off(modeladmin, request, queryset): + for obj in queryset: + obj.mqtt_toggle('off') + + message = 'All selected device have been toggled off.' + messages.add_message(request, level=messages.SUCCESS, message=message) @admin.register(models.DeviceType) class DeviceTypeModelAdmin(admin.ModelAdmin): list_display = ('name',) + prepopulated_fields = {'identifier': ('name',)} @admin.register(models.Device) class DeviceModelAdmin(admin.ModelAdmin): + actions = [toggle_devices_on, toggle_devices_off] fieldsets = ( - (None, {'fields': ('active', 'name', 'description', 'type', 'location')}), + ( + None, + { + 'fields': ( + 'id', + 'active', + 'managed_firmware', + 'name', + 'description', + 'type', + 'location', + ) + }, + ), ( 'Advanced options', { @@ -29,32 +62,121 @@ class DeviceModelAdmin(admin.ModelAdmin): }, ), ) + readonly_fields = ('id',) list_display = ( 'name', 'description', 'active', + 'managed_firmware', 'created_at', 'type', 'location', 'ip_address', ) - list_filter = ('active', 'type__name', 'location__name') + list_filter = ('active', 'managed_firmware', 'type__name', 'location__name') + formfield_overrides = { + JSONField: { + 'widget': widgets.PrettyJSONWidget(attrs={'rows': 20, 'cols': 120}) + } + } + + def save_model(self, request, obj, form, change): + try: + super().save_model(request, obj, form, change) + except exceptions.DeviceUnreachableError as error: + messages.add_message( + request, level=messages.WARNING, message=str(error) + ) + + +@admin.register(models.DevicePinType) +class DevicePinTypeModelAdmin(admin.ModelAdmin): + list_display = ('name',) + prepopulated_fields = {'identifier': ('name',)} @admin.register(models.DevicePin) class DevicePinModelAdmin(admin.ModelAdmin): - list_display = ('name', 'identifier', 'pin_number', 'device') - list_filter = ('device__name',) + list_display = ( + 'name', + 'identifier', + 'pin_number', + 'analog', + 'read', + 'i2c', + 'active', + ) + list_filter = ('devices__name', 'active') prepopulated_fields = {'identifier': ('name',)} + formfield_overrides = { + JSONField: { + 'widget': widgets.PrettyJSONWidget(attrs={'rows': 20, 'cols': 120}) + } + } @admin.register(models.DeviceStatus) class DeviceStatusModelAdmin(admin.ModelAdmin): list_display = ('device', 'created_at') list_filter = ('device__name', 'created_at') + formfield_overrides = { + JSONField: { + 'widget': widgets.PrettyJSONWidget(attrs={'rows': 20, 'cols': 120}) + } + } + + +@admin.register(models.DeviceHealth) +class DeviceHealthModelAdmin(admin.ModelAdmin): + list_display = ( + 'device', + 'updated_at', + 'status', + 'battery_level', + 'wifi_signal_strength', + ) + list_filter = ('device__name', 'updated_at') + + def status(self, obj): + if obj.status: + return mark_safe( + 'True' + ) + return mark_safe( + 'False' + ) + + status.short_description = "Status" @admin.register(models.Location) class LocationModelAdmin(admin.ModelAdmin): - formfield_overrides = {gis_models.PointField: {'widget': GooglePointFieldWidget}} + formfield_overrides = { + gis_models.PointField: {'widget': GoogleMapPointFieldWidget} + } list_display = ('name', 'position') + + +@admin.register(models.SonoffToken) +class SonoffTokenModelAdmin(admin.ModelAdmin): + """ + Read-only view of the OAuth token; connect via /integrations/sonoff/authorize/. + """ + + list_display = ( + 'region', + 'access_token_expires_at', + 'refresh_token_expires_at', + 'updated_at', + ) + readonly_fields = ( + 'access_token', + 'refresh_token', + 'access_token_expires_at', + 'refresh_token_expires_at', + 'region', + 'updated_at', + ) + + def has_add_permission(self, request): + return False diff --git a/iotserver/apps/device/api/filters.py b/iotserver/apps/device/api/filters.py new file mode 100644 index 0000000..e276a8f --- /dev/null +++ b/iotserver/apps/device/api/filters.py @@ -0,0 +1,26 @@ +from django.db.models import F +from django_filters import rest_framework as filters + +from iotserver.apps.device import models + + +class DeviceStatusFilter(filters.FilterSet): + start_date = filters.DateTimeFilter( + field_name='created_at', lookup_expr='gte' + ) + end_date = filters.DateTimeFilter( + field_name="created_at", lookup_expr='lte' + ) + created_at = filters.DateFilter(field_name="created_at", lookup_expr='date') + sample_size = filters.NumberFilter(method='filter_sample_size') + + def filter_sample_size(self, queryset, name, value): + if value is not None: + queryset = queryset.annotate(idmod4=F('id') % int(value)).filter( + idmod4=0 + ) + return queryset + + class Meta: + model = models.DeviceStatus + fields = ['device'] diff --git a/iotserver/apps/device/api/serializers.py b/iotserver/apps/device/api/serializers.py index e3d6c31..288649c 100644 --- a/iotserver/apps/device/api/serializers.py +++ b/iotserver/apps/device/api/serializers.py @@ -1,4 +1,3 @@ -from django.utils.text import slugify from rest_framework import serializers from rest_framework_gis import serializers as gis_serializers @@ -12,51 +11,76 @@ class Meta: fields = '__all__' -class DeviceTypeSerializer(serializers.HyperlinkedModelSerializer): +class DeviceTypeSerializer(serializers.ModelSerializer): class Meta: model = models.DeviceType fields = '__all__' -class DeviceSerializer(serializers.HyperlinkedModelSerializer): - device_type = serializers.HyperlinkedRelatedField( - many=False, read_only=True, view_name='devicetype-detail' - ) - location = serializers.HyperlinkedRelatedField( - many=False, view_name='location-detail', queryset=models.Location.objects.all() - ) - pins = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name='devicepin-detail' - ) - statuses = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name='devicestatus-detail' - ) - +class DevicePinTypeSerializer(serializers.ModelSerializer): class Meta: - model = models.Device + model = models.DevicePinType fields = '__all__' - lookup_field = 'id' -class DevicePinSerializer(serializers.HyperlinkedModelSerializer): - device = serializers.HyperlinkedRelatedField( - many=False, view_name='device-detail', queryset=models.Device.objects.all() +class DevicePinSerializer(serializers.ModelSerializer): + devices = serializers.PrimaryKeyRelatedField( + many=True, queryset=models.Device.objects.all() ) - identifier = serializers.SerializerMethodField() + type = DevicePinTypeSerializer(many=False, read_only=True) class Meta: model = models.DevicePin fields = '__all__' - def get_identifier(self, instance): - return slugify(instance.name) +class DeviceStatusSerializer(serializers.ModelSerializer): + class Meta: + model = models.DeviceStatus + fields = '__all__' -class DeviceStatusSerializer(serializers.HyperlinkedModelSerializer): - device = serializers.HyperlinkedRelatedField( - many=False, read_only=True, view_name='device-detail' - ) + +class DeviceHealthSerializer(serializers.ModelSerializer): + status = serializers.SerializerMethodField() class Meta: - model = models.DeviceStatus + model = models.DeviceHealth + fields = '__all__' + + def get_status(self, instance): + return instance.status + + +class DeviceListDetailSerializer(serializers.ModelSerializer): + type = DeviceTypeSerializer(many=False, read_only=True) + location = LocationSerializer(many=False, read_only=True) + pins = DevicePinSerializer(many=True, read_only=True) + last_status = serializers.SerializerMethodField() + aggregated_status = serializers.SerializerMethodField() + health = serializers.SerializerMethodField() + + class Meta: + model = models.Device fields = '__all__' + lookup_field = 'id' + + def get_last_status(self, instance): + if instance.last_status: + return DeviceStatusSerializer(instance.last_status).data + + def get_aggregated_status(self, instance): + if instance.aggregate_statuses: + return instance.aggregate_statuses + + def get_health(self, instance): + try: + return DeviceHealthSerializer(instance.health).data + except models.Device.health.RelatedObjectDoesNotExist: + return None + + +class DeviceCreateUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = models.Device + fields = '__all__' + lookup_field = 'id' diff --git a/iotserver/apps/device/api/viewsets.py b/iotserver/apps/device/api/viewsets.py index bd2b142..9950eff 100644 --- a/iotserver/apps/device/api/viewsets.py +++ b/iotserver/apps/device/api/viewsets.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from iotserver.apps.device import models -from iotserver.apps.device.api import serializers +from iotserver.apps.device.api import filters, serializers from iotserver.apps.device.integrations.weather import Location, Weather @@ -14,22 +14,50 @@ class DeviceTypeViewSet(viewsets.ModelViewSet): class DeviceViewSet(viewsets.ModelViewSet): queryset = models.Device.objects.all() - serializer_class = serializers.DeviceSerializer + filterset_fields = ['type', 'active'] + + def get_serializer_class(self): + if self.action in ['list', 'retrieve']: + return serializers.DeviceListDetailSerializer + return serializers.DeviceCreateUpdateSerializer + + @action(detail=True, methods=['post']) + def toggle(self, request, pk=None): + device = self.get_object() + state = request.data.get('state') + if state is None or state not in ['on', 'off']: + return Response( + data={'detail': 'State must either be `on` or `off`'}, + status=status.HTTP_404_NOT_FOUND, + ) + device.mqtt_toggle(state) + return Response(status=status.HTTP_202_ACCEPTED) class DevicePinViewSet(viewsets.ModelViewSet): queryset = models.DevicePin.objects.all() serializer_class = serializers.DevicePinSerializer + filterset_fields = ['devices', 'active'] class DeviceStatusViewSet(viewsets.ModelViewSet): queryset = models.DeviceStatus.objects.all() serializer_class = serializers.DeviceStatusSerializer + filterset_class = filters.DeviceStatusFilter + ordering_fields = ['created_at'] + ordering = ['created_at'] + + +class DeviceHealthViewSet(viewsets.ModelViewSet): + queryset = models.DeviceHealth.objects.all() + serializer_class = serializers.DeviceHealthSerializer + filterset_fields = ['device'] class LocationViewSet(viewsets.ModelViewSet): queryset = models.Location.objects.all() serializer_class = serializers.LocationSerializer + filterset_fields = ['device'] @action(detail=True, methods=['get']) def weather(self, request, pk=None): @@ -46,6 +74,6 @@ def weather(self, request, pk=None): return Response(data=getattr(weather, forecast_type)) except AttributeError: return Response( - data={'error': 'Incorrect forecast type'}, + data={'detail': 'Incorrect forecast type'}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/iotserver/apps/device/constants.py b/iotserver/apps/device/constants.py new file mode 100644 index 0000000..70579e8 --- /dev/null +++ b/iotserver/apps/device/constants.py @@ -0,0 +1 @@ +DEVICE_TOGGLE_STATE = {'on': 1, 'off': 0} diff --git a/iotserver/apps/device/exceptions.py b/iotserver/apps/device/exceptions.py new file mode 100644 index 0000000..8ebbefb --- /dev/null +++ b/iotserver/apps/device/exceptions.py @@ -0,0 +1,2 @@ +class DeviceUnreachableError(Exception): + """Raised when a device cannot be reached over WebREPL.""" diff --git a/iotserver/apps/device/integrations/olarm.py b/iotserver/apps/device/integrations/olarm.py new file mode 100644 index 0000000..b22eb59 --- /dev/null +++ b/iotserver/apps/device/integrations/olarm.py @@ -0,0 +1,57 @@ +from typing import Dict, List, Optional + +import requests +from django.conf import settings +from django.core.cache import cache + +ACTIVE_ALARM_EVENT_STATES = {'alarm', 'fire', 'emergency'} +DEACTIVATED_ALARM_EVENT_STATES = {'disarmed'} + + +class Olarm(object): + """Simple Olarm integration class which returns events for the configured + Olarm device and whether the alarm is currently activated (triggered). + """ + + def __init__(self, cache_timeout: int = None) -> None: + self.config = settings.INTEGRATIONS['olarm'] + self.device_id = self.config['device_id'] + self.cache_prefix = f'{self.__module__}.{self.__class__.__name__}' + self.cache_timeout = cache_timeout or self.config['cache_timeout'] + + @property + def events(self) -> List[Dict]: + cache_key = f'{self.cache_prefix}.events:{self.device_id}' + cached_events = cache.get(cache_key) + if cached_events is not None: + return cached_events + + response = requests.get( + url=f"{self.config['base_url']}/api/v4/devices/{self.device_id}/events", + headers={'Authorization': f"Bearer {self.config['api_key']}"}, + timeout=10, + ) + response.raise_for_status() + events = response.json().get('data', []) + + cache.set(cache_key, events, timeout=self.cache_timeout) + return events + + @property + def latest_event(self) -> Optional[Dict]: + """Return the latest area event, zone/device events don't reflect alarms.""" + area_events = [ + event for event in self.events if event.get('eventAction') == 'area' + ] + if not area_events: + return None + return max(area_events, key=lambda event: event.get('eventTime', 0)) + + @property + def activated(self) -> bool: + event = self.latest_event + if event is None: + return False + if event.get('eventState') in DEACTIVATED_ALARM_EVENT_STATES: + return False + return event.get('eventState') in ACTIVE_ALARM_EVENT_STATES diff --git a/iotserver/apps/device/integrations/rainpoint.py b/iotserver/apps/device/integrations/rainpoint.py new file mode 100644 index 0000000..13a6d4a --- /dev/null +++ b/iotserver/apps/device/integrations/rainpoint.py @@ -0,0 +1,153 @@ +import hashlib +import secrets + +import requests +from django.conf import settings +from django.core.cache import cache + +READING_MAP = { + 'rain_gauge': { + 'address': 1, + 'model_code': 87, + 'fields': {'rainfall_mm_hour': 'rainfall'}, + }, + 'pool_thermometer': { + 'address': 2, + 'model_code': 268, + 'fields': {'temperature_c': 'pool_temperature'}, + }, + 'temperature_sensor': { + 'address': 3, + 'model_code': 262, + 'fields': { + 'temperature_c': 'temperature', + 'humidity_pct': 'humidity', + }, + }, +} + + +class RainPoint: + """Read-only RainPoint Home integration via the HomGar cloud API.""" + + def __init__(self, hub_id: str, cache_timeout: int = None) -> None: + self.config = settings.INTEGRATIONS['rainpoint'] + self.hub_id = hub_id + self.cache_timeout = cache_timeout or self.config['cache_timeout'] + self.cache_prefix = f'{self.__module__}.{self.__class__.__name__}' + self.request_headers = { + 'lang': 'en', + 'appCode': self.config['app_code'], + } + + def _authenticate(self): + cache_key = f'{self.cache_prefix}.auth' + cached_auth_token = cache.get(cache_key) + if cached_auth_token: + return cached_auth_token + + auth_url = f"{self.config['base_url']}/auth/basic/app/login" + request_data = { + 'areaCode': self.config['area_code'], + 'phoneOrEmail': self.config['email'], + 'password': hashlib.md5( + self.config['password'].encode() + ).hexdigest(), + 'deviceId': secrets.token_hex(16), + } + response = requests.post( + url=auth_url, headers=self.request_headers, json=request_data + ) + response.raise_for_status() + auth_data = response.json()['data'] + + token_timeout = int(auth_data['tokenExpired']) + cache.set(cache_key, auth_data['token'], timeout=token_timeout) + + return auth_data['token'] + + def _decode_sensor(self, value, model_code): + if not isinstance(value, str) or not value.startswith(('10#', '11#')): + return {} + + data = bytes.fromhex(value.split('#', 1)[1]) + offset = 1 if value.startswith('11#') else 0 + datapoints = {} + while offset < len(data): + header = data[offset] + offset += 1 + if not header & 0x80: + continue + code = (header >> 2) & 0x1F + length = (header & 0x03) + 1 + if code == 31: + if offset >= len(data): + raise ValueError('Truncated RainPoint datapoint code') + code = data[offset] + offset += 1 + else: + code += 8 + if offset + length > len(data): + raise ValueError('Truncated RainPoint datapoint value') + datapoints[code] = data[offset : offset + length] + offset += length + + readings = {} + if model_code == 87: + if 4 in datapoints: + readings['rainfall_mm_hour'] = ( + int.from_bytes(datapoints[4], 'little') / 10 + ) + elif model_code in (262, 268): + if 9 in datapoints: + fahrenheit = ( + int.from_bytes(datapoints[9], 'little', signed=True) / 10 + ) + readings['temperature_c'] = round((fahrenheit - 32) * 5 / 9, 1) + if model_code == 262 and 10 in datapoints: + readings['humidity_pct'] = int.from_bytes( + datapoints[10], 'little' + ) + return readings + + @property + def statuses(self): + cache_key = f'{self.cache_prefix}.statuses:{self.hub_id}' + cached_statuses = cache.get(cache_key) + if cached_statuses is not None: + return cached_statuses + + token = self._authenticate() + headers = self.request_headers.copy() + headers['auth'] = token + + response = requests.get( + url=f"{self.config['base_url']}/app/device/getDeviceStatus", + params={'mid': self.hub_id}, + headers=headers, + ) + response.raise_for_status() + statuses_data = response.json()['data'] + + entries = { + entry['id']: entry + for entry in (statuses_data or {}).get('subDeviceStatus', []) + } + statuses = {} + for reading in READING_MAP.values(): + status_id = f'D{int(reading["address"]):02d}' + entry = entries.get(status_id, {}) + readings = self._decode_sensor( + entry.get('value'), reading['model_code'] + ) + statuses.update( + { + output: readings[field] + for field, output in reading['fields'].items() + if field in readings + } + ) + + cache.set(cache_key, statuses, timeout=self.cache_timeout) + + return statuses diff --git a/iotserver/apps/device/integrations/solarman.py b/iotserver/apps/device/integrations/solarman.py new file mode 100644 index 0000000..bffd95d --- /dev/null +++ b/iotserver/apps/device/integrations/solarman.py @@ -0,0 +1,76 @@ +import hashlib +from typing import Dict + +import requests +from django.conf import settings +from django.core.cache import cache + + +class Solarman(object): + """Simple Solarman integration class which returns realtime data for a + solar power station. + + Args: + station_id (int): The Solarman station_id + """ + + def __init__(self, station_id: int, cache_timeout: int = None) -> None: + self.config = settings.INTEGRATIONS['solarman'] + self.station_id = station_id + self.cache_prefix = f'{self.__module__}.{self.__class__.__name__}' + self.cache_timeout = cache_timeout or self.config['cache_timeout'] + + def _hash_password(self) -> str: + return hashlib.sha256( + self.config['password'].encode('utf-8') + ).hexdigest() + + def _authenticate(self) -> str: + """ + Authenticate with a pre-configured email address and password and return the + access_token. + """ + auth_url = ( + f"{self.config['base_url']}/account/v1.0/token" + f"?appId={self.config['app_id']}&language=en" + ) + request_data = { + 'appSecret': self.config['app_secret'], + 'email': self.config['email'], + 'password': self._hash_password(), + } + + response = requests.post(url=auth_url, json=request_data) + response.raise_for_status() + auth_data = response.json() + + return auth_data['access_token'] + + @property + def real_time(self) -> Dict: + """ + Return the solar input, battery state of charge and current consumption + for the station. + """ + cache_key = f'{self.cache_prefix}.real_time:{self.station_id}' + cached_real_time = cache.get(cache_key) + if cached_real_time is not None: + return cached_real_time + + access_token = self._authenticate() + + response = requests.post( + url=f"{self.config['base_url']}/station/v1.0/realTime", + json={'stationId': self.station_id}, + headers={'Authorization': f'Bearer {access_token}'}, + ) + response.raise_for_status() + real_time_data = response.json() + + real_time = { + 'solar_input': real_time_data['generationPower'], + 'battery_soc': real_time_data['batterySoc'], + 'current_consumption': real_time_data['usePower'], + } + cache.set(cache_key, real_time, timeout=self.cache_timeout) + return real_time diff --git a/iotserver/apps/device/integrations/sonoff.py b/iotserver/apps/device/integrations/sonoff.py new file mode 100644 index 0000000..c3c767d --- /dev/null +++ b/iotserver/apps/device/integrations/sonoff.py @@ -0,0 +1,225 @@ +import base64 +import hashlib +import hmac +import json +import random +import string +from datetime import datetime, timedelta +from datetime import timezone as tzinfo +from typing import Dict +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone + +from iotserver.apps.device.models import SonoffToken + + +class SonoffNotAuthorizedError(Exception): + """Raised when there is no valid Sonoff OAuth token to authenticate with.""" + + +class Sonoff(object): + """Simple sonoff integration class which can switch a basic device on/off. + + Uses the eWeLink v2 OAuth API, see: + https://coolkit-technologies.github.io/eWeLink-API/#/en/OAuth2.0 + + The configured appid only permits the OAuth authorization-code flow, so a + staff member must authorize the integration once (via `authorize_url()` / + `exchange_code()`) before `toggle_device()` can be used. The resulting + token pair is persisted in `SonoffToken` and refreshed automatically. + + Args: + device_id (str): The sonoff device_id, not required for the + authorize/exchange_code steps of the OAuth flow. + """ + + def __init__( + self, device_id: str = None, cache_timeout: int = None + ) -> None: + self.config = settings.INTEGRATIONS['sonoff'] + self.device_id = device_id + self.cache_timeout = cache_timeout or self.config['cache_timeout'] + self.cache_prefix = f'{self.__module__}.{self.__class__.__name__}' + + def _sign(self, message: str) -> str: + """ + Sign a message in order to authenticate. + """ + hmac_digest = hmac.new( + self.config['app_secret'].encode('utf-8'), + message.encode('utf-8'), + digestmod=hashlib.sha256, + ).digest() + return base64.b64encode(hmac_digest).decode() + + def _sign_request(self, data: Dict) -> str: + """ + Sign a JSON request body in order to authenticate. + """ + return self._sign(json.dumps(data)) + + def _generate_nonce(self) -> str: + """ + Generate a random 8 character nonce. + """ + return ''.join([random.choice(string.ascii_letters) for _ in range(8)]) + + def _headers(self, authorization: str) -> Dict: + return { + 'X-CK-Appid': self.config['app_id'], + 'X-CK-Nonce': self._generate_nonce(), + 'Authorization': authorization, + 'Content-Type': 'application/json', + } + + def _unwrap(self, response: requests.Response) -> Dict: + """ + Raise for a v2 API-level error and return the response's `data`. + """ + response_data = response.json() + if response_data['error'] != 0: + raise RuntimeError(response_data['msg']) + + return response_data['data'] + + @staticmethod + def _from_epoch_ms(value: int) -> datetime: + return datetime.fromtimestamp(value / 1000, tz=tzinfo.utc) + + def _store_tokens( + self, + access_token: str, + refresh_token: str, + access_token_expires_at: datetime, + refresh_token_expires_at: datetime, + ) -> None: + """ + Update the existing singleton row in place rather than assuming pk=1, + since a deleted row's pk is not reused (e.g. on Postgres). + """ + token = SonoffToken.objects.first() or SonoffToken() + token.access_token = access_token + token.refresh_token = refresh_token + token.access_token_expires_at = access_token_expires_at + token.refresh_token_expires_at = refresh_token_expires_at + token.region = self.config['region'] + token.save() + + def authorize_url(self, state: str) -> str: + """ + Build the eWeLink authorization page URL a staff member must open to + grant access to their eWeLink account. + """ + seq = str(int(timezone.now().timestamp() * 1000)) + params = { + 'clientId': self.config['app_id'], + 'seq': seq, + 'authorization': self._sign(f"{self.config['app_id']}_{seq}"), + 'redirectUrl': self.config['redirect_url'], + 'grantType': 'authorization_code', + 'state': state, + 'nonce': self._generate_nonce(), + 'showQRCode': 'false', + } + return f"{self.config['authorize_url']}?{urlencode(params)}" + + def exchange_code(self, code: str) -> None: + """ + Exchange an authorization code (obtained via `authorize_url()`) for an + access/refresh token pair, and persist it. + """ + request_data = { + 'code': code, + 'redirectUrl': self.config['redirect_url'], + 'grantType': 'authorization_code', + } + signed_token = self._sign_request(request_data) + + response = requests.post( + url=self.config['token_url'], + json=request_data, + headers=self._headers(f'Sign {signed_token}'), + ) + response.raise_for_status() + data = self._unwrap(response) + + self._store_tokens( + access_token=data['accessToken'], + refresh_token=data['refreshToken'], + access_token_expires_at=self._from_epoch_ms(data['atExpiredTime']), + refresh_token_expires_at=self._from_epoch_ms(data['rtExpiredTime']), + ) + + def _refresh(self, token: SonoffToken) -> str: + """ + Refresh the access token using the provided refresh token. Returns the + new access token. + """ + request_data = {'rt': token.refresh_token} + signed_token = self._sign_request(request_data) + + response = requests.post( + url=self.config['refresh_url'], + json=request_data, + headers=self._headers(f'Sign {signed_token}'), + ) + response.raise_for_status() + data = self._unwrap(response) + + now = timezone.now() + self._store_tokens( + access_token=data['at'], + refresh_token=data['rt'], + access_token_expires_at=now + timedelta(days=30), + refresh_token_expires_at=now + timedelta(days=60), + ) + return data['at'] + + def _get_access_token(self) -> str: + """ + Return a usable access token, refreshing it first if it has expired. + """ + token = SonoffToken.objects.first() + if token is None: + raise SonoffNotAuthorizedError( + 'Sonoff integration is not authorized yet, visit the ' + 'authorize URL to connect an eWeLink account.' + ) + + if token.access_token_expires_at > timezone.now(): + return token.access_token + + return self._refresh(token) + + def toggle_device(self, state: str) -> str: + """ + Toggle the device state, valid states are [on, off]. The state is + cached to prevent redundant API calls. + """ + cache_key = f'{self.cache_prefix}.toggle_device:{self.device_id}' + previous_state = cache.get(cache_key) + if previous_state == state: + return state + + access_token = self._get_access_token() + request_data = { + 'type': 1, + 'id': self.device_id, + 'params': {'switch': state}, + } + + response = requests.post( + url=self.config['device_url'], + json=request_data, + headers=self._headers(f'Bearer {access_token}'), + ) + response.raise_for_status() + self._unwrap(response) + + cache_timeout = self.cache_timeout + cache.set(cache_key, state, timeout=cache_timeout) + return state diff --git a/iotserver/apps/device/management/commands/mqtt.py b/iotserver/apps/device/management/commands/mqtt.py index 714e2dc..a9bfc7d 100644 --- a/iotserver/apps/device/management/commands/mqtt.py +++ b/iotserver/apps/device/management/commands/mqtt.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from typing import Any import paho.mqtt.client as mqtt from django.conf import settings @@ -11,26 +12,11 @@ class Command(BaseCommand): help = 'Run the mqtt client for devices.' - def mqtt_on_connect(self, client, userdata, flags, result_code): - client.subscribe('iot-devices/#') - - def mqtt_on_message(self, client, userdata, message): - self.stdout.write(self.style.SUCCESS(f'{message.topic}: {message.payload}')) - if 'status' in message.topic: - self.handle_status_queue(message) - - def handle(self, *args, **options): - client = mqtt.Client() - client.on_connect = self.mqtt_on_connect - client.on_message = self.mqtt_on_message - - client.connect(host=settings.MQTT['host'], port=settings.MQTT['port']) - - client.loop_forever() - - self.stdout.write(self.style.SUCCESS('MQTT Client started...')) - - def handle_status_queue(self, message): + def handle_status_queue(self, message: mqtt.MQTTMessage) -> None: + """ + Handle status messages from devices and creates a DeviceStatus entry in + the DB. + """ device_id = message.topic.split('/')[1] try: device = Device.objects.get(id=device_id) @@ -50,3 +36,53 @@ def handle_status_queue(self, message): f'[{timestamp}] Status message received for {device_status.device_id} and processed.' ) ) + + def handle_log_queue(self, message: mqtt.MQTTMessage) -> None: + """ + Handle log messages from devices and log to the console. + """ + device_id = message.topic.split('/')[1] + timestamp = datetime.now().strftime('%d/%b/%Y %H:%M:%S') + self.stdout.write( + self.style.WARNING( + f'[{timestamp}] Log received for {device_id} with message: "{message.payload}"' + ) + ) + + def mqtt_on_connect( + self, + client: mqtt.Client, + userdata: Any, + connect_flags: Any, + reason_code: Any, + properties: Any, + ) -> None: + """ + The callback for when the client receives a CONNACK response from the server. + """ + client.subscribe('iot-devices/#') + + def mqtt_on_message( + self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage + ) -> None: + """ + The callback for when a PUBLISH message is received from the server. + """ + if 'status' in message.topic: + self.handle_status_queue(message) + if 'logs' in message.topic: + self.handle_log_queue(message) + + def handle(self, *args, **options): + """ + Start the MQTT client and listen for messages from devices indefinitely. + """ + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.on_connect = self.mqtt_on_connect + client.on_message = self.mqtt_on_message + + client.connect(host=settings.MQTT['host'], port=settings.MQTT['port']) + + client.loop_forever() + + self.stdout.write(self.style.SUCCESS('MQTT Client started...')) diff --git a/iotserver/apps/device/management/commands/rules.py b/iotserver/apps/device/management/commands/rules.py new file mode 100644 index 0000000..bbc44dc --- /dev/null +++ b/iotserver/apps/device/management/commands/rules.py @@ -0,0 +1,78 @@ +import signal +import threading + +from django.core.management.base import BaseCommand + +from iotserver.apps.device.models import Device +from iotserver.apps.device.utils import rules + +# Seconds between checks for newly added/removed active non-managed-firmware devices. +DEVICE_POLL_INTERVAL = 30 + + +class Command(BaseCommand): + help = 'Run the rules engine for non-managed-firmware devices.' + + def add_arguments(self, parser): + parser.add_argument( + '--poll-interval', + type=int, + default=DEVICE_POLL_INTERVAL, + help='Seconds between checks for added/removed devices.', + ) + + def handle(self, *args, **options): + """ + Continuously reconciles worker threads against active, non-managed- + firmware devices, so devices added/removed/(de)activated while running + are picked up without a restart, until interrupted. + """ + self.shutdown_event = threading.Event() + + def handle_shutdown(signum, frame): + self.stdout.write(self.style.WARNING('Shutting down rules engine...')) + self.shutdown_event.set() + + signal.signal(signal.SIGINT, handle_shutdown) + signal.signal(signal.SIGTERM, handle_shutdown) + + workers = {} + while not self.shutdown_event.is_set(): + self._reconcile_workers(workers) + self.shutdown_event.wait(options['poll_interval']) + + for stop_event, _thread in workers.values(): + stop_event.set() + for _stop_event, thread in workers.values(): + thread.join() + + self.stdout.write(self.style.SUCCESS('Rules engine stopped.')) + + def _reconcile_workers(self, workers): + """ + Starts workers for newly active non-managed-firmware devices and stops + workers for devices which no longer match, mutating `workers` in place + (keyed by device id, valued by (stop_event, thread)). + """ + current_device_ids = set() + + for device in Device.objects.filter(managed_firmware=False, active=True): + current_device_ids.add(device.id) + if device.id not in workers: + stop_event = threading.Event() + thread = threading.Thread( + target=rules.run_device, args=(device, stop_event), daemon=True + ) + thread.start() + workers[device.id] = (stop_event, thread) + self.stdout.write( + self.style.SUCCESS(f'Started worker for device {device.id}.') + ) + + for device_id in [key for key in workers if key not in current_device_ids]: + stop_event, thread = workers.pop(device_id) + stop_event.set() + thread.join() + self.stdout.write( + self.style.WARNING(f'Stopped worker for device {device_id}.') + ) diff --git a/iotserver/apps/device/management/commands/sonoff_authorize.py b/iotserver/apps/device/management/commands/sonoff_authorize.py new file mode 100644 index 0000000..2cc34e0 --- /dev/null +++ b/iotserver/apps/device/management/commands/sonoff_authorize.py @@ -0,0 +1,35 @@ +import uuid + +from django.core.management.base import BaseCommand + +from iotserver.apps.device.integrations.sonoff import Sonoff + + +class Command(BaseCommand): + help = ( + 'Connect the Sonoff/eWeLink integration. Run with no arguments to print ' + 'an authorization URL, open it in a browser and log in, then re-run ' + "with --code using the 'code' query parameter from the redirect URL." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--code', + help="Authorization code from the redirect URL's 'code' parameter.", + ) + + def handle(self, *args, **options): + code = options.get('code') + + if not code: + url = Sonoff().authorize_url(uuid.uuid4().hex) + self.stdout.write( + 'Open this URL in a browser and log in, then re-run this ' + "command with --code using the redirect URL's 'code' " + 'parameter:\n' + ) + self.stdout.write(url) + return + + Sonoff().exchange_code(code) + self.stdout.write(self.style.SUCCESS('Sonoff account connected successfully.')) diff --git a/iotserver/apps/device/migrations/0001_initial.py b/iotserver/apps/device/migrations/0001_initial.py index a90f3be..6eaa6b6 100644 --- a/iotserver/apps/device/migrations/0001_initial.py +++ b/iotserver/apps/device/migrations/0001_initial.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/iotserver/apps/device/migrations/0002_auto_20211006_1728.py b/iotserver/apps/device/migrations/0002_auto_20211006_1728.py index 59937fe..fa3d0c1 100644 --- a/iotserver/apps/device/migrations/0002_auto_20211006_1728.py +++ b/iotserver/apps/device/migrations/0002_auto_20211006_1728.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ('device', '0001_initial'), ] diff --git a/iotserver/apps/device/migrations/0003_devicepin_interval.py b/iotserver/apps/device/migrations/0003_devicepin_interval.py index 3699fb3..4a33335 100644 --- a/iotserver/apps/device/migrations/0003_devicepin_interval.py +++ b/iotserver/apps/device/migrations/0003_devicepin_interval.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ('device', '0002_auto_20211006_1728'), ] diff --git a/iotserver/apps/device/migrations/0004_devicepin_active.py b/iotserver/apps/device/migrations/0004_devicepin_active.py new file mode 100644 index 0000000..50c240a --- /dev/null +++ b/iotserver/apps/device/migrations/0004_devicepin_active.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.6 on 2022-07-20 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0003_devicepin_interval'), + ] + + operations = [ + migrations.AddField( + model_name='devicepin', + name='active', + field=models.BooleanField(default=True), + ), + ] diff --git a/iotserver/apps/device/migrations/0005_alter_device_hostname_alter_device_ip_address_and_more.py b/iotserver/apps/device/migrations/0005_alter_device_hostname_alter_device_ip_address_and_more.py new file mode 100644 index 0000000..068b4d9 --- /dev/null +++ b/iotserver/apps/device/migrations/0005_alter_device_hostname_alter_device_ip_address_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.0.6 on 2022-07-24 04:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0004_devicepin_active'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='hostname', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AlterField( + model_name='device', + name='ip_address', + field=models.GenericIPAddressField(unique=True), + ), + migrations.AlterField( + model_name='device', + name='mac_address', + field=models.CharField(max_length=48, unique=True), + ), + migrations.AlterField( + model_name='devicepin', + name='identifier', + field=models.SlugField(max_length=64, unique=True), + ), + migrations.CreateModel( + name='DeviceHealth', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.BooleanField(default=False)), + ( + 'device', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='health', + to='device.device', + ), + ), + ], + options={ + 'verbose_name_plural': 'Device Health', + }, + ), + ] diff --git a/iotserver/apps/device/migrations/0006_remove_devicepin_device_devicepin_device.py b/iotserver/apps/device/migrations/0006_remove_devicepin_device_devicepin_device.py new file mode 100644 index 0000000..9309007 --- /dev/null +++ b/iotserver/apps/device/migrations/0006_remove_devicepin_device_devicepin_device.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1 on 2022-08-17 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0005_alter_device_hostname_alter_device_ip_address_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='devicepin', + name='device', + ), + migrations.AddField( + model_name='devicepin', + name='device', + field=models.ManyToManyField(related_name='pins', to='device.device'), + ), + ] diff --git a/iotserver/apps/device/migrations/0007_remove_devicehealth_status.py b/iotserver/apps/device/migrations/0007_remove_devicehealth_status.py new file mode 100644 index 0000000..1556098 --- /dev/null +++ b/iotserver/apps/device/migrations/0007_remove_devicehealth_status.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1 on 2022-11-30 18:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0006_remove_devicepin_device_devicepin_device'), + ] + + operations = [ + migrations.RemoveField( + model_name='devicehealth', + name='status', + ), + ] diff --git a/iotserver/apps/device/migrations/0008_alter_devicestatus_options_devicepin_display.py b/iotserver/apps/device/migrations/0008_alter_devicestatus_options_devicepin_display.py new file mode 100644 index 0000000..e6061df --- /dev/null +++ b/iotserver/apps/device/migrations/0008_alter_devicestatus_options_devicepin_display.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.7 on 2023-03-06 11:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0007_remove_devicehealth_status'), + ] + + operations = [ + migrations.AlterModelOptions( + name='devicestatus', + options={ + 'ordering': ['-created_at'], + 'verbose_name_plural': 'Device Statuses', + }, + ), + migrations.AddField( + model_name='devicepin', + name='display', + field=models.JSONField(null=True), + ), + ] diff --git a/iotserver/apps/device/migrations/0009_devicepin_i2c.py b/iotserver/apps/device/migrations/0009_devicepin_i2c.py new file mode 100644 index 0000000..3658204 --- /dev/null +++ b/iotserver/apps/device/migrations/0009_devicepin_i2c.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-03-11 04:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0008_alter_devicestatus_options_devicepin_display'), + ] + + operations = [ + migrations.AddField( + model_name='devicepin', + name='i2c', + field=models.BooleanField(default=False), + ), + ] diff --git a/iotserver/apps/device/migrations/0010_rename_device_devicepin_devices.py b/iotserver/apps/device/migrations/0010_rename_device_devicepin_devices.py new file mode 100644 index 0000000..9d147b5 --- /dev/null +++ b/iotserver/apps/device/migrations/0010_rename_device_devicepin_devices.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-10-18 05:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0009_devicepin_i2c'), + ] + + operations = [ + migrations.RenameField( + model_name='devicepin', + old_name='device', + new_name='devices', + ), + ] diff --git a/iotserver/apps/device/migrations/0011_devicepintype_devicetype_identifier_devicepin_type.py b/iotserver/apps/device/migrations/0011_devicepintype_devicetype_identifier_devicepin_type.py new file mode 100644 index 0000000..e2d114e --- /dev/null +++ b/iotserver/apps/device/migrations/0011_devicepintype_devicetype_identifier_devicepin_type.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.6 on 2025-11-18 04:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0010_rename_device_devicepin_devices'), + ] + + operations = [ + migrations.CreateModel( + name='DevicePinType', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('name', models.CharField(max_length=32)), + ('identifier', models.SlugField(max_length=64, null=True, unique=True)), + ], + ), + migrations.AddField( + model_name='devicetype', + name='identifier', + field=models.SlugField(max_length=64, null=True, unique=True), + ), + migrations.AddField( + model_name='devicepin', + name='type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='pins', + to='device.devicepintype', + ), + ), + ] diff --git a/iotserver/apps/device/migrations/0012_device_managed_firmware.py b/iotserver/apps/device/migrations/0012_device_managed_firmware.py new file mode 100644 index 0000000..609ef74 --- /dev/null +++ b/iotserver/apps/device/migrations/0012_device_managed_firmware.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-09-23 17:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0011_devicepintype_devicetype_identifier_devicepin_type'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='managed_firmware', + field=models.BooleanField(default=True), + ), + ] diff --git a/iotserver/apps/device/migrations/0013_alter_device_location.py b/iotserver/apps/device/migrations/0013_alter_device_location.py new file mode 100644 index 0000000..587c954 --- /dev/null +++ b/iotserver/apps/device/migrations/0013_alter_device_location.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 on 2026-09-24 07:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0012_device_managed_firmware'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='device.location', + ), + ), + ] diff --git a/iotserver/apps/device/migrations/0014_sonofftoken.py b/iotserver/apps/device/migrations/0014_sonofftoken.py new file mode 100644 index 0000000..54fd75b --- /dev/null +++ b/iotserver/apps/device/migrations/0014_sonofftoken.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.17 on 2026-09-24 08:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0013_alter_device_location'), + ] + + operations = [ + migrations.CreateModel( + name='SonoffToken', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('access_token', models.CharField(max_length=255)), + ('refresh_token', models.CharField(max_length=255)), + ('access_token_expires_at', models.DateTimeField()), + ('refresh_token_expires_at', models.DateTimeField()), + ('region', models.CharField(max_length=8)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Sonoff Token', + }, + ), + ] diff --git a/iotserver/apps/device/migrations/0015_alter_devicepin_display.py b/iotserver/apps/device/migrations/0015_alter_devicepin_display.py new file mode 100644 index 0000000..a38f758 --- /dev/null +++ b/iotserver/apps/device/migrations/0015_alter_devicepin_display.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-09-24 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0014_sonofftoken'), + ] + + operations = [ + migrations.AlterField( + model_name='devicepin', + name='display', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/iotserver/apps/device/migrations/0016_devicehealth_battery_level_and_more.py b/iotserver/apps/device/migrations/0016_devicehealth_battery_level_and_more.py new file mode 100644 index 0000000..8f56da6 --- /dev/null +++ b/iotserver/apps/device/migrations/0016_devicehealth_battery_level_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-10-07 09:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('device', '0015_alter_devicepin_display'), + ] + + operations = [ + migrations.AddField( + model_name='devicehealth', + name='battery_level', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='devicehealth', + name='wifi_signal_strength', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/iotserver/apps/device/models.py b/iotserver/apps/device/models.py index f1622df..aa5e0e7 100644 --- a/iotserver/apps/device/models.py +++ b/iotserver/apps/device/models.py @@ -7,9 +7,11 @@ from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.urls import reverse +from django.utils import timezone from django.utils.functional import cached_property -from iotserver.apps.device.utils import webrepl +from iotserver.apps.device import constants, exceptions +from iotserver.apps.device.utils import mqtt, stats, webrepl class Location(models.Model): @@ -33,6 +35,7 @@ def coordinates(self): class DeviceType(models.Model): name = models.CharField(max_length=32) + identifier = models.SlugField(max_length=64, unique=True, null=True) def __str__(self): return self.name @@ -49,6 +52,7 @@ class Device(models.Model): updated_at = models.DateTimeField(auto_now=True) active = models.BooleanField(default=False) + managed_firmware = models.BooleanField(default=True) name = models.CharField(max_length=128) description = models.CharField(max_length=1024) @@ -60,13 +64,13 @@ class Device(models.Model): null=True, related_name='devices', ) - location = models.OneToOneField( + location = models.ForeignKey( Location, on_delete=models.SET_NULL, blank=True, null=True ) - ip_address = models.GenericIPAddressField() - mac_address = models.CharField(max_length=48) - hostname = models.CharField(max_length=64) + ip_address = models.GenericIPAddressField(unique=True) + mac_address = models.CharField(max_length=48, unique=True) + hostname = models.CharField(max_length=64, blank=True, null=True) config = models.JSONField(blank=True, null=True) @@ -77,26 +81,64 @@ def __str__(self): def resource_url(self): return reverse('device-detail', kwargs={'pk': str(self.id)}) - @cached_property + @property def full_config(self): config = self.config - config['pins'] = [pin.config for pin in self.pins.all().order_by('-pin_number')] + config['pins'] = [ + pin.config + for pin in self.pins.filter(active=True).order_by('-pin_number') + ] return config + @cached_property + def last_status(self): + return self.statuses.first() + + @cached_property + def aggregate_statuses(self): + return stats.aggregate_statuses(self.statuses) + + def mqtt_toggle(self, state: str): + mqtt.toggle(self.id, str(constants.DEVICE_TOGGLE_STATE[state])) + + +class DevicePinType(models.Model): + name = models.CharField(max_length=32) + identifier = models.SlugField(max_length=64, unique=True, null=True) + + def __str__(self): + return self.name + + @cached_property + def resource_url(self): + return reverse('devicetype-detail', kwargs={'pk': self.pk}) + class DevicePin(models.Model): - device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='pins') + active = models.BooleanField(default=True) + + devices = models.ManyToManyField(Device, related_name='pins') name = models.CharField(max_length=32) - identifier = models.SlugField(max_length=64) + identifier = models.SlugField(max_length=64, unique=True) pin_number = models.PositiveSmallIntegerField(blank=True, null=True) + type = models.ForeignKey( + DevicePinType, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='pins', + ) interval = models.IntegerField(default=1) analog = models.BooleanField(default=False) read = models.BooleanField(default=False) + i2c = models.BooleanField(default=False) rule = models.JSONField(null=False) + display = models.JSONField(null=True, blank=True) + def __str__(self): return self.name @@ -113,6 +155,7 @@ def config(self): 'interval': self.interval, 'analog': self.analog, 'read': self.read, + 'i2c': self.i2c, 'rule': self.rule, } @@ -128,6 +171,7 @@ class DeviceStatus(models.Model): class Meta: verbose_name_plural = 'Device Statuses' + ordering = ['-created_at'] def __str__(self): return self.device.name @@ -137,42 +181,117 @@ def resource_url(self): return reverse('devicestatus-detail', kwargs={'pk': self.pk}) -@receiver(pre_save, sender=Device) -def handle_device_default_config(sender, instance, *args, **kwargs): - """Get the config from the new device and update the config field.""" - if instance.config is None: - temp_file_path = f'/tmp/config.{instance.id}.json' - with open(temp_file_path, 'w') as input_file: - input_file.write('') +class DeviceHealth(models.Model): + device = models.OneToOneField( + Device, on_delete=models.CASCADE, related_name='health' + ) + wifi_signal_strength = models.IntegerField(blank=True, null=True) + battery_level = models.IntegerField(blank=True, null=True) - _socket, web_socket = webrepl.get_websocket( - instance.ip_address, settings.WEBREPL_PORT, settings.WEBREPL_PASSWORD - ) - webrepl.get_file(web_socket, temp_file_path, 'config/config.json') - _socket.close() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = 'Device Health' + + def __str__(self): + return self.device.name - with open(temp_file_path, 'r') as input_file: - device_config = json.loads(input_file.read()) + @cached_property + def resource_url(self): + return reverse('devicehealth-detail', kwargs={'pk': self.pk}) - device_id = str(instance.id) - device_config['main']['identifier'] = device_id - device_config['mqtt']['client_id'] = device_id + @cached_property + def status(self): + return self.updated_at > timezone.now() - timezone.timedelta(minutes=1) - # Ignore the pin config - del device_config['pins'] - instance.config = device_config +class SonoffToken(models.Model): + """Singleton row holding the eWeLink OAuth token pair for the Sonoff integration.""" + + access_token = models.CharField(max_length=255) + refresh_token = models.CharField(max_length=255) + access_token_expires_at = models.DateTimeField() + refresh_token_expires_at = models.DateTimeField() + region = models.CharField(max_length=8) + + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Sonoff Token' + + def __str__(self): + return f'Sonoff token ({self.region})' + + +@receiver(pre_save, sender=Device) +def handle_device_default_config(sender, instance, *args, **kwargs): + """Get the config from the managed new device and update the config field.""" + if ( + settings.AUTO_SYNC_DEVICE + and instance.managed_firmware + and instance.active + ): + if instance.config is None: + temp_file_path = f'/tmp/config.{instance.id}.json' + with open(temp_file_path, 'w') as input_file: + input_file.write('') + + try: + _socket, web_socket = webrepl.get_websocket( + instance.ip_address, + settings.WEBREPL_PORT, + settings.WEBREPL_PASSWORD, + ) + webrepl.get_file( + web_socket, temp_file_path, 'config/config.json' + ) + _socket.close() + except OSError as error: + raise exceptions.DeviceUnreachableError( + f"Device '{instance.name}' at {instance.ip_address} is unreachable." + ) from error + + with open(temp_file_path, 'r') as input_file: + device_config = json.loads(input_file.read()) + + device_id = str(instance.id) + device_config['main']['identifier'] = device_id + + # Ignore the pin config + del device_config['pins'] + + instance.config = device_config + + elif instance.config is None and not instance.managed_firmware: + instance.config = json.loads( + json.dumps(settings.DEVICE_DEFAULT_CONFIG).replace( + '{identifier}', str(instance.id) + ) + ) @receiver(post_save, sender=Device) def handle_device_config_update(sender, instance, *args, **kwargs): - """Update config on the physical device via webrepl.""" - temp_file_path = f'/tmp/config.{instance.id}.json' - with open(temp_file_path, 'w') as input_file: - input_file.write(json.dumps(instance.full_config, indent=4)) - - _socket, web_socket = webrepl.get_websocket( - instance.ip_address, settings.WEBREPL_PORT, settings.WEBREPL_PASSWORD - ) - webrepl.put_file(web_socket, temp_file_path, 'config/config.json') - _socket.close() + """Update config on the managed physical device via webrepl.""" + if ( + settings.AUTO_SYNC_DEVICE + and instance.managed_firmware + and instance.active + ): + temp_file_path = f'/tmp/config.{instance.id}.json' + with open(temp_file_path, 'w') as input_file: + input_file.write(json.dumps(instance.full_config, indent=4)) + + try: + _socket, web_socket = webrepl.get_websocket( + instance.ip_address, + settings.WEBREPL_PORT, + settings.WEBREPL_PASSWORD, + ) + webrepl.put_file(web_socket, temp_file_path, 'config/config.json') + _socket.close() + except OSError as error: + raise exceptions.DeviceUnreachableError( + f"Device '{instance.name}' at {instance.ip_address} is unreachable." + ) from error diff --git a/iotserver/apps/device/tests/api/test_viewsets.py b/iotserver/apps/device/tests/api/test_viewsets.py index 781685c..9baf279 100644 --- a/iotserver/apps/device/tests/api/test_viewsets.py +++ b/iotserver/apps/device/tests/api/test_viewsets.py @@ -29,11 +29,13 @@ def test_list(self, api_client): return_data = response.json() assert response.status_code == 200 - assert len(return_data) == 1 + assert return_data['count'] == 1 - assert return_data[0]['id'] == self.location.id - assert return_data[0]['name'] == self.location.name - assert return_data[0]['position'] == json.loads(self.location.position.geojson) + assert return_data['results'][0]['id'] == self.location.id + assert return_data['results'][0]['name'] == self.location.name + assert return_data['results'][0]['position'] == json.loads( + self.location.position.geojson + ) def test_detail(self, api_client): response = api_client.get(f'{self.root_url}{self.location.pk}/') @@ -42,14 +44,18 @@ def test_detail(self, api_client): assert response.status_code == 200 assert return_data['id'] == self.location.id assert return_data['name'] == self.location.name - assert return_data['position'] == json.loads(self.location.position.geojson) + assert return_data['position'] == json.loads( + self.location.position.geojson + ) def test_create(self, api_client): location_data = { 'name': 'Test Location', 'position': {'type': 'Point', 'coordinates': [28.36402, -26.13946]}, } - response = api_client.post(f'{self.root_url}', location_data, format='json') + response = api_client.post( + f'{self.root_url}', location_data, format='json' + ) return_data = response.json() assert response.status_code == 201 @@ -79,15 +85,14 @@ def setup_method(self, test_method): self.device_type = device_factories.DeviceTypeFactory() def test_list(self, api_client): - response = api_client.get(self.root_url) return_data = response.json() assert response.status_code == 200 - assert len(return_data) == 1 + assert return_data['count'] == 1 - assert return_data[0]['name'] == self.device_type.name - assert str(self.device_type.pk) in return_data[0]['url'] + assert return_data['results'][0]['name'] == self.device_type.name + assert self.device_type.pk == return_data['results'][0]['id'] def test_detail(self, api_client): response = api_client.get(f'{self.root_url}{self.device_type.pk}/') @@ -95,11 +100,13 @@ def test_detail(self, api_client): assert response.status_code == 200 assert return_data['name'] == self.device_type.name - assert str(self.device_type.pk) in return_data['url'] + assert self.device_type.pk == return_data['id'] def test_create(self, api_client): device_type_data = {'name': 'Test Type'} - response = api_client.post(f'{self.root_url}', device_type_data, format='json') + response = api_client.post( + f'{self.root_url}', device_type_data, format='json' + ) return_data = response.json() assert response.status_code == 201 @@ -108,35 +115,48 @@ def test_create(self, api_client): def test_update(self, api_client): device_type_data = {'name': 'Test Type'} response = api_client.put( - f'{self.root_url}{self.device_type.pk}/', device_type_data, format='json' + f'{self.root_url}{self.device_type.pk}/', + device_type_data, + format='json', ) return_data = response.json() assert response.status_code == 200 assert return_data['name'] == device_type_data['name'] - assert str(self.device_type.pk) in return_data['url'] + assert self.device_type.pk == return_data['id'] @pytest.mark.django_db -@factory.django.mute_signals(signals.post_save, signals.pre_save) class TestDeviceViewset(object): root_url = '/api/devices/' def setup_method(self, test_method): self.device = device_factories.DeviceFactory() + self.device_health = device_factories.DeviceHealthFactory( + device=self.device + ) + self.device_type = device_factories.DeviceTypeFactory() + self.location = device_factories.LocationFactory() def test_list(self, api_client): response = api_client.get(self.root_url) return_data = response.json() assert response.status_code == 200 - assert len(return_data) == 1 + assert return_data['count'] == 1 - assert return_data[0]['name'] == self.device.name + assert return_data['results'][0]['name'] == self.device.name - assert str(self.device.pk) in return_data[0]['url'] - assert str(self.device.type.pk) in return_data[0]['type'] - assert str(self.device.location.pk) in return_data[0]['location'] + assert str(self.device.pk) == return_data['results'][0]['id'] + assert self.device.type.pk == return_data['results'][0]['type']['id'] + assert ( + self.device.location.pk + == return_data['results'][0]['location']['id'] + ) + assert ( + self.device_health.status + == return_data['results'][0]['health']['status'] + ) def test_detail(self, api_client): response = api_client.get(f'{self.root_url}{self.device.pk}/') @@ -145,44 +165,42 @@ def test_detail(self, api_client): assert response.status_code == 200 assert return_data['name'] == self.device.name - assert str(self.device.pk) in return_data['url'] - assert str(self.device.type.pk) in return_data['type'] - assert str(self.device.location.pk) in return_data['location'] + assert str(self.device.pk) == return_data['id'] + assert self.device.type.pk == return_data['type']['id'] + assert self.device.location.pk == return_data['location']['id'] + @factory.django.mute_signals(signals.post_save, signals.pre_save) def test_create(self, api_client): - device_type = device_factories.DeviceTypeFactory() - location = device_factories.LocationFactory() - device_data = { 'name': 'Test Device', 'description': 'Test Device Description', - 'type': device_type.resource_url, - 'location': location.resource_url, + 'type': self.device_type.id, + 'location': self.location.id, 'ip_address': '192.168.0.2', 'mac_address': 'EE:01:A0:01:60:BE', - 'hostname': 'test-device-002', + 'hostname': 'test-device-001', } - response = api_client.post(f'{self.root_url}', device_data, format='json') + response = api_client.post( + f'{self.root_url}', device_data, format='json' + ) return_data = response.json() assert response.status_code == 201 assert return_data['name'] == device_data['name'] - assert str(device_type.pk) in return_data['type'] - assert str(location.pk) in return_data['location'] + assert self.device_type.pk == return_data['type'] + assert self.location.pk == return_data['location'] + @factory.django.mute_signals(signals.post_save, signals.pre_save) def test_update(self, api_client): - device_type = device_factories.DeviceTypeFactory() - location = device_factories.LocationFactory() - device_data = { - 'name': 'Test Device', - 'description': 'Test Device Description', - 'type': device_type.resource_url, - 'location': location.resource_url, - 'ip_address': self.device.ip_address, - 'mac_address': self.device.mac_address, - 'hostname': self.device.hostname, + 'name': 'Test Device - v2', + 'description': 'Test Device - v2 Description', + 'type': self.device_type.id, + 'location': self.location.id, + 'ip_address': '192.168.0.3', + 'mac_address': 'EE:01:A0:01:60:BE', + 'hostname': 'test-device-v2-001', } response = api_client.put( f'{self.root_url}{self.device.pk}/', device_data, format='json' @@ -192,9 +210,8 @@ def test_update(self, api_client): assert response.status_code == 200, return_data assert return_data['name'] == device_data['name'] - assert str(self.device.pk) in return_data['url'] - assert str(device_type.pk) in return_data['type'] - assert str(location.pk) in return_data['location'] + assert self.device_type.pk == return_data['type'] + assert self.location.pk == return_data['location'] @pytest.mark.django_db @@ -202,20 +219,31 @@ class TestDevicePinViewset(object): root_url = '/api/devices/pins/' def setup_method(self, test_method): - self.device_pins = device_factories.DevicePinFactory() + self.device = device_factories.DeviceFactory( + ip_address='192.168.0.2', mac_address='0E:01:2A:C1:92:5E' + ) + self.device_pins = device_factories.DevicePinFactory( + devices=[self.device] + ) def test_list(self, api_client): response = api_client.get(self.root_url) return_data = response.json() assert response.status_code == 200 - assert len(return_data) == 1 + assert return_data['count'] == 1 - assert return_data[0]['name'] == self.device_pins.name - assert return_data[0]['identifier'] == self.device_pins.identifier + assert return_data['results'][0]['name'] == self.device_pins.name + assert ( + return_data['results'][0]['identifier'] + == self.device_pins.identifier + ) - assert str(self.device_pins.pk) in return_data[0]['url'] - assert str(self.device_pins.device.pk) in return_data[0]['device'] + assert self.device_pins.pk == return_data['results'][0]['id'] + assert ( + str(self.device_pins.devices.first().pk) + in return_data['results'][0]['devices'] + ) def test_detail(self, api_client): response = api_client.get(f'{self.root_url}{self.device_pins.pk}/') @@ -226,21 +254,24 @@ def test_detail(self, api_client): assert return_data['name'] == self.device_pins.name assert return_data['identifier'] == self.device_pins.identifier - assert str(self.device_pins.pk) in return_data['url'] - assert str(self.device_pins.device.pk) in return_data['device'] + assert self.device_pins.pk == return_data['id'] + assert ( + str(self.device_pins.devices.first().pk) in return_data['devices'] + ) def test_create(self, api_client): - device = device_factories.DeviceFactory() - device_pin_data = { - 'device': device.resource_url, + 'devices': [self.device.id], 'name': 'Test Pin', + 'identifier': 'test-pin', 'pin_number': 1, 'analog': True, 'read': False, 'rule': {}, } - response = api_client.post(f'{self.root_url}', device_pin_data, format='json') + response = api_client.post( + f'{self.root_url}', device_pin_data, format='json' + ) return_data = response.json() @@ -249,21 +280,22 @@ def test_create(self, api_client): assert return_data['name'] == device_pin_data['name'] assert return_data['identifier'] == 'test-pin' - assert str(device.id) in return_data['device'] + assert str(self.device.id) in return_data['devices'] def test_update(self, api_client): - device = device_factories.DeviceFactory() - device_pin_data = { - 'device': device.resource_url, + 'devices': [self.device.id], 'name': 'Test Pin 2', + 'identifier': 'test-pin-2', 'pin_number': self.device_pins.pin_number, 'analog': self.device_pins.analog, 'read': self.device_pins.read, 'rule': {}, } response = api_client.put( - f'{self.root_url}{self.device_pins.pk}/', device_pin_data, format='json' + f'{self.root_url}{self.device_pins.pk}/', + device_pin_data, + format='json', ) return_data = response.json() @@ -272,4 +304,4 @@ def test_update(self, api_client): assert return_data['name'] == device_pin_data['name'] assert return_data['identifier'] == 'test-pin-2' - assert str(device.id) in return_data['device'] + assert str(self.device.id) in return_data['devices'] diff --git a/iotserver/apps/device/tests/factories.py b/iotserver/apps/device/tests/factories.py index 481f0c0..229bb5b 100644 --- a/iotserver/apps/device/tests/factories.py +++ b/iotserver/apps/device/tests/factories.py @@ -44,8 +44,7 @@ class Meta: class DevicePinFactory(factory.django.DjangoModelFactory): class Meta: model = models.DevicePin - - device = factory.SubFactory(DeviceFactory) + skip_postgeneration_save = True name = factory.fuzzy.FuzzyText(length=12) identifier = factory.LazyAttribute(lambda obj: slugify(obj.name)) @@ -57,6 +56,14 @@ class Meta: rule = {} + @factory.post_generation + def devices(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for device in extracted: + self.devices.add(device) + class DeviceStatusFactory(factory.django.DjangoModelFactory): class Meta: @@ -64,3 +71,10 @@ class Meta: device = factory.SubFactory(DeviceFactory) status = {} + + +class DeviceHealthFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.DeviceHealth + + device = factory.SubFactory(DeviceFactory) diff --git a/iotserver/apps/device/tests/integrations/test_olarm.py b/iotserver/apps/device/tests/integrations/test_olarm.py new file mode 100644 index 0000000..e577c08 --- /dev/null +++ b/iotserver/apps/device/tests/integrations/test_olarm.py @@ -0,0 +1,189 @@ +import pytest + +from iotserver.apps.device.integrations.olarm import ( + ACTIVE_ALARM_EVENT_STATES, + DEACTIVATED_ALARM_EVENT_STATES, + Olarm, +) + + +@pytest.fixture(autouse=True) +def mock_settings(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.olarm.settings') + mock.INTEGRATIONS = { + 'olarm': { + 'base_url': 'https://example.com', + 'api_key': 'api-key', + 'device_id': 'device-id', + 'cache_timeout': 60, + } + } + return mock + + +@pytest.fixture(autouse=True) +def mock_cache(mocker): + """Mock Django cache to return None (cache miss)""" + mock = mocker.patch('iotserver.apps.device.integrations.olarm.cache') + mock.get.return_value = None + return mock + + +@pytest.fixture +def olarm(): + return Olarm() + + +@pytest.fixture +def mock_requests(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.olarm.requests') + mock.get.return_value.json.return_value = {'data': []} + return mock + + +def set_events(mock_requests, events): + mock_requests.get.return_value.json.return_value = {'data': events} + + +def area_event(event_time, state): + return {'eventTime': event_time, 'eventAction': 'area', 'eventState': state} + + +class TestOlarmIntegration(object): + def test_init_uses_device_id_from_settings(self, olarm): + assert olarm.device_id == 'device-id' + + def test_events(self, olarm, mock_requests): + events = [{'eventTime': 1, 'eventAction': 'disarm'}] + set_events(mock_requests, events) + + assert olarm.events == events + mock_requests.get.assert_called_once_with( + url='https://example.com/api/v4/devices/device-id/events', + headers={'Authorization': 'Bearer api-key'}, + timeout=10, + ) + mock_requests.get.return_value.raise_for_status.assert_called_once_with() + + def test_events_are_cached(self, olarm, mock_requests, mock_cache): + events = [area_event(1, 'arm')] + set_events(mock_requests, events) + + assert olarm.events == events + + cache_key = ( + 'iotserver.apps.device.integrations.olarm.Olarm.events:device-id' + ) + mock_cache.get.assert_called_once_with(cache_key) + mock_cache.set.assert_called_once_with(cache_key, events, timeout=60) + + def test_events_custom_cache_timeout(self, mock_requests, mock_cache): + _ = Olarm(cache_timeout=30).events + + assert mock_cache.set.call_args.kwargs['timeout'] == 30 + + def test_events_cache_hit(self, olarm, mock_requests, mock_cache): + events = [area_event(1, 'alarm')] + mock_cache.get.return_value = events + + assert olarm.events == events + mock_requests.get.assert_not_called() + mock_cache.set.assert_not_called() + + def test_events_http_error_is_not_cached( + self, olarm, mock_requests, mock_cache + ): + mock_requests.get.return_value.raise_for_status.side_effect = ( + RuntimeError + ) + + with pytest.raises(RuntimeError): + _ = olarm.events + mock_cache.set.assert_not_called() + + def test_events_missing_data_returns_empty_list(self, olarm, mock_requests): + mock_requests.get.return_value.json.return_value = {} + + assert olarm.events == [] + + def test_events_http_error_is_propagated(self, olarm, mock_requests): + mock_requests.get.return_value.raise_for_status.side_effect = ( + RuntimeError('events fetch failed') + ) + + with pytest.raises(RuntimeError, match='events fetch failed'): + _ = olarm.events + + def test_latest_event(self, olarm, mock_requests): + set_events( + mock_requests, + [ + area_event(100, 'arm'), + area_event(300, 'alarm'), + area_event(200, 'disarm'), + ], + ) + + assert olarm.latest_event == area_event(300, 'alarm') + + def test_latest_event_ignores_non_area_events(self, olarm, mock_requests): + set_events( + mock_requests, + [ + area_event(100, 'arm'), + { + 'eventTime': 200, + 'eventAction': 'zone', + 'eventState': 'active', + }, + ], + ) + + assert olarm.latest_event == area_event(100, 'arm') + + def test_latest_event_no_events(self, olarm, mock_requests): + assert olarm.latest_event is None + + def test_latest_event_no_area_events(self, olarm, mock_requests): + set_events( + mock_requests, + [{'eventTime': 200, 'eventAction': 'zone', 'eventState': 'active'}], + ) + + assert olarm.latest_event is None + + @pytest.mark.parametrize('state', ACTIVE_ALARM_EVENT_STATES) + def test_activated_when_latest_event_is_alarm( + self, olarm, mock_requests, state + ): + set_events( + mock_requests, [area_event(100, 'arm'), area_event(200, state)] + ) + + assert olarm.activated is True + + @pytest.mark.parametrize( + 'state', + ['arm', 'disarm', 'stay', 'sleep', 'notready', 'countdown', None], + ) + def test_not_activated_when_latest_event_is_not_alarm( + self, olarm, mock_requests, state + ): + set_events( + mock_requests, [area_event(100, 'alarm'), area_event(200, state)] + ) + + assert olarm.activated is False + + def test_not_activated_when_no_events(self, olarm, mock_requests): + assert olarm.activated is False + + @pytest.mark.parametrize('state', DEACTIVATED_ALARM_EVENT_STATES) + def test_deactivated_when_latest_event_is_deactivated( + self, olarm, mock_requests, state + ): + set_events( + mock_requests, [area_event(100, 'arm'), area_event(200, state)] + ) + + assert olarm.activated is False diff --git a/iotserver/apps/device/tests/integrations/test_rainpoint.py b/iotserver/apps/device/tests/integrations/test_rainpoint.py new file mode 100644 index 0000000..61bcaff --- /dev/null +++ b/iotserver/apps/device/tests/integrations/test_rainpoint.py @@ -0,0 +1,350 @@ +import hashlib + +import pytest +import requests + +from iotserver.apps.device.integrations.rainpoint import RainPoint + + +@pytest.fixture +def client(mocker): + settings = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.settings' + ) + settings.INTEGRATIONS = { + 'rainpoint': { + 'base_url': 'https://example.com', + 'app_code': '2', + 'email': 'api@example.com', + 'password': 'password', + 'area_code': '27', + 'hub_id': '361481', + 'cache_timeout': 60, + } + } + return RainPoint('361481') + + +@pytest.fixture +def mock_cache(mocker): + cache = mocker.patch('iotserver.apps.device.integrations.rainpoint.cache') + cache.get.return_value = None + return cache + + +@pytest.fixture +def mock_request(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.rainpoint.requests') + mock.post.return_value.json.return_value = { + 'code': 0, + 'data': {'token': 'token', 'tokenExpired': 3600}, + } + mock.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': []}, + } + return mock + + +@pytest.mark.parametrize('custom_mapping', [False, True]) +def test_statuses_uses_reading_map(mocker, mock_cache, custom_mapping): + settings = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.settings' + ) + settings.INTEGRATIONS = { + 'rainpoint': { + 'base_url': 'https://example.com', + 'app_code': '2', + 'cache_timeout': 60, + } + } + rainpoint = RainPoint('361481') + mocker.patch.object(rainpoint, '_authenticate', return_value='token') + request = mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.requests.get' + ) + if custom_mapping: + mocker.patch( + 'iotserver.apps.device.integrations.rainpoint.READING_MAP', + { + 'custom_sensor': { + 'address': 7, + 'model_code': 262, + 'fields': {'humidity_pct': 'custom_humidity'}, + } + }, + ) + request.return_value.json.return_value = { + 'data': { + 'subDeviceStatus': [ + {'id': 'D01', 'value': '10#FD040F00'}, + {'id': 'D02', 'value': '10#85B602'}, + {'id': 'D03', 'value': '10#85BA028842'}, + {'id': 'D07', 'value': '10#85BA028827'}, + ] + } + } + expected = ( + {'custom_humidity': 39} + if custom_mapping + else { + 'rainfall': 1.5, + 'pool_temperature': 20.8, + 'temperature': 21.0, + 'humidity': 66, + } + ) + + assert rainpoint.statuses == expected + request.assert_called_once_with( + url='https://example.com/app/device/getDeviceStatus', + params={'mid': '361481'}, + headers={'lang': 'en', 'appCode': '2', 'auth': 'token'}, + ) + mock_cache.set.assert_called_once_with( + f'{rainpoint.cache_prefix}.statuses:361481', expected, timeout=60 + ) + + +def test_statuses(client, mock_cache, mock_request): + status = { + 'subDeviceStatus': [ + { + 'id': 'D01', + 'time': 1791341339073, + 'value': '10#E10000FD040000FD050000FD060000DC0197DE030000FF0FDB4B8E1A', + }, + { + 'id': 'D02', + 'time': 1791444145150, + 'value': '10#E7B202B602DC01B80585B602FF0F9895901A', + }, + { + 'id': 'D03', + 'time': 1791444360142, + 'value': '10#E73A02BA02DC01B80E85BA028842E9425CFF0F7B96901A', + }, + {'id': 'D04', 'value': None}, + {'id': 'connected', 'value': '1'}, + ], + } + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': status, + } + + assert client.statuses == { + 'rainfall': 0.0, + 'pool_temperature': 20.8, + 'temperature': 21.0, + 'humidity': 66, + } + mock_request.post.assert_called_once_with( + url='https://example.com/auth/basic/app/login', + headers={'lang': 'en', 'appCode': '2'}, + json={ + 'areaCode': '27', + 'phoneOrEmail': 'api@example.com', + 'password': hashlib.md5(b'password').hexdigest(), + 'deviceId': mock_request.post.call_args.kwargs['json']['deviceId'], + }, + ) + mock_request.get.assert_called_once_with( + url='https://example.com/app/device/getDeviceStatus', + params={'mid': '361481'}, + headers={'lang': 'en', 'appCode': '2', 'auth': 'token'}, + ) + mock_request.post.return_value.raise_for_status.assert_called_once_with() + mock_request.get.return_value.raise_for_status.assert_called_once_with() + assert mock_cache.set.call_args.kwargs['timeout'] == 60 + + +@pytest.mark.parametrize('app_code', ['1', '2']) +def test_configured_app_code(client, mock_cache, mock_request, app_code): + client.config['app_code'] = app_code + client = RainPoint('361481') + client._authenticate() + + assert mock_request.post.call_args.kwargs['headers']['appCode'] == app_code + assert client.request_headers == {'lang': 'en', 'appCode': app_code} + + +def test_cached_statuses(client, mock_cache, mock_request): + statuses = {'rainfall': 0.0, 'temperature': 21.0} + mock_cache.get.return_value = statuses + assert client.statuses == statuses + mock_cache.get.assert_called_once_with( + f'{client.cache_prefix}.statuses:361481' + ) + mock_request.get.assert_not_called() + mock_request.post.assert_not_called() + + +def test_cached_authentication_token(client, mock_cache, mock_request): + mock_cache.get.return_value = 'old-token' + assert client._authenticate() == 'old-token' + mock_request.post.assert_not_called() + + +def test_authentication_token_cache_lifetime(client, mock_cache, mock_request): + mock_request.post.return_value.json.return_value = { + 'code': 0, + 'data': {'token': 'new', 'tokenExpired': 3600}, + } + assert client._authenticate() == 'new' + mock_cache.set.assert_called_once_with( + f'{client.cache_prefix}.auth', 'new', timeout=3600 + ) + + +@pytest.mark.parametrize('method', ['GET', 'POST']) +def test_http_error_not_cached( + client, mock_cache, mock_request, mocker, method +): + response = ( + mock_request.get.return_value + if method == 'GET' + else mock_request.post.return_value + ) + response.raise_for_status.side_effect = requests.HTTPError + if method == 'GET': + mocker.patch.object(client, '_authenticate', return_value='token') + with pytest.raises(requests.HTTPError): + if method == 'GET': + _ = client.statuses + else: + client._authenticate() + mock_cache.set.assert_not_called() + + +@pytest.mark.parametrize( + 'model_code, payload, expected', + [ + ( + 87, + '10#E10000FD040000FD050000FD060000DC0197DE030000FF0FDB4B8E1A', + {'rainfall_mm_hour': 0.0}, + ), + ( + 262, + '10#E73A02BA02DC01B80E85BA028842E9425CFF0F7B96901A', + { + 'temperature_c': 21.0, + 'humidity_pct': 66, + }, + ), + ( + 268, + '10#E7B202B602DC01B80585B602FF0F9895901A', + {'temperature_c': 20.8}, + ), + ], +) +def test_decode_sensor_sample(client, model_code, payload, expected): + assert client._decode_sensor(payload, model_code) == expected + + +@pytest.mark.parametrize('model_code', [87, 262, 268, 999]) +def test_decode_sensor_ignores_unused_datapoints(client, model_code): + assert ( + client._decode_sensor('10#DC01E700000000E9000097DE030000', model_code) + == {} + ) + + +@pytest.mark.parametrize('value', [None, '', 'offline']) +def test_decode_sensor_missing(client, value): + assert client._decode_sensor(value, 262) == {} + + +@pytest.mark.parametrize('value', ['10#FD', '10#85BA', '10#not-hex']) +def test_decode_sensor_malformed(client, value): + with pytest.raises(ValueError): + client._decode_sensor(value, 262) + + +def test_decode_sensor_signed_temperature(client): + assert client._decode_sensor('10#85CEFF', 262) == {'temperature_c': -20.6} + + +def test_decode_sensor_extended_stream(client): + assert client._decode_sensor('11#0085BA02', 262) == {'temperature_c': 21.0} + + +def test_custom_cache_timeout(client, mock_cache, mock_request): + client = RainPoint('361481', cache_timeout=30) + assert client.statuses == {} + assert mock_cache.set.call_args.kwargs['timeout'] == 30 + + +@pytest.mark.parametrize('cache_timeout', [None, 0]) +def test_default_cache_timeout(client, mock_cache, mock_request, cache_timeout): + client = RainPoint('361481', cache_timeout=cache_timeout) + cached_statuses = {'temperature': 21.0} + mock_cache.get.return_value = cached_statuses + + assert client.cache_timeout == client.config['cache_timeout'] == 60 + assert client.statuses == cached_statuses + mock_request.get.assert_not_called() + mock_request.post.assert_not_called() + mock_cache.set.assert_not_called() + + +def test_zero_cache_timeout_from_settings(client): + client.config['cache_timeout'] = 0 + assert RainPoint('361481').cache_timeout == 0 + + +@pytest.mark.parametrize('status', [None, {}, {'subDeviceStatus': []}]) +def test_missing_sensor_readings(client, mock_cache, mock_request, status): + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': status, + } + assert client.statuses == {} + + +def test_custom_device_address(client, mock_cache, mock_request, mocker): + mocker.patch.dict( + 'iotserver.apps.device.integrations.rainpoint.READING_MAP', + { + 'temperature_sensor': { + 'address': 7, + 'model_code': 262, + 'fields': {'temperature_c': 'temperature'}, + } + }, + ) + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': [{'id': 'D07', 'value': '10#85BA02'}]}, + } + assert client.statuses == {'temperature': 21.0} + + +@pytest.mark.parametrize( + 'status_id, value, expected', + [ + ('D01', '10#FD040F00', {'rainfall': 1.5}), + ('D03', '10#8540018800', {'temperature': 0.0, 'humidity': 0}), + ], +) +def test_requested_readings( + client, mock_cache, mock_request, mocker, status_id, value, expected +): + mocker.patch.object(client, '_authenticate', return_value='token') + mock_request.get.return_value.json.return_value = { + 'code': 0, + 'data': {'subDeviceStatus': [{'id': status_id, 'value': value}]}, + } + assert client.statuses == expected + + +def test_status_cache_is_scoped_to_hub(client, mock_cache): + mock_cache.get.return_value = {} + _ = client.statuses + first_key = mock_cache.get.call_args.args[0] + client.hub_id = 'other' + _ = client.statuses + second_key = mock_cache.get.call_args.args[0] + assert first_key != second_key diff --git a/iotserver/apps/device/tests/integrations/test_solarman.py b/iotserver/apps/device/tests/integrations/test_solarman.py new file mode 100644 index 0000000..463ff8f --- /dev/null +++ b/iotserver/apps/device/tests/integrations/test_solarman.py @@ -0,0 +1,201 @@ +import hashlib + +import pytest + +from iotserver.apps.device.integrations.solarman import Solarman + + +@pytest.fixture(autouse=True) +def mock_settings(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.solarman.settings') + mock.INTEGRATIONS = { + 'solarman': { + 'base_url': 'https://example.com', + 'email': 'user@example.com', + 'password': 'password', + 'app_id': 'app-id', + 'app_secret': 'app-secret', + 'cache_timeout': 300, + } + } + return mock + + +@pytest.fixture(autouse=True) +def mock_cache(mocker): + """Mock Django cache to return None (cache miss)""" + mock = mocker.patch('iotserver.apps.device.integrations.solarman.cache') + mock.get.return_value = None + return mock + + +@pytest.fixture +def solarman(): + return Solarman(station_id=3208097) + + +@pytest.fixture +def mock_requests(mocker): + auth_response = mocker.Mock() + auth_response.json.return_value = {'access_token': 'access-token'} + real_time_response = mocker.Mock() + + mock = mocker.patch('iotserver.apps.device.integrations.solarman.requests') + mock.post.side_effect = [auth_response, real_time_response] + mock.auth_response = auth_response + mock.real_time_response = real_time_response + return mock + + +class TestSolarmanIntegration(object): + def test_hash_password(self, solarman): + assert ( + solarman._hash_password() == hashlib.sha256(b'password').hexdigest() + ) + + def test_authenticate(self, solarman, mock_requests): + assert solarman._authenticate() == 'access-token' + mock_requests.post.assert_called_once_with( + url='https://example.com/account/v1.0/token?appId=app-id&language=en', + json={ + 'appSecret': 'app-secret', + 'email': 'user@example.com', + 'password': hashlib.sha256(b'password').hexdigest(), + }, + ) + mock_requests.auth_response.raise_for_status.assert_called_once_with() + + def test_authentication_http_error_is_propagated( + self, solarman, mock_requests + ): + mock_requests.auth_response.raise_for_status.side_effect = RuntimeError( + 'authentication failed' + ) + + with pytest.raises(RuntimeError, match='authentication failed'): + solarman._authenticate() + + def test_missing_access_token_is_propagated(self, solarman, mock_requests): + mock_requests.auth_response.json.return_value = {} + + with pytest.raises(KeyError, match='access_token'): + solarman._authenticate() + + def test_real_time(self, solarman, mock_requests, mocker): + authenticate = mocker.patch.object( + solarman, '_authenticate', return_value='access-token' + ) + mock_requests.post.side_effect = None + mock_requests.post.return_value = mock_requests.real_time_response + mock_requests.real_time_response.json.return_value = { + 'generationPower': 1200.0, + 'batterySoc': 71.0, + 'usePower': 932.0, + } + + assert solarman.real_time == { + 'solar_input': 1200.0, + 'battery_soc': 71.0, + 'current_consumption': 932.0, + } + authenticate.assert_called_once_with() + mock_requests.post.assert_called_once_with( + url='https://example.com/station/v1.0/realTime', + json={'stationId': 3208097}, + headers={'Authorization': 'Bearer access-token'}, + ) + mock_requests.real_time_response.raise_for_status.assert_called_once_with() + + def test_real_time_http_error_is_propagated( + self, solarman, mock_requests, mocker + ): + mocker.patch.object( + solarman, '_authenticate', return_value='access-token' + ) + mock_requests.post.side_effect = None + mock_requests.post.return_value = mock_requests.real_time_response + mock_requests.real_time_response.raise_for_status.side_effect = ( + RuntimeError('real time fetch failed') + ) + + with pytest.raises(RuntimeError, match='real time fetch failed'): + _ = solarman.real_time + + def test_real_time_is_cached( + self, solarman, mock_requests, mock_cache, mocker + ): + mocker.patch.object( + solarman, '_authenticate', return_value='access-token' + ) + mock_requests.post.side_effect = None + mock_requests.post.return_value = mock_requests.real_time_response + mock_requests.real_time_response.json.return_value = { + 'generationPower': 1200.0, + 'batterySoc': 71.0, + 'usePower': 932.0, + } + expected = { + 'solar_input': 1200.0, + 'battery_soc': 71.0, + 'current_consumption': 932.0, + } + + assert solarman.real_time == expected + + cache_key = f'{solarman.cache_prefix}.real_time:3208097' + assert solarman.cache_prefix == ( + 'iotserver.apps.device.integrations.solarman.Solarman' + ) + mock_cache.get.assert_called_once_with(cache_key) + mock_cache.set.assert_called_once_with(cache_key, expected, timeout=300) + + def test_real_time_custom_cache_timeout( + self, mock_requests, mock_cache, mocker + ): + solarman = Solarman(station_id=3208097, cache_timeout=30) + mocker.patch.object( + solarman, '_authenticate', return_value='access-token' + ) + mock_requests.post.side_effect = None + mock_requests.post.return_value = mock_requests.real_time_response + mock_requests.real_time_response.json.return_value = { + 'generationPower': 1200.0, + 'batterySoc': 71.0, + 'usePower': 932.0, + } + + _ = solarman.real_time + + assert mock_cache.set.call_args.kwargs['timeout'] == 30 + + def test_real_time_cache_hit( + self, solarman, mock_requests, mock_cache, mocker + ): + authenticate = mocker.patch.object(solarman, '_authenticate') + cached = { + 'solar_input': 1.0, + 'battery_soc': 2.0, + 'current_consumption': 3.0, + } + mock_cache.get.return_value = cached + + assert solarman.real_time == cached + authenticate.assert_not_called() + mock_requests.post.assert_not_called() + mock_cache.set.assert_not_called() + + def test_real_time_http_error_is_not_cached( + self, solarman, mock_requests, mock_cache, mocker + ): + mocker.patch.object( + solarman, '_authenticate', return_value='access-token' + ) + mock_requests.post.side_effect = None + mock_requests.post.return_value = mock_requests.real_time_response + mock_requests.real_time_response.raise_for_status.side_effect = ( + RuntimeError + ) + + with pytest.raises(RuntimeError): + _ = solarman.real_time + mock_cache.set.assert_not_called() diff --git a/iotserver/apps/device/tests/integrations/test_sonoff.py b/iotserver/apps/device/tests/integrations/test_sonoff.py new file mode 100644 index 0000000..4350e5a --- /dev/null +++ b/iotserver/apps/device/tests/integrations/test_sonoff.py @@ -0,0 +1,266 @@ +import base64 +import hashlib +import hmac +import json +from datetime import timedelta + +import pytest +from django.utils import timezone + +from iotserver.apps.device.integrations.sonoff import ( + Sonoff, + SonoffNotAuthorizedError, +) +from iotserver.apps.device.models import SonoffToken + + +@pytest.fixture(autouse=True) +def mock_settings(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.sonoff.settings') + mock.INTEGRATIONS = { + 'sonoff': { + 'app_id': 'app-id', + 'app_secret': 'app-secret', + 'region': 'eu', + 'redirect_url': 'https://example.com/callback/', + 'authorize_url': 'https://example.com/authorize', + 'token_url': 'https://example.com/v2/user/oauth/token', + 'refresh_url': 'https://example.com/v2/user/refresh', + 'device_url': 'https://example.com/v2/device/thing/status', + 'cache_timeout': 60, + } + } + return mock + + +@pytest.fixture +def sonoff(): + return Sonoff(device_id='device-id') + + +@pytest.fixture +def mock_requests(mocker): + response = mocker.Mock() + response.json.return_value = {'error': 0, 'msg': '', 'data': {}} + + mock = mocker.patch('iotserver.apps.device.integrations.sonoff.requests') + mock.post.return_value = response + mock.response = response + return mock + + +@pytest.fixture(autouse=True) +def mock_cache(mocker): + """Mock Django cache to return None (cache miss)""" + mock = mocker.patch('iotserver.apps.device.integrations.sonoff.cache') + mock.get.return_value = None + return mock + + +@pytest.mark.django_db +class TestSonoffIntegration(object): + def test_sign_request(self, sonoff): + data = {'deviceid': 'device-id', 'params': {'switch': 'on'}} + expected = base64.b64encode( + hmac.new( + b'app-secret', json.dumps(data).encode('utf-8'), hashlib.sha256 + ).digest() + ).decode() + + assert sonoff._sign_request(data) == expected + + def test_generate_nonce(self, sonoff, mocker): + choice = mocker.patch( + 'iotserver.apps.device.integrations.sonoff.random.choice', + side_effect='AbCdEfGh', + ) + + assert sonoff._generate_nonce() == 'AbCdEfGh' + assert choice.call_count == 8 + + def test_authorize_url(self, sonoff, mocker): + mocker.patch.object(sonoff, '_generate_nonce', return_value='nonce123') + mocker.patch( + 'iotserver.apps.device.integrations.sonoff.timezone.now', + return_value=timezone.datetime( + 2024, 1, 1, tzinfo=timezone.get_current_timezone() + ), + ) + + url = sonoff.authorize_url('state-123') + + assert url.startswith('https://example.com/authorize?') + assert 'clientId=app-id' in url + assert 'state=state-123' in url + assert 'redirectUrl=' in url + + def test_exchange_code(self, sonoff, mock_requests, mocker): + mocker.patch.object(sonoff, '_generate_nonce', return_value='nonce123') + sign_request = mocker.patch.object( + sonoff, '_sign_request', return_value='signed' + ) + mock_requests.response.json.return_value = { + 'error': 0, + 'msg': '', + 'data': { + 'accessToken': 'access-token', + 'atExpiredTime': 1704110400000, + 'refreshToken': 'refresh-token', + 'rtExpiredTime': 1706788800000, + }, + } + + sonoff.exchange_code('auth-code') + + expected_data = { + 'code': 'auth-code', + 'redirectUrl': 'https://example.com/callback/', + 'grantType': 'authorization_code', + } + sign_request.assert_called_once_with(expected_data) + mock_requests.post.assert_called_once_with( + url='https://example.com/v2/user/oauth/token', + json=expected_data, + headers={ + 'X-CK-Appid': 'app-id', + 'X-CK-Nonce': 'nonce123', + 'Authorization': 'Sign signed', + 'Content-Type': 'application/json', + }, + ) + token = SonoffToken.objects.get() + assert token.access_token == 'access-token' + assert token.refresh_token == 'refresh-token' + assert token.region == 'eu' + + def test_exchange_code_error_response_is_raised( + self, sonoff, mock_requests + ): + mock_requests.response.json.return_value = { + 'error': 10001, + 'msg': 'invalid code', + 'data': {}, + } + + with pytest.raises(RuntimeError, match='invalid code'): + sonoff.exchange_code('auth-code') + + def test_toggle_device_not_authorized(self, sonoff, mock_cache): + mock_cache.get.return_value = 'off' + with pytest.raises(SonoffNotAuthorizedError): + sonoff.toggle_device('on') + + @pytest.mark.parametrize('state', ['on', 'off']) + def test_toggle_device( + self, sonoff, mock_requests, mocker, state, mock_cache + ): + SonoffToken.objects.create( + access_token='access-token', + refresh_token='refresh-token', + access_token_expires_at=timezone.now() + timedelta(days=1), + refresh_token_expires_at=timezone.now() + timedelta(days=30), + region='eu', + ) + mocker.patch.object(sonoff, '_generate_nonce', return_value='nonce123') + mock_cache.get.return_value = 'off' if state == 'on' else 'on' + + assert sonoff.toggle_device(state) == state + mock_requests.post.assert_called_once_with( + url='https://example.com/v2/device/thing/status', + json={ + 'type': 1, + 'id': 'device-id', + 'params': {'switch': state}, + }, + headers={ + 'X-CK-Appid': 'app-id', + 'X-CK-Nonce': 'nonce123', + 'Authorization': 'Bearer access-token', + 'Content-Type': 'application/json', + }, + ) + mock_requests.response.raise_for_status.assert_called_once_with() + + def test_toggle_device_refreshes_expired_token( + self, sonoff, mock_requests, mocker, mock_cache + ): + SonoffToken.objects.create( + access_token='old-access-token', + refresh_token='old-refresh-token', + access_token_expires_at=timezone.now() - timedelta(minutes=1), + refresh_token_expires_at=timezone.now() + timedelta(days=30), + region='eu', + ) + mocker.patch.object(sonoff, '_generate_nonce', return_value='nonce123') + mocker.patch.object(sonoff, '_sign_request', return_value='signed') + mock_cache.get.return_value = 'off' + + refresh_response = mocker.Mock() + refresh_response.json.return_value = { + 'error': 0, + 'msg': '', + 'data': {'at': 'new-access-token', 'rt': 'new-refresh-token'}, + } + device_response = mocker.Mock() + device_response.json.return_value = {'error': 0, 'msg': '', 'data': {}} + mock_requests.post.side_effect = [refresh_response, device_response] + + assert sonoff.toggle_device('on') == 'on' + + token = SonoffToken.objects.get() + assert token.access_token == 'new-access-token' + assert token.refresh_token == 'new-refresh-token' + + device_call = mock_requests.post.call_args_list[1] + assert ( + device_call.kwargs['headers']['Authorization'] + == 'Bearer new-access-token' + ) + + def test_toggle_http_error_is_propagated( + self, sonoff, mock_requests, mock_cache + ): + SonoffToken.objects.create( + access_token='access-token', + refresh_token='refresh-token', + access_token_expires_at=timezone.now() + timedelta(days=1), + refresh_token_expires_at=timezone.now() + timedelta(days=30), + region='eu', + ) + mock_requests.response.raise_for_status.side_effect = RuntimeError( + 'toggle failed' + ) + mock_cache.get.return_value = 'off' + + with pytest.raises(RuntimeError, match='toggle failed'): + sonoff.toggle_device('on') + + def test_toggle_error_response_is_raised( + self, sonoff, mock_requests, mock_cache + ): + SonoffToken.objects.create( + access_token='access-token', + refresh_token='refresh-token', + access_token_expires_at=timezone.now() + timedelta(days=1), + refresh_token_expires_at=timezone.now() + timedelta(days=30), + region='eu', + ) + mock_cache.get.return_value = 'off' + mock_requests.response.json.return_value = { + 'error': 30022, + 'msg': 'device is offline', + 'data': {}, + } + + with pytest.raises(RuntimeError, match='device is offline'): + sonoff.toggle_device('on') + + def test_toggle_cache(self, mocker, sonoff, mock_requests, mock_cache): + mock_cache.get.return_value = 'on' + mocker_get_access_token = mocker.patch( + 'iotserver.apps.device.integrations.sonoff.Sonoff._get_access_token' + ) + + assert sonoff.toggle_device('on') == 'on' + mock_requests.post.assert_not_called() + mocker_get_access_token.assert_not_called() diff --git a/iotserver/apps/device/tests/integrations/test_weather.py b/iotserver/apps/device/tests/integrations/test_weather.py index 7108c62..9bd2c80 100644 --- a/iotserver/apps/device/tests/integrations/test_weather.py +++ b/iotserver/apps/device/tests/integrations/test_weather.py @@ -3,18 +3,39 @@ from iotserver.apps.device.integrations.weather import Location, Weather -@pytest.fixture() -def mock_requests(mocker): - return mocker.patch('iotserver.apps.device.integrations.weather.requests') +@pytest.fixture(autouse=True) +def mock_settings(mocker): + mock = mocker.patch('iotserver.apps.device.integrations.weather.settings') + mock.INTEGRATIONS = { + 'weather': { + 'url': 'https://api.openweathermap.org/data/3.0/onecall', + 'api_key': 'openweather-key', + } + } + return mock @pytest.fixture(autouse=True) -def mock_get_response(mocker, mock_requests): - response = mocker.Mock() - response.json.return_value = [] - response.status_code = 200 - mock_requests.get.return_value = response - return response +def mock_cache(mocker): + """Mock Django cache to return None (cache miss)""" + mock = mocker.patch('iotserver.apps.device.integrations.weather.cache') + mock.get.return_value = None + return mock + + +@pytest.fixture +def mock_requests(mocker): + mock_response = mocker.Mock() + mock_response.json.return_value = [] + mock_response.status_code = 200 + + mock_requests_module = mocker.patch( + 'iotserver.apps.device.integrations.weather.requests' + ) + mock_requests_module.get.return_value = mock_response + mock_requests_module.response = mock_response + + return mock_requests_module @pytest.fixture @@ -56,17 +77,51 @@ class TestWeatherIntegration(object): def test_build_url(self, weather): url = weather._build_url() assert ( - 'https://api.openweathermap.org/data/2.5/onecall?lat=-26.12345&lon=28.54321' + 'https://api.openweathermap.org/data/3.0/onecall?lat=-26.12345&lon=28.54321' in url ) - def test_current_weather(self, mock_get_response, weather, weather_data): - response = mock_get_response - response.json.return_value = weather_data + def test_get_weather_data_from_cache( + self, weather, weather_data, mock_cache, mock_requests + ): + mock_cache.get.return_value = weather_data + + assert weather._get_weather_data() == weather_data + mock_requests.get.assert_not_called() + + def test_get_weather_data_from_api( + self, weather, weather_data, mock_cache, mock_requests + ): + mock_requests.response.json.return_value = weather_data + + assert weather._get_weather_data() == weather_data + mock_requests.get.assert_called_once_with(weather._build_url()) + mock_requests.response.raise_for_status.assert_called_once_with() + mock_cache.set.assert_called_once_with( + f'{weather.cache_prefix}._get_weather_data:{weather.location}', + weather_data, + ) + + def test_empty_weather_data_is_not_cached( + self, weather, mock_cache, mock_requests + ): + assert weather._get_weather_data() is None + mock_cache.set.assert_not_called() + + def test_http_errors_are_propagated(self, weather, mock_requests): + mock_requests.response.raise_for_status.side_effect = RuntimeError( + 'API failure' + ) + + with pytest.raises(RuntimeError, match='API failure'): + weather._get_weather_data() + + def test_current_weather(self, weather, weather_data, mock_requests): + mock_requests.response.json.return_value = weather_data data = weather.current - assert type(data) == dict + assert isinstance(data, dict) for key in ['humidity', 'rain', 'temperature']: assert key in data @@ -74,13 +129,34 @@ def test_current_weather(self, mock_get_response, weather, weather_data): assert data['humidity'] == 50 assert data['temperature'] == 25.12 - def test_forecast_weather(self, mock_get_response, weather, weather_data): - response = mock_get_response - response.json.return_value = weather_data + def test_current_weather_detects_rain( + self, weather, weather_data, mock_requests + ): + weather_data['current']['weather'][0]['main'] = 'Rain' + mock_requests.response.json.return_value = weather_data + + assert weather.current['rain'] + + @pytest.mark.parametrize('current_data', [{}, {'weather': []}]) + def test_malformed_current_weather_returns_none( + self, weather, weather_data, mock_requests, current_data + ): + weather_data['current'] = current_data + mock_requests.response.json.return_value = weather_data + + assert weather.current is None + + def test_current_weather_returns_none_for_empty_response( + self, weather, mock_requests + ): + assert weather.current is None + + def test_forecast_weather(self, weather, weather_data, mock_requests): + mock_requests.response.json.return_value = weather_data data = weather.forecast - assert type(data) == list + assert isinstance(data, list) assert len(data) == 2 for key in ['date', 'humidity', 'rain', 'temperature']: for item in data: @@ -93,3 +169,27 @@ def test_forecast_weather(self, mock_get_response, weather, weather_data): assert data[1]['date'] == '2021-10-06' assert not data[1]['rain'] + + def test_forecast_uses_defaults_for_optional_fields( + self, weather, weather_data, mock_requests + ): + weather_data['daily'] = [ + {'dt': 1633392000, 'temp': {'max': 18.51, 'min': 9.35}} + ] + mock_requests.response.json.return_value = weather_data + + assert weather.forecast[0]['humidity'] is None + assert not weather.forecast[0]['rain'] + + def test_malformed_forecast_returns_none( + self, weather, weather_data, mock_requests + ): + weather_data['daily'][0]['temp'] = {} + mock_requests.response.json.return_value = weather_data + + assert weather.forecast is None + + def test_forecast_returns_none_for_empty_response( + self, weather, mock_requests + ): + assert weather.forecast is None diff --git a/iotserver/apps/device/tests/management/__init__.py b/iotserver/apps/device/tests/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotserver/apps/device/tests/management/commands/__init__.py b/iotserver/apps/device/tests/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotserver/apps/device/tests/management/commands/test_mqtt.py b/iotserver/apps/device/tests/management/commands/test_mqtt.py new file mode 100644 index 0000000..113d370 --- /dev/null +++ b/iotserver/apps/device/tests/management/commands/test_mqtt.py @@ -0,0 +1,111 @@ +import io +import json + +import pytest +from django.core.management.base import OutputWrapper + +from iotserver.apps.device.management.commands.mqtt import Command +from iotserver.apps.device.models import DeviceStatus +from iotserver.apps.device.tests import factories as device_factories + + +@pytest.fixture +def command(): + command = Command() + command.stdout = OutputWrapper(io.StringIO()) + return command + + +@pytest.mark.django_db +class TestHandleStatusQueue: + def test_creates_device_status_for_existing_device(self, command, mocker): + device = device_factories.DeviceFactory() + message = mocker.Mock( + topic=f'iot-devices/{device.id}/status', + payload=json.dumps({'sonoff-switch': True}).encode(), + ) + + command.handle_status_queue(message) + + status = DeviceStatus.objects.get(device=device) + assert status.status == {'sonoff-switch': True} + + def test_ignores_message_for_unknown_device(self, command, mocker): + message = mocker.Mock( + topic='iot-devices/00000000-0000-0000-0000-000000000000/status', + payload=b'{}', + ) + + command.handle_status_queue(message) + + assert DeviceStatus.objects.count() == 0 + assert 'does not exist' in command.stdout._out.getvalue() + + +class TestHandleLogQueue: + def test_writes_log_message_to_stdout(self, command, mocker): + message = mocker.Mock( + topic='iot-devices/device-1/logs', payload=b'Device disconnected' + ) + + command.handle_log_queue(message) + + output = command.stdout._out.getvalue() + assert 'device-1' in output + assert 'Device disconnected' in output + + +class TestMqttOnConnect: + def test_subscribes_to_all_device_topics(self, command, mocker): + client = mocker.Mock() + + command.mqtt_on_connect(client, None, None, None, None) + + client.subscribe.assert_called_once_with('iot-devices/#') + + +class TestMqttOnMessage: + def test_dispatches_status_messages(self, command, mocker): + mock_handle_status = mocker.patch.object(command, 'handle_status_queue') + message = mocker.Mock(topic='iot-devices/device-1/status') + + command.mqtt_on_message(None, None, message) + + mock_handle_status.assert_called_once_with(message) + + def test_dispatches_log_messages(self, command, mocker): + mock_handle_log = mocker.patch.object(command, 'handle_log_queue') + message = mocker.Mock(topic='iot-devices/device-1/logs') + + command.mqtt_on_message(None, None, message) + + mock_handle_log.assert_called_once_with(message) + + def test_ignores_unrelated_topics(self, command, mocker): + mock_handle_status = mocker.patch.object(command, 'handle_status_queue') + mock_handle_log = mocker.patch.object(command, 'handle_log_queue') + message = mocker.Mock(topic='iot-devices/device-1/toggle') + + command.mqtt_on_message(None, None, message) + + mock_handle_status.assert_not_called() + mock_handle_log.assert_not_called() + + +class TestHandle: + def test_connects_and_starts_loop_forever(self, command, mocker): + mock_client_cls = mocker.patch( + 'iotserver.apps.device.management.commands.mqtt.mqtt.Client' + ) + mocker.patch( + 'iotserver.apps.device.management.commands.mqtt.settings.MQTT', + {'host': 'broker.local', 'port': 1883}, + ) + + command.handle() + + client = mock_client_cls.return_value + assert client.on_connect == command.mqtt_on_connect + assert client.on_message == command.mqtt_on_message + client.connect.assert_called_once_with(host='broker.local', port=1883) + client.loop_forever.assert_called_once() diff --git a/iotserver/apps/device/tests/management/commands/test_rules.py b/iotserver/apps/device/tests/management/commands/test_rules.py new file mode 100644 index 0000000..50eb765 --- /dev/null +++ b/iotserver/apps/device/tests/management/commands/test_rules.py @@ -0,0 +1,85 @@ +import pytest + +from iotserver.apps.device.management.commands.rules import Command +from iotserver.apps.device.tests import factories as device_factories + + +@pytest.mark.django_db +class TestReconcileWorkers: + def test_starts_worker_for_new_active_device(self, mocker): + device = device_factories.DeviceFactory( + managed_firmware=False, active=True + ) + mock_thread_cls = mocker.patch('threading.Thread') + command = Command() + workers = {} + + command._reconcile_workers(workers) + + assert device.id in workers + mock_thread_cls.assert_called_once() + _, kwargs = mock_thread_cls.call_args + assert kwargs['args'][0] == device + assert kwargs['daemon'] is True + mock_thread_cls.return_value.start.assert_called_once() + + def test_ignores_managed_or_inactive_devices(self, mocker): + device_factories.DeviceFactory(managed_firmware=True, active=True) + device_factories.DeviceFactory( + managed_firmware=False, + active=False, + ip_address='192.168.0.2', + mac_address='0E:00:20:01:71:AF', + ) + mock_thread_cls = mocker.patch('threading.Thread') + command = Command() + + command._reconcile_workers({}) + + mock_thread_cls.assert_not_called() + + def test_does_not_restart_an_already_running_device(self, mocker): + device_factories.DeviceFactory(managed_firmware=False, active=True) + mock_thread_cls = mocker.patch('threading.Thread') + command = Command() + workers = {} + command._reconcile_workers(workers) + mock_thread_cls.reset_mock() + + command._reconcile_workers(workers) + + mock_thread_cls.assert_not_called() + + def test_stops_worker_for_device_no_longer_matching(self, mocker): + device = device_factories.DeviceFactory( + managed_firmware=False, active=True + ) + mocker.patch('threading.Thread') + command = Command() + workers = {} + command._reconcile_workers(workers) + stop_event, thread = workers[device.id] + + device.active = False + device.save() + command._reconcile_workers(workers) + + assert device.id not in workers + assert stop_event.is_set() + thread.join.assert_called_once() + + +class TestHandle: + def test_polls_until_shutdown_event_is_set(self, mocker): + command = Command() + + def stop_after_first_call(workers): + command.shutdown_event.set() + + mock_reconcile = mocker.patch.object( + command, '_reconcile_workers', side_effect=stop_after_first_call + ) + + command.handle(poll_interval=0) + + mock_reconcile.assert_called_once() diff --git a/iotserver/apps/device/tests/management/commands/test_sonoff_authorize.py b/iotserver/apps/device/tests/management/commands/test_sonoff_authorize.py new file mode 100644 index 0000000..430a3d0 --- /dev/null +++ b/iotserver/apps/device/tests/management/commands/test_sonoff_authorize.py @@ -0,0 +1,26 @@ +import pytest +from django.core.management import call_command + +from iotserver.apps.device.integrations.sonoff import Sonoff + + +@pytest.mark.django_db +class TestSonoffAuthorizeCommand(object): + def test_prints_authorize_url_without_code(self, mocker, capsys): + mocker.patch.object( + Sonoff, + 'authorize_url', + return_value='https://example.com/authorize', + ) + + call_command('sonoff_authorize') + + assert 'https://example.com/authorize' in capsys.readouterr().out + + def test_exchanges_code_when_provided(self, mocker, capsys): + exchange_code = mocker.patch.object(Sonoff, 'exchange_code') + + call_command('sonoff_authorize', '--code', 'auth-code') + + exchange_code.assert_called_once_with('auth-code') + assert 'connected successfully' in capsys.readouterr().out diff --git a/iotserver/apps/device/tests/test_admin.py b/iotserver/apps/device/tests/test_admin.py new file mode 100644 index 0000000..6977875 --- /dev/null +++ b/iotserver/apps/device/tests/test_admin.py @@ -0,0 +1,44 @@ +import pytest +from django.contrib import messages +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import RequestFactory + +from iotserver.apps.device.admin import DeviceModelAdmin +from iotserver.apps.device.exceptions import DeviceUnreachableError +from iotserver.apps.device.models import Device +from iotserver.apps.device.tests import factories as device_factories + + +@pytest.mark.django_db +class TestDeviceModelAdmin(object): + def setup_method(self, test_method): + self.device = device_factories.DeviceFactory() + self.request = RequestFactory().post('/') + self.request.session = {} + self.request._messages = FallbackStorage(self.request) + self.admin = DeviceModelAdmin(Device, None) + + def test_save_model_success(self, mocker): + mock_save_model = mocker.patch( + 'django.contrib.admin.ModelAdmin.save_model' + ) + + self.admin.save_model(self.request, self.device, form=None, change=True) + + mock_save_model.assert_called_once_with( + self.request, self.device, None, True + ) + assert list(messages.get_messages(self.request)) == [] + + def test_save_model_device_unreachable(self, mocker): + mocker.patch( + 'django.contrib.admin.ModelAdmin.save_model', + side_effect=DeviceUnreachableError('Device is unreachable.'), + ) + + self.admin.save_model(self.request, self.device, form=None, change=True) + + stored_messages = list(messages.get_messages(self.request)) + assert len(stored_messages) == 1 + assert stored_messages[0].level == messages.WARNING + assert str(stored_messages[0]) == 'Device is unreachable.' diff --git a/iotserver/apps/device/tests/test_models.py b/iotserver/apps/device/tests/test_models.py index d78fa26..c5982d2 100644 --- a/iotserver/apps/device/tests/test_models.py +++ b/iotserver/apps/device/tests/test_models.py @@ -1,5 +1,9 @@ +import json + import pytest +from iotserver.apps.device import models +from iotserver.apps.device.exceptions import DeviceUnreachableError from iotserver.apps.device.tests import factories as device_factories @@ -14,6 +18,12 @@ def test_str(self): def test_resource_url(self): assert str(self.location.pk) in self.location.resource_url + def test_coordinates(self): + assert self.location.coordinates == { + 'latitude': self.location.position.y, + 'longitude': self.location.position.x, + } + @pytest.mark.django_db class TestDeviceTypeModel(object): @@ -31,6 +41,33 @@ def test_resource_url(self): class TestDeviceModel(object): def setup_method(self, test_method): self.device = device_factories.DeviceFactory() + self.device_pin = device_factories.DevicePinFactory( + devices=[self.device] + ) + self.device_statuses = [ + device_factories.DeviceStatusFactory( + device=self.device, + status={ + 'dht-sensor': {'humidity': 10, 'temperature': 10}, + 'light-sensor': 10, + }, + ), + device_factories.DeviceStatusFactory( + device=self.device, + status={ + 'dht-sensor': {'humidity': 20, 'temperature': 20}, + 'light-sensor': 20, + }, + ), + device_factories.DeviceStatusFactory( + device=self.device, + status={ + 'dht-sensor': {'humidity': 12, 'temperature': 12}, + 'light-sensor': 12, + }, + ), + ] + self.device_status = self.device_statuses[2] def test_str(self): assert str(self.device) == self.device.name @@ -38,6 +75,143 @@ def test_str(self): def test_resource_url(self): assert str(self.device.id) in self.device.resource_url + def test_full_config(self): + assert self.device.full_config == { + **self.device.config, + **{'pins': [self.device_pin.config]}, + } + + def test_last_status(self): + assert self.device.last_status.pk == self.device_status.pk + assert self.device.last_status == self.device_statuses[2] + + def test_aggregate_statuses(self): + assert self.device.aggregate_statuses == { + 'dht-sensor': { + 'humidity': {'minimum': 10, 'maximum': 20, 'average': 14.0}, + 'temperature': {'minimum': 10, 'maximum': 20, 'average': 14.0}, + }, + 'light-sensor': {'minimum': 10, 'maximum': 20, 'average': 14.0}, + } + + def test_mqtt_toggle(self, mocker): + mock_mqtt_toggle = mocker.patch( + 'iotserver.apps.device.models.mqtt.toggle' + ) + mock_mqtt_toggle.return_value = None + self.device.mqtt_toggle('on') + mock_mqtt_toggle.assert_called_once_with(self.device.id, '1') + + def test_handle_device_default_config(self, settings, mocker): + settings.AUTO_SYNC_DEVICE = True + self.device.managed_firmware = True + self.device.active = True + self.device.config = None + + mock_web_socket = mocker.Mock() + mock_get_websocket = mocker.patch( + 'iotserver.apps.device.models.webrepl.get_websocket', + return_value=(mocker.Mock(), mock_web_socket), + ) + + def fake_get_file(web_socket, path, remote_path): + with open(path, 'w') as input_file: + input_file.write(json.dumps({'main': {}, 'pins': []})) + + mocker.patch( + 'iotserver.apps.device.models.webrepl.get_file', + side_effect=fake_get_file, + ) + + models.handle_device_default_config( + sender=models.Device, instance=self.device + ) + + mock_get_websocket.assert_called_once_with( + self.device.ip_address, + settings.WEBREPL_PORT, + settings.WEBREPL_PASSWORD, + ) + assert self.device.config == { + 'main': {'identifier': str(self.device.id)} + } + + def test_handle_device_default_config_unreachable(self, settings, mocker): + settings.AUTO_SYNC_DEVICE = True + self.device.managed_firmware = True + self.device.active = True + self.device.config = None + + mocker.patch( + 'iotserver.apps.device.models.webrepl.get_websocket', + side_effect=OSError, + ) + + with pytest.raises(DeviceUnreachableError): + models.handle_device_default_config( + sender=models.Device, instance=self.device + ) + + def test_handle_device_default_config_unmanaged(self, settings): + settings.AUTO_SYNC_DEVICE = True + self.device.managed_firmware = False + self.device.config = None + + models.handle_device_default_config( + sender=models.Device, instance=self.device + ) + + expected_config = json.loads( + json.dumps(settings.DEVICE_DEFAULT_CONFIG).replace( + '{identifier}', str(self.device.id) + ) + ) + assert self.device.config == expected_config + + def test_handle_device_config_update(self, settings, mocker): + settings.AUTO_SYNC_DEVICE = True + self.device.managed_firmware = True + self.device.config = {'main': {}} + + mock_web_socket = mocker.Mock() + mock_get_websocket = mocker.patch( + 'iotserver.apps.device.models.webrepl.get_websocket', + return_value=(mocker.Mock(), mock_web_socket), + ) + mock_put_file = mocker.patch( + 'iotserver.apps.device.models.webrepl.put_file' + ) + + models.handle_device_config_update( + sender=models.Device, instance=self.device + ) + + mock_get_websocket.assert_called_once_with( + self.device.ip_address, + settings.WEBREPL_PORT, + settings.WEBREPL_PASSWORD, + ) + mock_put_file.assert_called_once_with( + mock_web_socket, + f'/tmp/config.{self.device.id}.json', + 'config/config.json', + ) + + def test_handle_device_config_update_unreachable(self, settings, mocker): + settings.AUTO_SYNC_DEVICE = True + self.device.managed_firmware = True + self.device.config = {'main': {}} + + mocker.patch( + 'iotserver.apps.device.models.webrepl.get_websocket', + side_effect=OSError, + ) + + with pytest.raises(DeviceUnreachableError): + models.handle_device_config_update( + sender=models.Device, instance=self.device + ) + @pytest.mark.django_db class TestDevicePinModel(object): @@ -50,6 +224,18 @@ def test_str(self): def test_resource_url(self): assert str(self.device_pin.id) in self.device_pin.resource_url + def test_config(self): + assert self.device_pin.config == { + 'pin_number': self.device_pin.pin_number, + 'name': self.device_pin.name, + 'identifier': self.device_pin.identifier, + 'interval': self.device_pin.interval, + 'analog': self.device_pin.analog, + 'read': self.device_pin.read, + 'i2c': self.device_pin.i2c, + 'rule': self.device_pin.rule, + } + @pytest.mark.django_db class TestDeviceStatusModel(object): @@ -61,3 +247,18 @@ def test_str(self): def test_resource_url(self): assert str(self.device_status.id) in self.device_status.resource_url + + +@pytest.mark.django_db +class TestDeviceHealthModel(object): + def setup_method(self, test_method): + self.device_health = device_factories.DeviceHealthFactory() + + def test_str(self): + assert str(self.device_health) == self.device_health.device.name + + def test_resource_url(self): + assert str(self.device_health.id) in self.device_health.resource_url + + def test_status(self): + assert self.device_health.status diff --git a/iotserver/apps/device/tests/test_widgets.py b/iotserver/apps/device/tests/test_widgets.py new file mode 100644 index 0000000..6b1ebab --- /dev/null +++ b/iotserver/apps/device/tests/test_widgets.py @@ -0,0 +1,174 @@ +import json + +from iotserver.apps.device.widgets import MASK_VALUE, PrettyJSONWidget + + +class TestPrettyJSONWidget(object): + def setup_method(self, test_method): + self.widget = PrettyJSONWidget() + + # -- format_value ----------------------------------------------------- + + def test_format_value_none(self): + assert self.widget.format_value(None) is None + + def test_format_value_empty_string(self): + assert self.widget.format_value("") is None + + def test_format_value_json_string(self): + value = json.dumps({'b': 1, 'a': 2}) + + result = self.widget.format_value(value) + + assert result == json.dumps({'a': 2, 'b': 1}, indent=4, sort_keys=True) + + def test_format_value_dict(self): + result = self.widget.format_value({'b': 1, 'a': 2}) + + assert result == json.dumps({'a': 2, 'b': 1}, indent=4, sort_keys=True) + + def test_format_value_list(self): + result = self.widget.format_value([3, 1, 2]) + + assert result == json.dumps([3, 1, 2], indent=4, sort_keys=True) + + def test_format_value_invalid_json_string_returned_unchanged(self): + value = '{not valid json' + + assert self.widget.format_value(value) == value + + def test_format_value_non_json_scalar_returned_unchanged(self): + assert self.widget.format_value(123) == 123 + + def test_format_value_masks_top_level_secret_keys(self): + value = { + 'password': 'hunter2', + 'webrepl_password': 'secret', + 'auth_header': 'Bearer xyz', + } + + result = json.loads(self.widget.format_value(value)) + + assert result == { + 'password': MASK_VALUE, + 'webrepl_password': MASK_VALUE, + 'auth_header': MASK_VALUE, + } + + def test_format_value_masks_nested_secret_keys(self): + value = { + 'wifi': {'ssid': 'home', 'password': 'hunter2'}, + 'name': 'device', + } + + result = json.loads(self.widget.format_value(value)) + + assert result == { + 'wifi': {'ssid': 'home', 'password': MASK_VALUE}, + 'name': 'device', + } + + def test_format_value_masks_secret_keys_in_list_of_dicts(self): + value = { + 'endpoints': [ + {'auth_header': 'Bearer xyz'}, + {'auth_header': 'Bearer abc'}, + ] + } + + result = json.loads(self.widget.format_value(value)) + + assert result == { + 'endpoints': [ + {'auth_header': MASK_VALUE}, + {'auth_header': MASK_VALUE}, + ] + } + + def test_format_value_does_not_mask_empty_secret_value(self): + result = json.loads(self.widget.format_value({'password': ''})) + + assert result == {'password': ''} + + def test_format_value_is_case_insensitive_for_secret_keys(self): + result = json.loads(self.widget.format_value({'Password': 'hunter2'})) + + assert result == {'Password': MASK_VALUE} + + # -- render ------------------------------------------------------------- + + def test_render_includes_masked_visible_value(self): + html = self.widget.render('config', {'password': 'hunter2'}) + visible_textarea = html.split('__pretty_json_original')[0] + + assert MASK_VALUE in visible_textarea + assert 'hunter2' not in visible_textarea + + def test_render_includes_hidden_original_value(self): + html = self.widget.render('config', {'password': 'hunter2'}) + + assert 'name="config__pretty_json_original"' in html + assert 'hunter2' in html + + def test_render_hidden_original_empty_for_none_value(self): + html = self.widget.render('config', None) + + assert 'name="config__pretty_json_original" value=""' in html + + # -- value_from_datadict -------------------------------------------- + + def test_value_from_datadict_restores_untouched_masked_value(self): + original = {'ssid': 'home', 'password': 'hunter2'} + data = { + 'config': json.dumps({'ssid': 'home', 'password': MASK_VALUE}), + 'config__pretty_json_original': json.dumps(original), + } + + result = json.loads(self.widget.value_from_datadict(data, {}, 'config')) + + assert result == original + + def test_value_from_datadict_keeps_new_secret_value(self): + original = {'ssid': 'home', 'password': 'hunter2'} + data = { + 'config': json.dumps({'ssid': 'home', 'password': 'new-password'}), + 'config__pretty_json_original': json.dumps(original), + } + + result = json.loads(self.widget.value_from_datadict(data, {}, 'config')) + + assert result == {'ssid': 'home', 'password': 'new-password'} + + def test_value_from_datadict_restores_nested_untouched_masked_value(self): + original = {'wifi': {'ssid': 'home', 'password': 'hunter2'}} + data = { + 'config': json.dumps( + {'wifi': {'ssid': 'home', 'password': MASK_VALUE}} + ), + 'config__pretty_json_original': json.dumps(original), + } + + result = json.loads(self.widget.value_from_datadict(data, {}, 'config')) + + assert result == original + + def test_value_from_datadict_without_original_returns_submitted_unchanged( + self, + ): + data = {'config': json.dumps({'password': MASK_VALUE})} + + result = self.widget.value_from_datadict(data, {}, 'config') + + assert result == data['config'] + + def test_value_from_datadict_non_structured_submission_returned_unchanged( + self, + ): + data = { + 'config': 'not json', + 'config__pretty_json_original': json.dumps({'password': 'hunter2'}), + } + + result = self.widget.value_from_datadict(data, {}, 'config') + + assert result == 'not json' diff --git a/iotserver/apps/device/tests/utils/__init__.py b/iotserver/apps/device/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotserver/apps/device/tests/utils/test_mqtt.py b/iotserver/apps/device/tests/utils/test_mqtt.py new file mode 100644 index 0000000..86addad --- /dev/null +++ b/iotserver/apps/device/tests/utils/test_mqtt.py @@ -0,0 +1,34 @@ +import pytest + +from iotserver.apps.device.utils import mqtt + + +@pytest.fixture +def publish_single(mocker): + return mocker.patch('iotserver.apps.device.utils.mqtt.mqtt_publish.single') + + +def test_toggle_uses_django_settings(mocker, publish_single): + mocker.patch.object(mqtt.settings, 'MQTT', {'host': 'mqtt', 'port': 1883}) + + mqtt.toggle('device-id', 1) + + publish_single.assert_called_once_with( + 'iot-devices/device-id/toggle', + 1, + hostname='mqtt', + port=1883, + retain=True, + ) + + +def test_toggle_uses_supplied_settings(publish_single): + mqtt.toggle('device-id', 0, {'host': 'example.com', 'port': 1884}) + + publish_single.assert_called_once_with( + 'iot-devices/device-id/toggle', + 0, + hostname='example.com', + port=1884, + retain=True, + ) diff --git a/iotserver/apps/device/tests/utils/test_rules.py b/iotserver/apps/device/tests/utils/test_rules.py new file mode 100644 index 0000000..d9a879b --- /dev/null +++ b/iotserver/apps/device/tests/utils/test_rules.py @@ -0,0 +1,721 @@ +import hashlib +import json +import threading + +import pytest + +from iotserver.apps.device.utils import rules + + +class TestFindXpathValue: + def test_returns_top_level_value(self): + assert rules.find_xpath_value({'a': 1}, ['a']) == 1 + + def test_returns_nested_value(self): + # xpaths are passed in already reversed, e.g. 'a.b' -> ['b', 'a'] + assert rules.find_xpath_value({'a': {'b': 2}}, ['b', 'a']) == 2 + + def test_returns_none_for_missing_key(self): + assert rules.find_xpath_value({'a': 1}, ['missing']) is None + + def test_returns_none_when_intermediate_is_not_subscriptable(self): + assert rules.find_xpath_value({'a': 1}, ['b', 'a']) is None + + +class TestEvaluateCondition: + @pytest.mark.parametrize( + 'operator,input,value,expected', + [ + ('eq', True, True, True), + ('eq', True, False, False), + ('gt', 5, 3, True), + ('gt', 2, 3, False), + ('lt', 2, 3, True), + ], + ) + def test_evaluate(self, operator, input, value, expected): + assert rules.evaluate_condition(input, operator, value) is expected + + def test_returns_false_on_type_error(self): + assert rules.evaluate_condition(None, 'gt', 3) is False + + def test_handle_conditions_returns_must_and_should_booleans(self): + rule_values = { + 'timer': True, + 'weather-service-current.rain': False, + 'mqtt-toggle': True, + } + input_value = { + 'conditions': { + 'must': { + 'timer': {'operator': 'eq', 'value': True}, + 'weather-service-current.rain': { + 'operator': 'eq', + 'value': False, + }, + }, + 'should': { + 'mqtt-toggle': {'operator': 'eq', 'value': True}, + }, + } + } + + assert rules.handle_conditions(rule_values, input_value) == { + 'must': [True, True], + 'should': [True], + } + + +class TestValueToBool: + @pytest.mark.parametrize( + 'value,expected', + [ + ('true', True), + ('1', True), + ('yes', True), + ('on', True), + ('false', False), + ('0', False), + (1, True), + (0, False), + (None, False), + (True, True), + ], + ) + def test_value_to_bool(self, value, expected): + assert rules.value_to_bool(value) is expected + + +class TestTimer: + def test_within_range_same_day(self, mocker): + mock_localtime = mocker.patch( + 'iotserver.apps.device.utils.rules.timezone.localtime' + ) + mock_localtime.return_value.strftime.return_value = '1200' + + assert rules.timer(start_time='09:00', end_time='17:00') is True + + def test_outside_range_same_day(self, mocker): + mock_localtime = mocker.patch( + 'iotserver.apps.device.utils.rules.timezone.localtime' + ) + mock_localtime.return_value.strftime.return_value = '2000' + + assert rules.timer(start_time='09:00', end_time='17:00') is False + + def test_within_overnight_range(self, mocker): + mock_localtime = mocker.patch( + 'iotserver.apps.device.utils.rules.timezone.localtime' + ) + mock_localtime.return_value.strftime.return_value = '2300' + + assert rules.timer(start_time='18:00', end_time='05:00') is True + + def test_outside_overnight_range(self, mocker): + mock_localtime = mocker.patch( + 'iotserver.apps.device.utils.rules.timezone.localtime' + ) + mock_localtime.return_value.strftime.return_value = '1200' + + assert rules.timer(start_time='18:00', end_time='05:00') is False + + +class TestService: + def test_returns_json_with_auth_header(self, mocker): + mock_requests = mocker.patch( + 'iotserver.apps.device.utils.rules.requests' + ) + mock_requests.get.return_value.json.return_value = {'temperature': 20} + + result = rules.service(url='http://example.com', auth_header='abc123') + + mock_requests.get.assert_called_once_with( + 'http://example.com', headers={'Authorization': 'Token abc123'} + ) + mock_requests.get.return_value.raise_for_status.assert_called_once() + assert result == {'temperature': 20} + + def test_omits_headers_without_auth_header(self, mocker): + mock_requests = mocker.patch( + 'iotserver.apps.device.utils.rules.requests' + ) + + rules.service(url='http://example.com') + + mock_requests.get.assert_called_once_with( + 'http://example.com', headers=None + ) + + def test_extracts_only_configured_fields(self, mocker): + mock_requests = mocker.patch( + 'iotserver.apps.device.utils.rules.requests' + ) + mock_requests.get.return_value.json.return_value = { + 'last_status': {'status': {'light-sensor': 19.8, 'other': 'x'}} + } + + result = rules.service( + url='http://example.com', + fields=['last_status.status.light-sensor'], + ) + + assert result == {'last_status.status.light-sensor': 19.8} + + def test_extracts_none_for_missing_field(self, mocker): + mock_requests = mocker.patch( + 'iotserver.apps.device.utils.rules.requests' + ) + mock_requests.get.return_value.json.return_value = { + 'last_status': {'status': {}} + } + + result = rules.service( + url='http://example.com', + fields=['last_status.status.light-sensor'], + ) + + assert result == {'last_status.status.light-sensor': None} + + +class TestMqttToggle: + def test_returns_true_for_retained_truthy_value(self): + assert rules.mqtt_toggle(topic='t', mqtt_values={'t': '1'}) is True + + def test_returns_false_for_missing_topic(self): + assert rules.mqtt_toggle(topic='t', mqtt_values={}) is False + + +class TestSonoffToggle: + def test_toggles_on(self, mocker): + mock_sonoff_cls = mocker.patch( + 'iotserver.apps.device.utils.rules.Sonoff' + ) + mock_sonoff_cls.return_value.toggle_device.return_value = 'on' + + result = rules.sonoff_toggle(on=True, device_id='abc123') + + mock_sonoff_cls.assert_called_once_with('abc123', cache_timeout=None) + mock_sonoff_cls.return_value.toggle_device.assert_called_once_with( + state='on' + ) + assert result is True + + def test_toggles_off(self, mocker): + mock_sonoff_cls = mocker.patch( + 'iotserver.apps.device.utils.rules.Sonoff' + ) + mock_sonoff_cls.return_value.toggle_device.return_value = 'off' + + result = rules.sonoff_toggle(on=False, device_id='abc123') + + mock_sonoff_cls.return_value.toggle_device.assert_called_once_with( + state='off' + ) + assert result is False + + def test_custom_cache_timeout(self, mocker): + mock_sonoff_cls = mocker.patch( + 'iotserver.apps.device.utils.rules.Sonoff' + ) + mock_sonoff_cls.return_value.toggle_device.return_value = 'on' + + rules.sonoff_toggle(on=True, device_id='abc123', cache_timeout=30) + + mock_sonoff_cls.assert_called_once_with('abc123', cache_timeout=30) + + +class TestOlarmActivated: + @pytest.mark.parametrize('activated', [True, False]) + def test_returns_activated(self, mocker, activated): + mock_olarm_cls = mocker.patch('iotserver.apps.device.utils.rules.Olarm') + mock_olarm_cls.return_value.activated = activated + + assert rules.olarm_activated() is activated + mock_olarm_cls.assert_called_once_with(cache_timeout=None) + + def test_custom_cache_timeout(self, mocker): + mock_olarm_cls = mocker.patch('iotserver.apps.device.utils.rules.Olarm') + + rules.olarm_activated(cache_timeout=30) + + mock_olarm_cls.assert_called_once_with(cache_timeout=30) + + +class TestSolarmanStatus: + def test_returns_real_time(self, mocker): + real_time = { + 'solar_input': 1200.0, + 'battery_soc': 71.0, + 'current_consumption': 932.0, + } + mock_solarman_cls = mocker.patch( + 'iotserver.apps.device.utils.rules.Solarman' + ) + mock_solarman_cls.return_value.real_time = real_time + + assert rules.solarman_status(station_id=3208097) == real_time + mock_solarman_cls.assert_called_once_with(3208097, cache_timeout=None) + + def test_custom_cache_timeout(self, mocker): + mock_solarman_cls = mocker.patch( + 'iotserver.apps.device.utils.rules.Solarman' + ) + + rules.solarman_status(station_id=3208097, cache_timeout=30) + + mock_solarman_cls.assert_called_once_with(3208097, cache_timeout=30) + + +class TestRainPointStatus: + @pytest.mark.parametrize('cache_timeout', [None, 30, 0]) + def test_returns_statuses(self, mocker, cache_timeout): + statuses = { + 'rainfall': 1.5, + 'temperature': 21.0, + 'humidity': 66, + 'pool_temperature': 20.8, + } + mock_client = mocker.patch( + 'iotserver.apps.device.utils.rules.RainPoint' + ) + mock_client.return_value.statuses = statuses + + rainpoint_status = rules.rainpoint_status( + hub_id='hub-1', cache_timeout=cache_timeout + ) + assert rainpoint_status == statuses + mock_client.assert_called_once_with( + 'hub-1', cache_timeout=cache_timeout + ) + + +def test_rule_actions_dispatch_table(): + assert rules.RULE_ACTIONS == { + 'timer': rules.timer, + 'service': rules.service, + 'mqtt_toggle': rules.mqtt_toggle, + 'sonoff_toggle': rules.sonoff_toggle, + 'olarm_activated': rules.olarm_activated, + 'solarman_status': rules.solarman_status, + 'rainpoint_status': rules.rainpoint_status, + } + + +class TestBuildMqttClient: + @pytest.fixture + def mock_client_cls(self, mocker): + return mocker.patch('iotserver.apps.device.utils.rules.mqtt.Client') + + def test_connects_and_starts_loop_with_default_port(self, mock_client_cls): + client = rules._build_mqtt_client( + {'client_id': 'device-1', 'host': 'broker.local'}, None, None + ) + + mock_client_cls.assert_called_once_with( + rules.mqtt.CallbackAPIVersion.VERSION2, client_id='device-1' + ) + client.connect.assert_called_once_with(host='broker.local', port=1883) + client.loop_start.assert_called_once() + + def test_uses_configured_port(self, mock_client_cls): + client = rules._build_mqtt_client( + {'client_id': 'd', 'host': 'h', 'port': 8883}, None, None + ) + + client.connect.assert_called_once_with(host='h', port=8883) + + def test_sets_credentials_when_configured(self, mock_client_cls): + client = rules._build_mqtt_client( + { + 'client_id': 'd', + 'host': 'h', + 'username': 'user', + 'password': 'pass', + }, + None, + None, + ) + + client.username_pw_set.assert_called_once_with('user', 'pass') + + def test_skips_credentials_when_not_configured(self, mock_client_cls): + client = rules._build_mqtt_client( + {'client_id': 'd', 'host': 'h'}, None, None + ) + + client.username_pw_set.assert_not_called() + + def test_enables_tls_when_configured(self, mock_client_cls): + client = rules._build_mqtt_client( + {'client_id': 'd', 'host': 'h', 'ssl_enabled': True}, None, None + ) + + client.tls_set.assert_called_once() + + def test_sets_lastwill_when_configured(self, mock_client_cls): + client = rules._build_mqtt_client( + { + 'client_id': 'd', + 'host': 'h', + 'lastwill': { + 'topic': 'iot-devices/d/logs', + 'message': 'disconnected', + }, + }, + None, + None, + ) + + client.will_set.assert_called_once_with( + topic='iot-devices/d/logs', payload='disconnected' + ) + + +class TestResolveRuleParams: + def test_passes_through_plain_values(self): + rule = { + 'action': 'timer', + 'input': {'start_time': '18:00', 'end_time': '05:00'}, + } + + assert rules._resolve_rule_params(rule, {}, {}) == { + 'start_time': '18:00', + 'end_time': '05:00', + } + + def test_evaluates_conditions(self): + rule = { + 'action': 'sonoff_toggle', + 'input': { + 'device_id': 'abc123', + 'on': { + 'conditions': { + 'must': {'timer': {'operator': 'eq', 'value': True}}, + 'should': {}, + } + }, + }, + } + + result = rules._resolve_rule_params(rule, {'timer': True}, {}) + + assert result == {'device_id': 'abc123', 'on': True} + + def test_evaluates_should_only_conditions(self): + rule = { + 'action': 'sonoff_toggle', + 'input': { + 'device_id': 'abc123', + 'on': { + 'conditions': { + 'should': { + 'mqtt-toggle': {'operator': 'eq', 'value': True} + } + } + }, + }, + } + + result = rules._resolve_rule_params(rule, {'mqtt-toggle': False}, {}) + + assert result == {'device_id': 'abc123', 'on': False} + + def test_injects_mqtt_values_for_mqtt_toggle_action(self): + rule = { + 'action': 'mqtt_toggle', + 'input': {'topic': 'iot-devices/x/toggle'}, + } + mqtt_values = {'iot-devices/x/toggle': '1'} + + result = rules._resolve_rule_params(rule, {}, mqtt_values) + + assert result == { + 'topic': 'iot-devices/x/toggle', + 'mqtt_values': mqtt_values, + } + + +class TestPublishStatus: + def test_publishes_when_status_has_changed(self, mocker): + client = mocker.Mock() + + status_hash = rules._publish_status(client, 'device-1', {'a': 1}, None) + + payload = json.dumps({'a': 1}, sort_keys=True) + client.publish.assert_called_once_with( + 'iot-devices/device-1/status', payload + ) + assert status_hash == hashlib.sha1(payload.encode()).digest() + + def test_skips_publish_when_status_unchanged(self, mocker): + client = mocker.Mock() + payload = json.dumps({'a': 1}, sort_keys=True) + previous_hash = hashlib.sha1(payload.encode()).digest() + + result_hash = rules._publish_status( + client, 'device-1', {'a': 1}, previous_hash + ) + + client.publish.assert_not_called() + assert result_hash == previous_hash + + +class TestPublishLog: + def test_publishes_when_at_or_above_threshold(self, mocker): + mock_logger = mocker.patch('iotserver.apps.device.utils.rules.logger') + client = mocker.Mock() + + rules._publish_log( + client, 'device-1', {'level': 'warning'}, 'error', 'boom' + ) + + mock_logger.error.assert_called_once_with('boom') + client.publish.assert_called_once_with( + 'iot-devices/device-1/logs', 'boom' + ) + + def test_suppresses_publish_below_threshold(self, mocker): + mock_logger = mocker.patch('iotserver.apps.device.utils.rules.logger') + client = mocker.Mock() + + rules._publish_log( + client, 'device-1', {'level': 'warning'}, 'debug', 'noisy' + ) + + mock_logger.debug.assert_called_once_with('noisy') + client.publish.assert_not_called() + + +class TestRunDevice: + def _device(self, mocker, **config_overrides): + config = { + 'main': {'process_interval': 0}, + 'health': {'url': 'http://example.com/health/'}, + 'mqtt': {'client_id': 'device-1', 'host': 'broker.local'}, + 'logging': {'level': 'warning'}, + 'pins': [], + } + config.update(config_overrides) + return mocker.Mock(id='device-1', full_config=config) + + def test_runs_health_check_dispatches_rule_and_publishes_status( + self, mocker + ): + stop_event = threading.Event() + mock_client = mocker.Mock() + mocker.patch( + 'iotserver.apps.device.utils.rules._build_mqtt_client', + return_value=mock_client, + ) + mock_requests_get = mocker.patch( + 'iotserver.apps.device.utils.rules.requests.get' + ) + mock_action = mocker.Mock(return_value=True) + mocker.patch.dict( + 'iotserver.apps.device.utils.rules.RULE_ACTIONS', + {'timer': mock_action}, + ) + mock_publish_status = mocker.patch( + 'iotserver.apps.device.utils.rules._publish_status', + side_effect=lambda *a, **k: stop_event.set(), + ) + + device = self._device( + mocker, + pins=[ + { + 'identifier': 'day-timer', + 'interval': 1, + 'rule': { + 'action': 'timer', + 'input': {'start_time': '18:00', 'end_time': '05:00'}, + }, + } + ], + ) + + rules.run_device(device, stop_event) + + mock_requests_get.assert_called_once_with( + 'http://example.com/health/', timeout=10 + ) + mock_action.assert_called_once_with( + start_time='18:00', end_time='05:00' + ) + mock_publish_status.assert_called_once() + mock_client.loop_stop.assert_called_once() + mock_client.disconnect.assert_called_once() + + def test_catches_iteration_errors_and_keeps_looping(self, mocker): + stop_event = threading.Event() + mock_client = mocker.Mock() + mocker.patch( + 'iotserver.apps.device.utils.rules._build_mqtt_client', + return_value=mock_client, + ) + mocker.patch( + 'iotserver.apps.device.utils.rules.requests.get', + side_effect=[Exception('boom'), None], + ) + mock_publish_log = mocker.patch( + 'iotserver.apps.device.utils.rules._publish_log' + ) + mocker.patch( + 'iotserver.apps.device.utils.rules._publish_status', + side_effect=lambda *a, **k: stop_event.set(), + ) + + device = self._device(mocker) + + rules.run_device(device, stop_event) + + mock_publish_log.assert_called_once() + mock_client.loop_stop.assert_called_once() + mock_client.disconnect.assert_called_once() + + def test_subscribes_to_mqtt_toggle_topics_on_connect(self, mocker): + stop_event = threading.Event() + mock_client = mocker.Mock() + mock_build_client = mocker.patch( + 'iotserver.apps.device.utils.rules._build_mqtt_client', + return_value=mock_client, + ) + mocker.patch('iotserver.apps.device.utils.rules.requests.get') + mocker.patch( + 'iotserver.apps.device.utils.rules._publish_status', + side_effect=lambda *a, **k: stop_event.set(), + ) + + device = self._device( + mocker, + pins=[ + { + 'identifier': 'mqtt-toggle', + 'interval': 1, + 'rule': { + 'action': 'mqtt_toggle', + 'input': {'topic': 'iot-devices/device-1/toggle'}, + }, + } + ], + ) + + rules.run_device(device, stop_event) + + _, on_connect, _on_message = mock_build_client.call_args[0] + on_connect(mock_client, None, None, None, None) + + mock_client.subscribe.assert_called_once_with( + 'iot-devices/device-1/toggle' + ) + + def test_service_rule_only_stores_configured_fields(self, mocker): + stop_event = threading.Event() + mock_client = mocker.Mock() + mocker.patch( + 'iotserver.apps.device.utils.rules._build_mqtt_client', + return_value=mock_client, + ) + mocker.patch('iotserver.apps.device.utils.rules.requests.get') + mock_publish_status = mocker.patch( + 'iotserver.apps.device.utils.rules._publish_status', + side_effect=lambda *a, **k: stop_event.set(), + ) + # RULE_ACTIONS binds the action functions directly, so patching the + # module-level names doesn't affect dispatch -- patch the dict instead. + mocker.patch.dict( + 'iotserver.apps.device.utils.rules.RULE_ACTIONS', + { + 'service': mocker.Mock( + return_value={'last_status.status.light-sensor': 19.8} + ), + 'sonoff_toggle': mocker.Mock(return_value=False), + }, + ) + + device = self._device( + mocker, + pins=[ + { + 'identifier': 'light-sensor-service', + 'interval': 1, + 'rule': { + 'action': 'service', + 'input': { + 'url': 'http://x/', + 'fields': ['last_status.status.light-sensor'], + }, + }, + }, + { + 'identifier': 'sonoff-switch', + 'interval': 1, + 'rule': { + 'action': 'sonoff_toggle', + 'input': { + 'device_id': 'abc123', + 'on': { + 'conditions': { + 'must': { + 'light-sensor-service.last_status.status.light-sensor': { # noqa: E501 + 'operator': 'lt', + 'value': 20, + } + } + } + }, + }, + }, + }, + ], + ) + + rules.run_device(device, stop_event) + + rule_values = mock_publish_status.call_args[0][2] + assert rule_values == { + 'light-sensor-service.last_status.status.light-sensor': 19.8, + 'sonoff-switch': False, + } + + def test_solarman_status_fields_are_namespaced(self, mocker): + stop_event = threading.Event() + mocker.patch( + 'iotserver.apps.device.utils.rules._build_mqtt_client', + return_value=mocker.Mock(), + ) + mocker.patch('iotserver.apps.device.utils.rules.requests.get') + mock_publish_status = mocker.patch( + 'iotserver.apps.device.utils.rules._publish_status', + side_effect=lambda *a, **k: stop_event.set(), + ) + mocker.patch.dict( + 'iotserver.apps.device.utils.rules.RULE_ACTIONS', + { + 'solarman_status': mocker.Mock( + return_value={'battery_soc': 71.0, 'solar_input': 1200.0} + ), + }, + ) + + device = self._device( + mocker, + pins=[ + { + 'identifier': 'solar', + 'interval': 1, + 'rule': { + 'action': 'solarman_status', + 'input': {'station_id': 3208097}, + }, + } + ], + ) + + rules.run_device(device, stop_event) + + rule_values = mock_publish_status.call_args[0][2] + assert rule_values == { + 'solar.battery_soc': 71.0, + 'solar.solar_input': 1200.0, + } diff --git a/iotserver/apps/device/tests/utils/test_stats.py b/iotserver/apps/device/tests/utils/test_stats.py new file mode 100644 index 0000000..002edbd --- /dev/null +++ b/iotserver/apps/device/tests/utils/test_stats.py @@ -0,0 +1,39 @@ +import pytest + +from iotserver.apps.device.tests import factories as device_factories + + +@pytest.mark.django_db +class TestAggregateStatuses(object): + def setup_method(self, test_method): + self.location = device_factories.DeviceFactory() + + def test_aggregate_statuses(self): + _ = [ + device_factories.DeviceStatusFactory( + device=self.location, + status={ + 'sensor-a': {'reading-a': 30, 'reading-b': 15}, + 'sensor-b': 20, + 'sensor-c': True, + }, + ), + device_factories.DeviceStatusFactory( + device=self.location, + status={ + 'sensor-a': {'reading-a': 30, 'reading-b': 15}, + 'sensor-b': 40, + 'sensor-c': False, + }, + ), + ] + + aggregate = self.location.aggregate_statuses + + assert aggregate == { + 'sensor-a': { + 'reading-a': {'minimum': 30, 'maximum': 30, 'average': 30.0}, + 'reading-b': {'minimum': 15, 'maximum': 15, 'average': 15.0}, + }, + 'sensor-b': {'minimum': 20, 'maximum': 40, 'average': 30.0}, + } diff --git a/iotserver/apps/device/tests/utils/test_webrepl.py b/iotserver/apps/device/tests/utils/test_webrepl.py new file mode 100644 index 0000000..9814547 --- /dev/null +++ b/iotserver/apps/device/tests/utils/test_webrepl.py @@ -0,0 +1,42 @@ +import pytest + +from iotserver.apps.device.utils import webrepl + + +@pytest.mark.parametrize( + ('remote', 'expected'), + [ + ('device.local:config.json', ('device.local', 8266, 'config.json')), + ( + 'device.local:9000:config.json', + ('device.local', 9000, 'config.json'), + ), + ('device.local:', ('device.local', 8266, '/')), + ], +) +def test_parse_remote(remote, expected): + assert webrepl.parse_remote(remote) == expected + + +def test_get_websocket(mocker): + socket_instance = mocker.Mock() + mocker.patch.object(webrepl.socket, 'socket', return_value=socket_instance) + mocker.patch.object( + webrepl.socket, + 'getaddrinfo', + return_value=[(None, None, None, None, ('192.0.2.1', 8266))], + ) + client_handshake = mocker.patch.object(webrepl, 'client_handshake') + login = mocker.patch.object(webrepl, 'login') + get_ver = mocker.patch.object(webrepl, 'get_ver', return_value=(1, 2, 3)) + web_socket = mocker.Mock() + mocker.patch.object(webrepl, 'WebSocket', return_value=web_socket) + + result = webrepl.get_websocket('device.local', 8266, 'password') + + assert result == (socket_instance, web_socket) + socket_instance.connect.assert_called_once_with(('192.0.2.1', 8266)) + client_handshake.assert_called_once_with(socket_instance) + login.assert_called_once_with(web_socket, 'password') + get_ver.assert_called_once_with(web_socket) + web_socket.ioctl.assert_called_once_with(9, 2) diff --git a/iotserver/apps/device/utils/mqtt.py b/iotserver/apps/device/utils/mqtt.py new file mode 100644 index 0000000..183aa71 --- /dev/null +++ b/iotserver/apps/device/utils/mqtt.py @@ -0,0 +1,22 @@ +from typing import Dict, Optional + +import paho.mqtt.publish as mqtt_publish +from django.conf import settings + + +def toggle(device_id: str, state: int, mqtt_settings: Optional[Dict] = None) -> None: + """ + Toggle the device on/off via mqtt if the rule has been applied to one of it's + pins. + """ + topic = f'iot-devices/{device_id}/toggle' + mqtt_settings = mqtt_settings or settings.MQTT + + # Switch to the new toggle state + mqtt_publish.single( + topic, + state, + hostname=mqtt_settings['host'], + port=mqtt_settings['port'], + retain=True, + ) diff --git a/iotserver/apps/device/utils/rules.py b/iotserver/apps/device/utils/rules.py new file mode 100644 index 0000000..c2ea802 --- /dev/null +++ b/iotserver/apps/device/utils/rules.py @@ -0,0 +1,378 @@ +import hashlib +import json +import logging +import threading + +import paho.mqtt.client as mqtt +import requests +from django.utils import timezone + +from iotserver.apps.device.integrations.olarm import Olarm +from iotserver.apps.device.integrations.rainpoint import RainPoint +from iotserver.apps.device.integrations.solarman import Solarman +from iotserver.apps.device.integrations.sonoff import Sonoff + +logger = logging.getLogger(__name__) + +# Ordering (not alphabetical) matches the IoTDevice firmware's LOG_LEVELS so the +# same `logging.level` config value has the same meaning on both sides. +LOG_LEVELS = ['info', 'debug', 'warning', 'error'] + +CONDITION_OPERATORS = { + 'eq': lambda input, value: input == value, + 'gt': lambda input, value: input > value, + 'lt': lambda input, value: input < value, +} + + +def find_xpath_value(response, xpaths): + """ + Recursively finds a value in a nested dict/list structure based on a list of + xpaths. `xpaths` must be reversed before being passed in. + """ + xpath = xpaths.pop() + + try: + response = response[xpath] + except (KeyError, IndexError, TypeError): + return None + + if not xpaths: + return response + + return find_xpath_value(response, xpaths) + + +def evaluate_condition(input, operator, value): + """ + Evaluates a condition and returns a boolean. + """ + try: + return CONDITION_OPERATORS[operator](input, value) + except TypeError: + return False + + +def handle_conditions(rule_values, input_value): + """ + Returns a dict of `must`/`should` condition boolean lists to be + evaluated. `rule_values` is flat, keyed by either a pin identifier or, + for `service` rule fields, the full dotted xpath referenced by the condition. + """ + condition_values = {'must': [], 'should': []} + for condition_type, conditions in input_value['conditions'].items(): + if condition_type in condition_values: + for xpath, condition in conditions.items(): + condition_values[condition_type].append( + evaluate_condition(rule_values.get(xpath), **condition) + ) + + return condition_values + + +def value_to_bool(value): + """ + Converts a string, int, bool, or None to a boolean. + """ + if isinstance(value, str): + return value.strip().lower() in ['true', '1', 'yes', 'on'] + return bool(value) + + +def timer(**kwargs): + """ + Checks if the current local time is within a start/end time range, handling + ranges which span midnight (e.g. 18:00 - 05:00). + """ + now = timezone.localtime().strftime('%H%M') + start = kwargs.get('start_time').replace(':', '') + end = kwargs.get('end_time').replace(':', '') + + if start <= end: + return start <= now <= end + return now >= start or now <= end + + +def service(**kwargs): + """ + Calls a web service and returns its JSON response. If `fields` (a list of + dotted paths) is given, only those values are returned, keyed by their + path, so the whole payload isn't persisted as device status. + """ + url = kwargs.get('url') + auth_header = kwargs.get('auth_header') + fields = kwargs.get('fields') + headers = {'Authorization': f'Token {auth_header}'} if auth_header else None + + response = requests.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + + if not fields: + return payload + + extracted = {} + for field in fields: + xpaths = field.split('.') + xpaths.reverse() + extracted[field] = find_xpath_value(payload, xpaths) + return extracted + + +def mqtt_toggle(**kwargs): + """ + Reads the latest retained MQTT message received for `topic` by the device + worker's MQTT client and returns it as a boolean. + """ + topic = kwargs.get('topic') + mqtt_values = kwargs.get('mqtt_values', {}) + + return value_to_bool(mqtt_values.get(topic)) + + +def sonoff_toggle(**kwargs): + """ + Toggles a Sonoff cloud device on/off via the eWeLink/Coolkit API. + """ + on = kwargs.get('on') + device_id = kwargs.get('device_id') + cache_timeout = kwargs.get('cache_timeout') + + sonoff = Sonoff(device_id, cache_timeout=cache_timeout) + state = sonoff.toggle_device(state='on' if on else 'off') + + return value_to_bool(state) + + +def olarm_activated(**kwargs): + """ + Returns whether the configured Olarm alarm is currently activated (triggered). + """ + cache_timeout = kwargs.get('cache_timeout') + + olarm = Olarm(cache_timeout=cache_timeout) + + return olarm.activated + + +def solarman_status(**kwargs): + """ + Returns the real time solar input, battery SoC and consumption for a + Solarman station. + """ + station_id = kwargs.get('station_id') + cache_timeout = kwargs.get('cache_timeout') + + solarman = Solarman(station_id, cache_timeout=cache_timeout) + + return solarman.real_time + + +def rainpoint_status(**kwargs): + """Return decoded sensor readings for the configured RainPoint hub.""" + hub_id = kwargs.get('hub_id') + cache_timeout = kwargs.get('cache_timeout') + + rainpoint = RainPoint(hub_id, cache_timeout=cache_timeout) + + return rainpoint.statuses + + +# Explicit whitelist of callable rule actions, rather than `getattr` on this +# module, so `rule['action']` (data, not code) can't invoke arbitrary attributes. +RULE_ACTIONS = { + 'timer': timer, + 'service': service, + 'mqtt_toggle': mqtt_toggle, + 'sonoff_toggle': sonoff_toggle, + 'olarm_activated': olarm_activated, + 'solarman_status': solarman_status, + 'rainpoint_status': rainpoint_status, +} + + +def _build_mqtt_client(mqtt_config, on_connect, on_message): + """ + Builds, connects, and starts the network loop for a device's persistent MQTT + client. + """ + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, client_id=mqtt_config['client_id'] + ) + client.on_connect = on_connect + client.on_message = on_message + + username = mqtt_config.get('username') + if username: + client.username_pw_set(username, mqtt_config.get('password')) + + if mqtt_config.get('ssl_enabled'): + client.tls_set() + + lastwill = mqtt_config.get('lastwill') + if lastwill: + client.will_set(topic=lastwill['topic'], payload=lastwill['message']) + + client.connect(host=mqtt_config['host'], port=mqtt_config.get('port', 1883)) + client.loop_start() + + return client + + +def _resolve_rule_params(rule, rule_values, mqtt_values): + """ + Resolves a rule's `input` into keyword arguments, evaluating any must/should + conditions against previously collected rule values. + + Must: + All conditions listed under `must` must be satisfied for the rule to + be considered valid. + Should: + At least one condition listed under `should` should be satisfied for + the rule to be considered valid. + """ + rule_params = {} + for key, value in rule['input'].items(): + if isinstance(value, dict) and 'conditions' in value: + condition_values = handle_conditions(rule_values, value) + must, should = condition_values['must'], condition_values['should'] + rule_params[key] = all( + [ + all(must if must else [True]), + any(should if should else [True]), + ] + ) + else: + rule_params[key] = value + + if rule['action'] == 'mqtt_toggle': + rule_params['mqtt_values'] = mqtt_values + + return rule_params + + +def _publish_status(client, device_id, rule_values, previous_status_hash): + """ + Publishes the current rule values as the device status if they've changed + since the last publish, returning the new dedup hash. + """ + payload = json.dumps(rule_values, sort_keys=True) + status_hash = hashlib.sha1(payload.encode()).digest() + + if status_hash != previous_status_hash: + client.publish(f'iot-devices/{device_id}/status', payload) + + return status_hash + + +def _publish_log(client, device_id, logging_config, level, message): + """ + Logs locally and, if the configured logging level permits it, publishes the + message to the device's log topic for ingestion by the `mqtt` command. + """ + getattr(logger, level, logger.info)(message) + + threshold = logging_config.get('level', 'warning') + if LOG_LEVELS.index(level) >= LOG_LEVELS.index(threshold): + client.publish(f'iot-devices/{device_id}/logs', message) + + +def run_device(device, stop_event: threading.Event) -> None: + """ + Runs the rules engine loop for a single non-managed-firmware device until + `stop_event` is set. Mirrors the IoTDevice firmware's main loop, but drives + the device over HTTP/MQTT (via its own config) instead of local GPIO pins. + """ + config = device.full_config + device_id = str(device.id) + + mqtt_values = {} + mqtt_topics = [ + pin['rule']['input']['topic'] + for pin in config['pins'] + if pin['rule']['action'] == 'mqtt_toggle' + ] + + def on_connect(client, userdata, connect_flags, reason_code, properties): + for topic in mqtt_topics: + client.subscribe(topic) + + def on_message(client, userdata, message): + mqtt_values[message.topic] = message.payload.decode('utf-8') + + client = _build_mqtt_client(config['mqtt'], on_connect, on_message) + + logging_kwargs = { + 'client': client, + 'device_id': device_id, + 'logging_config': config['logging'], + } + + rule_values = {} + previous_status_hash = None + run_count = 0 + + try: + while not stop_event.is_set(): + try: + # Perform a health check for the device before processing rules. + requests.get(config['health']['url'], timeout=10) + + for pin in config['pins']: + # Skip this pin if the current run count does not align with + # its interval. + if run_count % pin.get('interval', 1): + _publish_log( + level='debug', + message=f'Skipping pin {pin["identifier"]} due to interval', + **logging_kwargs, + ) + continue + + # Get the resolved rule parameters for this pin. + rule = pin['rule'] + rule_params = _resolve_rule_params( + rule, rule_values, mqtt_values + ) + action = rule['action'] + + value = RULE_ACTIONS[action](**rule_params) + if action == 'solarman_status' or ( + action == 'service' and rule['input'].get('fields') + ): + # value is keyed by field path; namespace it under the + # pin's identifier to match how conditions reference it. + rule_values.update( + { + f"{pin['identifier']}.{field}": field_value + for field, field_value in value.items() + } + ) + else: + rule_values[pin['identifier']] = value + + _publish_log( + level='debug', + message=( + f'Completed rule: {action} with output: {value}.' + ), + **logging_kwargs, + ) + + previous_status_hash = _publish_status( + client, device_id, rule_values, previous_status_hash + ) + except Exception: + _publish_log( + level='error', + message=f'Error running rules for device {device_id}', + **logging_kwargs, + ) + + # Increment the run count and wait for the next processing interval. + run_count += 1 + stop_event.wait(config['main']['process_interval']) + + finally: + client.loop_stop() + client.disconnect() diff --git a/iotserver/apps/device/utils/stats.py b/iotserver/apps/device/utils/stats.py new file mode 100644 index 0000000..dcb64c9 --- /dev/null +++ b/iotserver/apps/device/utils/stats.py @@ -0,0 +1,52 @@ +from django.utils import timezone + + +def aggregate_statuses(statuses: list[dict]) -> dict: + """ + Returns device stats by minimum, maximum and average values for today. + """ + today = timezone.now().date() + statuses = statuses.filter(created_at__date=today) + + aggregates = {} + values_for_avg = {} + + for status in statuses: + for key, value in status.status.items(): + if isinstance(value, dict): + if key not in aggregates: + aggregates[key] = {} + values_for_avg[key] = {} + for subkey, subvalue in value.items(): + if subkey not in aggregates[key]: + aggregates[key][subkey] = { + 'minimum': subvalue, + 'maximum': subvalue, + } + values_for_avg[key][subkey] = [subvalue] + else: + aggregates[key][subkey]['minimum'] = min( + aggregates[key][subkey]['minimum'], subvalue + ) + aggregates[key][subkey]['maximum'] = max( + aggregates[key][subkey]['maximum'], subvalue + ) + values_for_avg[key][subkey].append(subvalue) + aggregates[key][subkey]['average'] = sum( + values_for_avg[key][subkey] + ) / len(values_for_avg[key][subkey]) + else: + if isinstance(value, bool) or not isinstance(value, (int, float)): + continue + if key not in aggregates: + aggregates[key] = {'minimum': value, 'maximum': value} + values_for_avg[key] = [value] + else: + aggregates[key]['minimum'] = min(aggregates[key]['minimum'], value) + aggregates[key]['maximum'] = max(aggregates[key]['maximum'], value) + values_for_avg[key].append(value) + aggregates[key]['average'] = sum(values_for_avg[key]) / len( + values_for_avg[key] + ) + + return aggregates diff --git a/iotserver/apps/device/utils/webrepl.py b/iotserver/apps/device/utils/webrepl.py index 5943b85..372f978 100755 --- a/iotserver/apps/device/utils/webrepl.py +++ b/iotserver/apps/device/utils/webrepl.py @@ -40,11 +40,18 @@ def client_handshake(sock): class WebSocket: - def __init__(self, _socket): + """ + Simple WebSocket implementation for WebREPL protocol. + """ + + def __init__(self, _socket: socket.socket) -> None: self.socket = _socket self.buffer = b'' - def write(self, data): + def write(self, data: bytes) -> None: + """ + Write data to the WebSocket. + """ length = len(data) if length < 126: hdr = struct.pack('>BB', 0x82, length) @@ -53,17 +60,23 @@ def write(self, data): self.socket.send(hdr) self.socket.send(data) - def recvexactly(self, sz): + def recvexactly(self, size: int) -> bytes: + """ + Receive exact size of bytes from the WebSocket. + """ res = b'' - while sz: - data = self.socket.recv(sz) + while size: + data = self.socket.recv(size) if not data: break res += data - sz -= len(data) + size -= len(data) return res - def read(self, size, text_ok=False): + def read(self, size: int, text_ok: bool = False) -> bytes: + """ + Read data from the WebSocket. + """ if not self.buffer: while True: hdr = self.recvexactly(2) @@ -93,11 +106,17 @@ def read(self, size, text_ok=False): assert len(d) == size, len(d) return d - def ioctl(self, req, val): + def ioctl(self, req: int, val: int) -> None: + """ + Dummy ioctl implementation. + """ assert req == 9 and val == 2 -def login(ws, passwd): +def login(ws: WebSocket, passwd: str) -> None: + """ + Log in to the WebREPL server using the provided password. + """ while True: c = ws.read(1, text_ok=True) if c == b':': @@ -106,27 +125,39 @@ def login(ws, passwd): ws.write(passwd.encode('utf-8') + b'\r') -def read_resp(ws): +def read_resp(ws: WebSocket) -> int: + """ + Read a response from the WebREPL server. + """ data = ws.read(4) sig, code = struct.unpack('<2sH', data) assert sig == b'WB' return code -def send_req(ws, op, sz=0, fname=b''): +def send_req(ws: WebSocket, op: int, sz: int = 0, fname: bytes = b'') -> None: + """ + Send a request to the WebREPL server. + """ rec = struct.pack(WEBREPL_REQ_S, b'WA', op, 0, 0, sz, len(fname), fname) debug_msg('%r %d' % (rec, len(rec))) ws.write(rec) -def get_ver(ws): +def get_ver(ws: WebSocket) -> tuple[int, int, int]: + """ + Get the version of the remote WebREPL. + """ send_req(ws, WEBREPL_GET_VER) - d = ws.read(3) - d = struct.unpack(' None: + """ + Upload a file from the local machine to the remote MicroPython device. + """ sz = os.stat(local_file)[6] dest_fname = remote_file.encode('utf-8') rec = struct.pack( @@ -149,7 +180,10 @@ def put_file(ws, local_file, remote_file): assert read_resp(ws) == 0 -def get_file(ws, local_file, remote_file): +def get_file(ws: WebSocket, local_file: str, remote_file: str) -> None: + """ + Download a file from the remote MicroPython device to the local machine. + """ src_fname = remote_file.encode('utf-8') rec = struct.pack( WEBREPL_REQ_S, b'WA', WEBREPL_GET_FILE, 0, 0, 0, len(src_fname), src_fname @@ -176,7 +210,12 @@ def get_file(ws, local_file, remote_file): assert read_resp(ws) == 0 -def get_websocket(host, port, passwd): +def get_websocket(host: str, port: int, passwd: str) -> tuple[socket.socket, WebSocket]: + """ + Establish a websocket connection to the given host and port using the + provided password. + Returns a tuple containing the underlying socket and the WebSocket object. + """ _socket = socket.socket() address_info = socket.getaddrinfo(host, port) @@ -196,7 +235,10 @@ def get_websocket(host, port, passwd): return _socket, web_socket -def help(rc=0): +def help(rc: int = 0) -> None: + """ + Print help message and exit. + """ exename = sys.argv[0].rsplit('/', 1)[-1] print( '%s - Perform remote file operations using MicroPython WebREPL protocol' @@ -216,12 +258,20 @@ def help(rc=0): sys.exit(rc) -def error(msg): +def error(msg: str) -> None: + """ + Print error message and exit. + """ print(msg) sys.exit(1) -def parse_remote(remote): +def parse_remote(remote: str) -> tuple[str, int, str]: + """ + Parse remote file specification of the form host:port:fname or host:fname. + If port is not specified, default port 8266 is used. + Returns (host, port, fname). + """ host, fname = remote.rsplit(':', 1) if fname == '': fname = '/' @@ -232,7 +282,49 @@ def parse_remote(remote): return (host, port, fname) -def main(): +def main() -> None: + """ + Main function for webrepl CLI utility. + + This command-line interface allows copying files between a local machine and a + MicroPython device using the WebREPL protocol over WiFi. + + Usage: + webrepl_cli.py [-p password] + + Arguments: + -p password: Optional password flag. If not provided, password will be prompted. + source: Either a local file path or remote file in format host:port:path or host:path + destination: Either a local file path or remote file in format host:port:path or host:path + + Remote File Format: + host:remote_file - Uses default port 8266 + host:port:remote_file - Uses custom port + + Operations: + GET: Copy remote file to local file (when source contains ':') + PUT: Copy local file to remote file (when destination contains ':') + + Examples: + # Upload local file to device (using default port 8266) + webrepl_cli.py script.py 192.168.4.1:/another_name.py + + # Upload to remote directory + webrepl_cli.py script.py 192.168.4.1:/app/ + + # Download from device with password + webrepl_cli.py -p mypassword 192.168.4.1:/app/script.py . + + # Use custom port + webrepl_cli.py script.py 192.168.4.1:8080:/config.json + + Raises: + SystemExit: If invalid arguments provided or operations on 2 remote files attempted + + Note: + Exactly one remote file (source or destination) is required. + Operations between two remote files are not supported. + """ if len(sys.argv) not in (3, 5): help(1) diff --git a/iotserver/apps/device/widgets.py b/iotserver/apps/device/widgets.py new file mode 100644 index 0000000..4df4ee6 --- /dev/null +++ b/iotserver/apps/device/widgets.py @@ -0,0 +1,109 @@ +import json + +from django.forms import Textarea +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +MASK_VALUE = '**********' +MASKED_KEYS = frozenset({'webrepl_password', 'password', 'auth_header'}) + + +def _is_masked_key(key): + return isinstance(key, str) and key.lower() in MASKED_KEYS + + +def _mask_secrets(value): + """Recursively replace values of masked keys with MASK_VALUE.""" + if isinstance(value, dict): + return { + key: MASK_VALUE + if _is_masked_key(key) and val not in (None, '') + else _mask_secrets(val) + for key, val in value.items() + } + if isinstance(value, list): + return [_mask_secrets(item) for item in value] + return value + + +def _restore_masked(submitted, original): + """Recursively replace untouched MASK_VALUE placeholders with their original values.""" + if isinstance(submitted, dict): + original_dict = original if isinstance(original, dict) else {} + restored = {} + for key, val in submitted.items(): + if _is_masked_key(key) and val == MASK_VALUE and key in original_dict: + restored[key] = original_dict[key] + else: + restored[key] = _restore_masked(val, original_dict.get(key)) + return restored + if isinstance(submitted, list): + original_list = original if isinstance(original, list) else [] + return [ + _restore_masked( + item, original_list[index] if index < len(original_list) else None + ) + for index, item in enumerate(submitted) + ] + return submitted + + +class PrettyJSONWidget(Textarea): + """A widget that pretty-prints JSON data and masks sensitive keys on display. + + Masked keys (see MASKED_KEYS) are never rendered in plain text. A hidden + companion field carries the real, unmasked value across the request so + that untouched masked fields are restored instead of being saved as the + literal mask placeholder. + """ + + ORIGINAL_FIELD_SUFFIX = '__pretty_json_original' + + def original_field_name(self, name): + return f'{name}{self.ORIGINAL_FIELD_SUFFIX}' + + def format_value(self, value): + if value is None or value == '': + return None + # value may already be a parsed dict/list (e.g. a field default) rather than a string. + if isinstance(value, (dict, list)): + return json.dumps(_mask_secrets(value), indent=4, sort_keys=True) + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return value + if isinstance(parsed, (dict, list)): + parsed = _mask_secrets(parsed) + return json.dumps(parsed, indent=4, sort_keys=True) + + def render(self, name, value, attrs=None, renderer=None): + rendered = super().render(name, value, attrs, renderer) + original = self._to_python(value) + original_json = '' if original is None else json.dumps(original) + hidden_input = format_html( + '', + self.original_field_name(name), + original_json, + ) + return mark_safe(f'{rendered}{hidden_input}') + + def value_from_datadict(self, data, files, name): + submitted = super().value_from_datadict(data, files, name) + original = self._to_python(data.get(self.original_field_name(name))) + submitted_parsed = self._to_python(submitted) + + if original is None or not isinstance(submitted_parsed, (dict, list)): + return submitted + + return json.dumps(_restore_masked(submitted_parsed, original)) + + @staticmethod + def _to_python(value): + if value is None or value == '': + return None + if isinstance(value, (dict, list)): + return value + try: + return json.loads(value) + except (TypeError, ValueError): + return None diff --git a/iotserver/apps/user/api/viewsets.py b/iotserver/apps/user/api/viewsets.py index 0eafc5b..e2f0b1c 100644 --- a/iotserver/apps/user/api/viewsets.py +++ b/iotserver/apps/user/api/viewsets.py @@ -1,5 +1,8 @@ from django.contrib.auth import get_user_model from rest_framework import viewsets +from rest_framework.authtoken import views +from rest_framework.authtoken.models import Token +from rest_framework.response import Response from iotserver.apps.user.api import serializers @@ -7,3 +10,14 @@ class UserViewSet(viewsets.ModelViewSet): queryset = get_user_model().objects.all() serializer_class = serializers.UserSerializer + + +class ObtainAuthTokenUser(views.ObtainAuthToken): + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = serializer.validated_data['user'] + token, _ = Token.objects.get_or_create(user=user) + + return Response({'token': token.key, 'user_id': token.user_id}) diff --git a/iotserver/settings.py b/iotserver/settings.py index 7f91567..00dff9f 100644 --- a/iotserver/settings.py +++ b/iotserver/settings.py @@ -9,7 +9,6 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ @@ -17,11 +16,10 @@ SECRET_KEY = os.environ.get('IOTSERVER_SECRET_KEY', 'insecure-secretkey') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ.get('IOTSERVER_DJANGO_DEBUG', '1') == '1' ALLOWED_HOSTS = ['*'] - # Application definition INSTALLED_APPS = [ 'django.contrib.admin', @@ -33,6 +31,8 @@ 'django.contrib.gis', # 3rd party apps 'corsheaders', + 'django_filters', + 'drf_spectacular', 'mapwidgets', 'rest_framework', 'rest_framework_gis', @@ -73,16 +73,15 @@ WSGI_APPLICATION = 'iotserver.wsgi.application' - # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases DATABASES = { 'default': { 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': 'iotserver', - 'USER': '', - 'PASSWORD': '', - 'HOST': '127.0.0.1', + 'NAME': os.environ.get('IOTSERVER_POSTGRES_DBNAME', 'iotserver'), + 'USER': os.environ.get('IOTSERVER_POSTGRES_USER', ''), + 'PASSWORD': os.environ.get('IOTSERVER_POSTGRES_PASSWORD', ''), + 'HOST': os.environ.get('IOTSERVER_POSTGRES_HOST', 'localhost'), 'PORT': '5432', } } @@ -91,17 +90,18 @@ CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', - 'LOCATION': '127.0.0.1:11211', - 'TIMEOUT': (60 * 15), # 15 mins + 'LOCATION': os.environ.get( + 'IOTSERVER_MEMCACHED_LOCATION', 'localhost:11211' + ), + 'TIMEOUT': (60 * 60), # 1 hour } } - # Password validation # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa: E501 }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', @@ -114,32 +114,56 @@ }, ] -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.TokenAuthentication', - ], - 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.DjangoModelPermissions'], -} - - # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Africa/Johannesburg' USE_I18N = True -USE_L10N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static/') # CORS Setup -CORS_ORIGIN_WHITELIST = [ - os.environ.get('IOTSERVER_CORS_ORIGIN_WHITELIST', 'http://localhost:3000'), -] +CORS_ALLOWED_ORIGINS = os.environ.get( + 'IOTSERVER_CORS_ORIGIN_WHITELIST', 'http://localhost:3000' +).split(',') +CORS_ALLOW_ALL_ORIGINS = ( + os.environ.get('IOTSERVER_CORS_ALLOW_ALL_ORIGINS') == '1' +) + +# Geodjango GDAL library path +GDAL_LIBRARY_PATH = os.environ.get('IOTSERVER_GDAL_LIBRARY_PATH', None) +GEOS_LIBRARY_PATH = os.environ.get('IOTSERVER_GEOS_LIBRARY_PATH', None) + +# Django Rest Framework config +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.DjangoModelPermissions', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 50, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +# Spectacular settings - OpenAPI 3 +SPECTACULAR_SETTINGS = { + 'TITLE': 'IoTServer API', + 'DESCRIPTION': 'Simple IoT Server, Configuration Tool & Dashboard', + 'VERSION': '0.9.0', + 'SERVE_INCLUDE_SCHEMA': False, +} # MQTT settings MQTT = { @@ -149,31 +173,129 @@ # Integration config INTEGRATIONS = { + 'sonoff': { + 'app_id': os.environ.get('IOTSERVER_SONOFF_APP_ID', None), + 'app_secret': os.environ.get('IOTSERVER_SONOFF_APP_SECRET', None), + 'region': os.environ.get('IOTSERVER_SONOFF_REGION', 'eu'), + 'redirect_url': os.environ.get( + 'IOTSERVER_SONOFF_REDIRECT_URL', + 'http://localhost:8000/integrations/sonoff/callback/', + ), + 'authorize_url': os.environ.get( + 'IOTSERVER_SONOFF_AUTHORIZE_URL', + 'https://c2ccdn.coolkit.cc/oauth/index.html', + ), + 'token_url': os.environ.get( + 'IOTSERVER_SONOFF_TOKEN_URL', + 'https://eu-apia.coolkit.cc/v2/user/oauth/token', + ), + 'refresh_url': os.environ.get( + 'IOTSERVER_SONOFF_REFRESH_URL', + 'https://eu-apia.coolkit.cc/v2/user/refresh', + ), + 'device_url': os.environ.get( + 'IOTSERVER_SONOFF_DEVICE_URL', + 'https://eu-apia.coolkit.cc/v2/device/thing/status', + ), + 'cache_timeout': int( + os.environ.get('IOTSERVER_SONOFF_CACHE_TIMEOUT', 60 * 5) + ), + }, 'weather': { 'url': os.environ.get( 'IOTSERVER_OPENWEATHER_URL', - 'https://api.openweathermap.org/data/2.5/onecall', + 'https://api.openweathermap.org/data/3.0/onecall', ), - 'api_key': os.environ.get('IOTSERVER_OPENWEATHER_APIKEY', 'openweather-key'), - } + 'api_key': os.environ.get( + 'IOTSERVER_OPENWEATHER_APIKEY', 'openweather-key' + ), + }, + 'solarman': { + 'base_url': os.environ.get( + 'IOTSERVER_SOLARMAN_BASE_URL', + 'https://globalapi.solarmanpv.com', + ), + 'email': os.environ.get('IOTSERVER_SOLARMAN_EMAIL', None), + 'password': os.environ.get('IOTSERVER_SOLARMAN_PASSWORD', None), + 'app_id': os.environ.get('IOTSERVER_SOLARMAN_APP_ID', None), + 'app_secret': os.environ.get('IOTSERVER_SOLARMAN_APP_SECRET', None), + 'cache_timeout': int( + os.environ.get('IOTSERVER_SOLARMAN_CACHE_TIMEOUT', 60 * 5) + ), + }, + 'rainpoint': { + 'base_url': os.environ.get( + 'IOTSERVER_RAINPOINT_BASE_URL', 'https://region3.homgarus.com' + ), + 'email': os.environ.get('IOTSERVER_RAINPOINT_EMAIL', None), + 'password': os.environ.get('IOTSERVER_RAINPOINT_PASSWORD', None), + 'app_code': os.environ.get('IOTSERVER_RAINPOINT_APP_CODE', '2'), + 'area_code': os.environ.get('IOTSERVER_RAINPOINT_AREA_CODE', '27'), + 'cache_timeout': int( + os.environ.get('IOTSERVER_RAINPOINT_CACHE_TIMEOUT', 60) + ), + }, + 'olarm': { + 'base_url': os.environ.get( + 'IOTSERVER_OLARM_BASE_URL', + 'https://api.olarm.com', + ), + 'api_key': os.environ.get('IOTSERVER_OLARM_API_KEY', None), + 'device_id': os.environ.get('IOTSERVER_OLARM_DEVICE_ID', None), + 'cache_timeout': int( + os.environ.get('IOTSERVER_OLARM_CACHE_TIMEOUT', 60) + ), + }, } # GIS config MAP_WIDGETS = { - 'GooglePointFieldWidget': ( - ('zoom', 15), - ('mapCenterLocation', [-26.190, 28.050]), - ( - 'GooglePlaceAutocompleteOptions', - {'componentRestrictions': {'country': 'za'}}, - ), - ('markerFitZoom', 12), - ), - 'GOOGLE_MAP_API_KEY': os.environ.get( - 'IOTSERVER_GOOGLEMAPS_APIKEY', 'googlemaps-key' - ), + 'GoogleMap': { + 'apiKey': os.environ.get( + 'IOTSERVER_GOOGLEMAPS_APIKEY', 'googlemaps-key' + ), + 'PointField': { + 'interactive': { + 'mapOptions': {'zoom': 15, 'center': [-26.190, 28.050]}, + 'GooglePlaceAutocompleteOptions': { + 'componentRestrictions': {'country': 'za'} + }, + 'markerFitZoom': 12, + } + }, + }, } +# Device syncing +AUTO_SYNC_DEVICE = os.environ.get('IOTSERVER_AUTO_SYNC_DEVICE', '0') == '1' + # Webrepl config WEBREPL_PORT = os.environ.get('IOTSERVER_WEBREPL_PORT', 8266) -WEBREPL_PASSWORD = os.environ.get('IOTSERVER_WEBREPL_PASSWORD', 'webrepl-password') +WEBREPL_PASSWORD = os.environ.get( + 'IOTSERVER_WEBREPL_PASSWORD', 'webrepl-password' +) + +# Default config generated for devices with unmanaged firmware +DEVICE_HEALTH_URL = os.environ.get( + 'IOTSERVER_DEVICE_HEALTH_URL', 'http://localhost:8000/health/{identifier}/' +) +DEVICE_DEFAULT_CONFIG = { + 'mqtt': { + 'client_id': '{identifier}', + 'host': MQTT['host'], + 'username': None, + 'password': None, + 'ssl_enabled': False, + 'lastwill': { + 'topic': 'iot-devices/{identifier}/logs', + 'message': 'Device disconnected from MQTT', + }, + }, + 'logging': {'level': 'warning'}, + 'main': { + 'identifier': '{identifier}', + 'process_interval': 15, + }, + 'health': {'url': DEVICE_HEALTH_URL}, + 'pins': [], +} diff --git a/iotserver/tests/__init__.py b/iotserver/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iotserver/tests/test_views.py b/iotserver/tests/test_views.py new file mode 100644 index 0000000..a0d512e --- /dev/null +++ b/iotserver/tests/test_views.py @@ -0,0 +1,34 @@ +import pytest +from rest_framework.test import APIClient + +from iotserver.apps.device.tests import factories as device_factories + + +@pytest.fixture +def api_client(): + client = APIClient() + return client + + +@pytest.mark.django_db +class TestHealthView(object): + def setup_method(self): + self.root_url = '/health/' + self.device_health = device_factories.DeviceHealthFactory() + + def test_detail__no_device(self, api_client): + response = api_client.get(self.root_url) + return_data = response.json() + + assert response.status_code == 200 + assert return_data['status'] == 'ok' + + def test_detail__device_id(self, api_client): + response = api_client.get(f'{self.root_url}{self.device_health.device_id}/') + + return_data = response.json() + + assert response.status_code == 200 + assert return_data['status'] == 'ok' + + assert self.device_health.updated_at != self.device_health.created_at diff --git a/iotserver/urls.py b/iotserver/urls.py index 9e9467a..4c7a184 100644 --- a/iotserver/urls.py +++ b/iotserver/urls.py @@ -1,7 +1,13 @@ +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) from rest_framework import routers -from rest_framework.authtoken import views from iotserver.apps.device.api import viewsets as device_viewsets from iotserver.apps.user.api import viewsets as user_viewsets @@ -12,6 +18,7 @@ # Device endpoints router.register('devices/locations', device_viewsets.LocationViewSet) +router.register('devices/health', device_viewsets.DeviceHealthViewSet) router.register('devices/types', device_viewsets.DeviceTypeViewSet) router.register('devices/pins', device_viewsets.DevicePinViewSet) router.register('devices/statuses', device_viewsets.DeviceStatusViewSet) @@ -22,6 +29,7 @@ # Health endpoint urlpatterns = [ + path('health//', health), path('health/', health), ] @@ -30,10 +38,23 @@ path('admin/', admin.site.urls), ] - # DRF url patterns urlpatterns += [ - path('api/auth/token', views.obtain_auth_token), + path('api/auth/token/', user_viewsets.ObtainAuthTokenUser.as_view()), path('api/auth/', include('rest_framework.urls')), path('api/', include(router.urls)), ] + +# DRF spectacular schema +urlpatterns += [ + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path( + 'api/schema/ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='ui' + ), + path( + 'api/schema/docs/', SpectacularRedocView.as_view(url_name='schema'), name='docs' + ), +] + +# Static files +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/iotserver/views.py b/iotserver/views.py index 872fd73..db65498 100644 --- a/iotserver/views.py +++ b/iotserver/views.py @@ -3,11 +3,30 @@ authentication_classes, permission_classes, ) +from rest_framework.request import Request from rest_framework.response import Response +from iotserver.apps.device.models import DeviceHealth -@api_view(['GET']) + +@api_view(['GET', 'POST']) @authentication_classes([]) @permission_classes([]) -def health(request): +def health(request: Request, device_pk: int = None): + wifi_signal_strength = request.data.get('wifi_signal_strength') + battery_level = request.data.get('battery_level') + + if device_pk is not None: + device_health, created = DeviceHealth.objects.get_or_create( + device_id=device_pk, + defaults={ + 'wifi_signal_strength': wifi_signal_strength, + 'battery_level': battery_level, + }, + ) + if not created: + device_health.wifi_signal_strength = wifi_signal_strength + device_health.battery_level = battery_level + device_health.save() + return Response({'status': 'ok'}) diff --git a/iotserver/wsgi.py b/iotserver/wsgi.py index 47d6c0b..2b054f5 100644 --- a/iotserver/wsgi.py +++ b/iotserver/wsgi.py @@ -2,7 +2,6 @@ from django.core.wsgi import get_wsgi_application - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotserver.settings') application = get_wsgi_application() diff --git a/manage.py b/manage.py index 5cad988..e581e38 100755 --- a/manage.py +++ b/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/mosquitto.conf.example b/mosquitto.conf.example new file mode 100644 index 0000000..5baec75 --- /dev/null +++ b/mosquitto.conf.example @@ -0,0 +1,6 @@ +persistence true +persistence_location /mosquitto/data/ +log_dest file /mosquitto/log/mosquitto.log +listener 1883 +max_keepalive 0 +allow_anonymous true diff --git a/poetry.lock b/poetry.lock index 224b2d9..d4c0cd4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,122 +1,630 @@ +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. + [[package]] name = "asgiref" -version = "3.5.0" +version = "3.12.1" description = "ASGI specs, helper code, and adapters" -category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094"}, + {file = "asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340"}, +] [package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +mypy = ["mypy (>=1.14.0)"] +tests = ["pytest", "pytest-asyncio"] [[package]] -name = "atomicwrites" -version = "1.4.0" -description = "Atomic file writes." -category = "dev" +name = "asttokens" +version = "3.0.2" +description = "Annotate AST trees with source code positions" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"}, + {file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"}, +] + +[package.extras] +astroid = ["astroid (>=2,<5)"] +test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] [[package]] name = "attrs" -version = "21.4.0" +version = "26.1.0" description = "Classes Without Boilerplate" -category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] [[package]] -name = "black" -version = "22.3.0" -description = "The uncompromising code formatter." -category = "dev" +name = "certifi" +version = "2026.7.22" +description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, +] [[package]] -name = "certifi" -version = "2021.10.8" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "cfgv" -version = "3.3.1" +version = "3.5.0" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] [[package]] name = "charset-normalizer" -version = "2.0.12" +version = "3.5.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false -python-versions = ">=3.5.0" - -[package.extras] -unicode_backport = ["unicodedata2"] +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win32.whl", hash = "sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win_arm64.whl", hash = "sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc"}, + {file = "charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685"}, + {file = "charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef"}, +] [[package]] name = "click" -version = "8.1.2" +version = "8.5.0" description = "Composable command line interface toolkit" -category = "main" optional = false -python-versions = ">=3.7" - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, + {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, +] [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.16.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b"}, + {file = "coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943"}, + {file = "coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7"}, + {file = "coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e"}, + {file = "coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d"}, + {file = "coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878"}, + {file = "coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516"}, + {file = "coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d"}, + {file = "coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a"}, + {file = "coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48"}, + {file = "coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206"}, + {file = "coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a"}, + {file = "coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162"}, + {file = "coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02"}, + {file = "coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9"}, + {file = "coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4"}, + {file = "coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865"}, + {file = "coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6"}, + {file = "coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79"}, + {file = "coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a"}, + {file = "coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6"}, + {file = "coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011"}, + {file = "coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b"}, + {file = "coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb"}, + {file = "coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf"}, + {file = "coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce"}, + {file = "coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46"}, + {file = "coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c"}, + {file = "coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91"}, + {file = "coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8"}, + {file = "coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63"}, + {file = "coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e"}, + {file = "coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae"}, + {file = "coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495"}, + {file = "coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75"}, + {file = "coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c"}, + {file = "coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35"}, + {file = "coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5"}, + {file = "coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8"}, + {file = "coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f"}, + {file = "coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "50.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +files = [ + {file = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93"}, + {file = "cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"}, + {file = "cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e"}, + {file = "cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c"}, + {file = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94"}, + {file = "cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452"}, + {file = "cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] [[package]] name = "distlib" -version = "0.3.4" +version = "0.4.3" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, +] [[package]] name = "django" -version = "4.0.6" +version = "5.2.18" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -category = "main" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c"}, + {file = "django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d"}, +] [package.dependencies] -asgiref = ">=3.4.1,<4" -sqlparse = ">=0.2.2" +asgiref = ">=3.8.1" +sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] @@ -125,709 +633,1257 @@ bcrypt = ["bcrypt"] [[package]] name = "django-cors-headers" -version = "3.12.0" +version = "4.9.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." -category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449"}, + {file = "django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8"}, +] [package.dependencies] -Django = ">=3.2" +asgiref = ">=3.6" +django = ">=4.2" + +[[package]] +name = "django-filter" +version = "26.2" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "django_filter-26.2-py3-none-any.whl", hash = "sha256:df8f737841d6359df00b84dda9b5ab59067fe60292091f1bdae1e3e6281cedb0"}, + {file = "django_filter-26.2.tar.gz", hash = "sha256:fd5cc83995fbe9f5f07fb5dcda16fde0f04de1ecf8ef82628b6c0ec921b751af"}, +] + +[package.dependencies] +Django = ">=5.2" + +[package.extras] +drf = ["djangorestframework"] [[package]] name = "django-map-widgets" -version = "0.3.3" -description = "Map widgets for Django PostGIS fields" -category = "main" +version = "0.6.0" +description = "Configurable and user-friendly map widgets for GeoDjango fields" optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "django_map_widgets-0.6.0-py3-none-any.whl", hash = "sha256:7924e0ed5590b4e11633591bc867c972b6b9126b95dc307ddaff988e2ffea066"}, + {file = "django_map_widgets-0.6.0.tar.gz", hash = "sha256:d6d7f73df8470b3e7a321614c241c808f1b33e04a5aa328c789efbc385b112e0"}, +] [[package]] name = "djangorestframework" -version = "3.13.1" +version = "3.18.3" description = "Web APIs for Django, made easy." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12"}, + {file = "djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030"}, +] [package.dependencies] -django = ">=2.2" -pytz = "*" +django = ">=5.2" [[package]] name = "djangorestframework-gis" -version = "0.18" +version = "1.3.0" description = "Geographic add-ons for Django Rest Framework" -category = "main" optional = false python-versions = "*" +groups = ["main"] +files = [ + {file = "djangorestframework_gis-1.3.0-py2.py3-none-any.whl", hash = "sha256:6e39a16f17c62cb4f4e2a6e5c0821fe257a6bf458c48933e07dbc442262ee500"}, + {file = "djangorestframework_gis-1.3.0.tar.gz", hash = "sha256:f9ab54cf2f737f798fccd1f3b0501377f1053699ca421154cc3fb3edc4856366"}, +] + +[package.dependencies] +django = ">=4.2" +django-filter = ">=23.5,<27.0" +djangorestframework = ">=3.12,<3.19" + +[[package]] +name = "drf-spectacular" +version = "0.30.0" +description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff"}, + {file = "drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9"}, +] [package.dependencies] -djangorestframework = "*" +Django = ">=2.2" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +jsonschema = ">=2.6.0" +PyYAML = ">=5.1" +uritemplate = ">=2.0.0" + +[package.extras] +offline = ["drf-spectacular-sidecar"] +sidecar = ["drf-spectacular-sidecar"] + +[[package]] +name = "executing" +version = "2.2.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] [[package]] name = "factory-boy" -version = "3.2.1" +version = "3.3.3" description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc"}, + {file = "factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03"}, +] [package.dependencies] Faker = ">=0.7.0" [package.extras] -dev = ["coverage", "django", "flake8", "isort", "pillow", "sqlalchemy", "mongoengine", "wheel (>=0.32.0)", "tox", "zest.releaser"] -doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] +dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "mongomock", "mypy", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "13.4.0" +version = "40.41.0" description = "Faker is a Python package that generates fake data for you." -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "faker-40.41.0-py3-none-any.whl", hash = "sha256:35a7f66282990698c20c3e6dd57180900206b760c2f777112c085cc217e7de92"}, + {file = "faker-40.41.0.tar.gz", hash = "sha256:5383e4ebba7061491530ae5952a33dcf8f161a250c89717fa0ac856602dcfaf3"}, +] [package.dependencies] -python-dateutil = ">=2.4" +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +image = ["pillow"] +tzdata = ["tzdata"] [[package]] name = "filelock" -version = "3.6.0" +version = "4.0.12" description = "A platform independent file lock." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8"}, + {file = "filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c"}, +] + +[[package]] +name = "gunicorn" +version = "26.2.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3"}, + {file = "gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447"}, +] [package.extras] -docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +fast = ["gunicorn_h1c (>=0.6.9)"] +gevent = ["gevent (>=24.10.1)", "packaging"] +http2 = ["h2 (>=4.4.1)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.4.1)", "httpx[http2] (>=0.23.0)", "inotify (>=0.2.10) ; sys_platform == \"linux\"", "packaging", "pytest (>=9.0.3)", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] +tornado = ["tornado (>=6.5.7)"] [[package]] name = "identify" -version = "2.5.0" +version = "2.6.20" description = "File identification library for Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa"}, + {file = "identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285"}, +] [package.extras] license = ["ukkonen"] [[package]] name = "idna" -version = "3.3" +version = "3.20" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, +] + +[package.extras] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" optional = false python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] [[package]] name = "iniconfig" +version = "2.3.1" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"}, + {file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"}, +] + +[[package]] +name = "ipython" +version = "9.17.1" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "ipython-9.17.1-py3-none-any.whl", hash = "sha256:6d1645743cfd1a07eb695d85aa2b5fa66721f8cbae9431d4049f7084bbf06509"}, + {file = "ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.4", markers = "sys_platform == \"win32\""} +ipython-pygments-lexers = ">=1.0.0" +jedi = ">=0.18.2" +matplotlib-inline = ">=0.1.6" +pexpect = {version = ">4.6", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt_toolkit = ">=3.0.41,<3.1.0" +psutil = {version = ">=7", markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""} +pygments = ">=2.14.0" +stack_data = ">=0.6.0" +traitlets = ">=5.13.0" + +[package.extras] +all = ["argcomplete (>=3.0)", "ipython[doc,matplotlib,test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[matplotlib,test]", "setuptools (>=80.0)", "sphinx (>=8.0)", "sphinx-rtd-theme (>=0.1.8)", "sphinx_toml (==0.0.4)", "typing_extensions"] +matplotlib = ["matplotlib (>3.9)"] +test = ["packaging (>=23.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=1.0.0)", "setuptools (>=80.0)", "testpath (>=0.2)"] +test-extra = ["curio", "ipykernel (>6.30)", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=2.0)", "pandas (>2.1)", "trio (>=0.22.0)"] + +[[package]] +name = "ipython-pygments-lexers" version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" +description = "Defines a variety of Pygments lexers for highlighting IPython code." optional = false -python-versions = "*" +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, + {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, +] + +[package.dependencies] +pygments = "*" [[package]] -name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" +name = "jedi" +version = "0.20.0" +description = "An autocompletion tool for Python that can be used for text editors." optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"}, + {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"}, +] + +[package.dependencies] +parso = ">=0.8.6,<0.9.0" + +[package.extras] +dev = ["Django", "attrs", "colorama", "docopt", "flake8 (==7.1.2)", "pytest (<9.0.0)", "types-setuptools (==80.9.0.20250529)", "typing-extensions", "zuban (==0.7.0)"] +docs = ["Jinja2 (==3.1.6)", "MarkupSafe (==3.0.3)", "Pygments (==2.20.0)", "Sphinx (==9.1.0)", "alabaster (==1.0.0)", "babel (==2.18.0)", "certifi (==2026.4.22)", "charset-normalizer (==3.4.7)", "docutils (==0.22.4)", "idna (==3.13)", "imagesize (==2.0.0)", "iniconfig (==2.3.0)", "packaging (==26.2)", "pluggy (==1.6.0)", "pytest (==9.0.3)", "requests (==2.33.1)", "roman-numerals (==4.1.0)", "snowballstemmer (==3.0.1)", "sphinx-rtd-theme (==3.1.0)", "sphinxcontrib-applehelp (==2.0.0)", "sphinxcontrib-devhelp (==2.0.0)", "sphinxcontrib-htmlhelp (==2.1.0)", "sphinxcontrib-jquery (==4.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==2.0.0)", "sphinxcontrib-serializinghtml (==2.0.0)", "urllib3 (==2.6.3)"] + +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"}, + {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"}, +] + +[package.dependencies] +traitlets = "*" + +[package.extras] +test = ["flake8", "matplotlib", "nbdime", "nbval", "notebook", "pytest"] [[package]] name = "nodeenv" -version = "1.6.0" +version = "1.11.0" description = "Node.js virtual environment builder" -category = "dev" optional = false -python-versions = "*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25"}, + {file = "nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146"}, +] + +[package.extras] +certifi = ["certifi"] [[package]] name = "packaging" -version = "21.3" +version = "26.3" description = "Core utilities for Python packages" -category = "dev" optional = false -python-versions = ">=3.6" - -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] [[package]] name = "paho-mqtt" -version = "1.6.1" +version = "2.1.0" description = "MQTT version 5.0/3.1.1 client class" -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] [package.extras] proxy = ["pysocks"] [[package]] -name = "pathspec" -version = "0.9.0" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" +name = "parso" +version = "0.8.7" +description = "A Python Parser" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"}, + {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "types-setuptools (==67.2.0.1)", "zuban (==0.5.1)"] +testing = ["docopt", "pytest"] [[package]] -name = "platformdirs" -version = "2.5.2" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." optional = false -python-versions = ">=3.7" +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] -[package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "platformdirs" +version = "4.12.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"}, + {file = "platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250"}, +] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.18.1" +version = "4.6.2" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e"}, + {file = "pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441"}, +] [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" nodeenv = ">=0.11.1" pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" +virtualenv = ">=20.10.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2"}, + {file = "prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6"}, +] + +[package.dependencies] +wcwidth = ">=0.1.4" [[package]] -name = "psycopg2-binary" -version = "2.9.3" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" +name = "psutil" +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." optional = false python-versions = ">=3.6" +groups = ["dev"] +markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\"" +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] [[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" +name = "psycopg" +version = "3.3.6" +description = "PostgreSQL database adapter for Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631"}, + {file = "psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2"}, +] + +[package.dependencies] +psycopg-binary = {version = "3.3.6", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.3.6) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.3.6) ; implementation_name != \"pypy\""] +dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.21)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg (>=0.0.3)", "isort[colors] (>=6.0)", "mypy (>=2.1.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] +docs = ["Sphinx (>=9.1)", "furo (==2025.12.19)", "sphinx-autobuild (>=2025.8.25)", "sphinx-autodoc-typehints (>=3.10.2)"] +pool = ["psycopg-pool"] +test = ["anyio (>=4.0)", "mypy (>=2.1.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] -name = "pymemcache" -version = "3.5.2" -description = "A comprehensive, fast, pure Python memcached client" -category = "main" +name = "psycopg-binary" +version = "3.3.6" +description = "PostgreSQL database adapter for Python -- C optimisation distribution" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"pypy\"" +files = [ + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b"}, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.4" +description = "Safely evaluate AST nodes without side effects" optional = false python-versions = "*" +groups = ["dev"] +files = [ + {file = "pure_eval-0.2.4-py3-none-any.whl", hash = "sha256:96cae060a313cfaad51bb761278bfb0e62dc0248d9315a81173752dc546cd37a"}, + {file = "pure_eval-0.2.4.tar.gz", hash = "sha256:260c2774686e651b79f8b8e7fc9d80b3599ea6a66334b47d5f4abb69fc2c0ea1"}, +] -[package.dependencies] -six = "*" +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] [[package]] -name = "pyparsing" -version = "3.0.8" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" +name = "pygments" +version = "2.21.0" +description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.6.8" +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, +] [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymemcache" +version = "4.0.0" +description = "A comprehensive, fast, pure Python memcached client" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab"}, + {file = "pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94"}, +] [[package]] name = "pytest" -version = "7.1.2" +version = "9.1.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, +] [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-django" -version = "4.5.2" +version = "4.14.0" description = "A Django plugin for pytest." -category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187"}, + {file = "pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef"}, +] [package.dependencies] -pytest = ">=5.4.0" +pytest = ">=7.0.0" [package.extras] +django = ["django (>=5.2)"] docs = ["sphinx", "sphinx-rtd-theme"] -testing = ["django", "django-configurations (>=2.0)"] [[package]] name = "pytest-mock" -version = "3.7.0" +version = "3.16.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8"}, + {file = "pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636"}, +] [package.dependencies] -pytest = ">=5.0" +pytest = ">=6.2.5" [package.extras] -dev = ["pre-commit", "tox", "pytest-asyncio"] +dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -category = "dev" +name = "python-discovery" +version = "1.6.1" +description = "Python interpreter discovery" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3"}, + {file = "python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e"}, +] [package.dependencies] -six = ">=1.5" +filelock = ">=3.15.4" [[package]] name = "python-dotenv" -version = "0.20.0" +version = "1.2.4" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc"}, + {file = "python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0"}, +] [package.extras] cli = ["click (>=5.0)"] [[package]] -name = "pytz" -version = "2022.1" -description = "World timezone definitions, modern and historical" -category = "main" +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" optional = false -python-versions = "*" +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] [[package]] -name = "pyyaml" -version = "6.0" -description = "YAML parser and emitter for Python" -category = "dev" +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" [[package]] name = "requests" -version = "2.27.1" +version = "2.34.2" description = "Python HTTP for Humans." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] [package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} -urllib3 = ">=1.21.1,<1.27" +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "main" +name = "rpds-py" +version = "2026.9.1" +description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2"}, + {file = "rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e"}, + {file = "rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391"}, + {file = "rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f"}, + {file = "rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12"}, + {file = "rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af"}, + {file = "rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e"}, + {file = "rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc"}, + {file = "rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f"}, + {file = "rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02"}, + {file = "rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9"}, + {file = "rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33"}, + {file = "rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36"}, + {file = "rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9"}, + {file = "rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f"}, + {file = "rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b"}, + {file = "rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35"}, + {file = "rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785"}, + {file = "rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c"}, + {file = "rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569"}, + {file = "rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e"}, + {file = "rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9"}, + {file = "rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297"}, + {file = "rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6"}, + {file = "rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15"}, + {file = "rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b"}, + {file = "rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32"}, + {file = "rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0"}, + {file = "rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37"}, + {file = "rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12"}, +] + +[[package]] +name = "ruff" +version = "0.16.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, + {file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, + {file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, + {file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, + {file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, + {file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, + {file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, +] [[package]] name = "sqlparse" -version = "0.4.2" +version = "0.6.0" description = "A non-validating SQL parser." -category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f"}, + {file = "sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9"}, +] + +[package.extras] +dev = ["build"] +doc = ["furo", "sphinx"] [[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "*" +groups = ["dev"] +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" +name = "traitlets" +version = "5.16.1" +description = "Traitlets Python configuration system" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b"}, + {file = "traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3) ; python_version < \"3.12\"", "argcomplete (>=3.5.2) ; python_version >= \"3.12\"", "mypy (>=2.0) ; implementation_name != \"pypy\"", "pre-commit", "pytest (>=7.0,<10.0)", "pytest-mock", "pytest-mypy-testing ; implementation_name != \"pypy\""] [[package]] name = "tzdata" -version = "2022.1" +version = "2026.5" description = "Provider of IANA time zone data" -category = "main" optional = false python-versions = ">=2" +groups = ["main", "dev"] +files = [ + {file = "tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac"}, + {file = "tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7"}, +] +markers = {main = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\""} + +[[package]] +name = "uritemplate" +version = "4.2.0" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, + {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, +] [[package]] name = "urllib3" -version = "1.26.9" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, +] [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "20.14.1" +version = "21.14.5" description = "Virtual Python Environment builder" -category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da"}, + {file = "virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647"}, +] [package.dependencies] -distlib = ">=0.3.1,<1" -filelock = ">=3.2,<4" -platformdirs = ">=2,<3" -six = ">=1.9.0,<2" +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<5", markers = "python_version >= \"3.10\""} +packaging = ">=23.1" +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.6" -[package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +[[package]] +name = "wcwidth" +version = "0.9.2" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07"}, + {file = "wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04"}, + {file = "wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4"}, + {file = "wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec"}, + {file = "wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa"}, + {file = "wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7"}, + {file = "wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14"}, + {file = "wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724"}, + {file = "wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8"}, + {file = "wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e"}, + {file = "wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b"}, +] [metadata] -lock-version = "1.1" -python-versions = "^3.10" -content-hash = "81e88adc7290a1519c00e16143814ab357fafb4c9b883df8e22bb879b7914f93" - -[metadata.files] -asgiref = [ - {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, - {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, -] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] -attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, -] -black = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, -] -certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, -] -cfgv = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, -] -charset-normalizer = [ - {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, - {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, -] -click = [ - {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, - {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, -] -colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] -distlib = [ - {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, - {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, -] -django = [] -django-cors-headers = [ - {file = "django-cors-headers-3.12.0.tar.gz", hash = "sha256:5f07e2ff8a95c887698e748588a4a0b2ad0ad1b5a292e2d33132f1253e2a97cb"}, - {file = "django_cors_headers-3.12.0-py3-none-any.whl", hash = "sha256:39d1d5acb872c1860ecfd88b8572bfbb3a1f201b5685ede951d71fc57c7dfae5"}, -] -django-map-widgets = [ - {file = "django-map-widgets-0.3.3.tar.gz", hash = "sha256:d85db880417c978579ef236595420383f444266813c5fa12cce6d5be5a332788"}, - {file = "django_map_widgets-0.3.3-py3-none-any.whl", hash = "sha256:2f514f4faff4b0fac8da085e0a8724f60d8ac06757e7b51194d5606c1392b838"}, -] -djangorestframework = [ - {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, - {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, -] -djangorestframework-gis = [ - {file = "djangorestframework-gis-0.18.tar.gz", hash = "sha256:6ddd201a208e68f8f11ac71d2df2c030b1280818a4b54681be00e73057fcafb1"}, - {file = "djangorestframework_gis-0.18-py2.py3-none-any.whl", hash = "sha256:7df5d5a32c3492d55837e2a695ef8f9a5c83dc163d90a0764eaa187091a9a633"}, -] -factory-boy = [ - {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, - {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, -] -faker = [ - {file = "Faker-13.4.0-py3-none-any.whl", hash = "sha256:2d4139254c1cc69f2b5203f4a9aaab33a9e65f7994059856300ef6e21ac93f39"}, - {file = "Faker-13.4.0.tar.gz", hash = "sha256:2a9716f2c409224121144f0d2fdc7105e978e2e266deb4d8216061d9668951fc"}, -] -filelock = [ - {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, - {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, -] -identify = [ - {file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"}, - {file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"}, -] -idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -mypy-extensions = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] -nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, -] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] -paho-mqtt = [ - {file = "paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f"}, -] -pathspec = [ - {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, - {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, -] -platformdirs = [ - {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, - {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -pre-commit = [ - {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, - {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, -] -psycopg2-binary = [ - {file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-win32.whl", hash = "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029"}, - {file = "psycopg2_binary-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b"}, - {file = "psycopg2_binary-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba"}, - {file = "psycopg2_binary-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-win32.whl", hash = "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce"}, - {file = "psycopg2_binary-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-win32.whl", hash = "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d"}, - {file = "psycopg2_binary-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] -pymemcache = [ - {file = "pymemcache-3.5.2-py2.py3-none-any.whl", hash = "sha256:3fca0215845d7b2ecd5f4c627fcf4ce2345a703a897b7e116380115b5a197be2"}, - {file = "pymemcache-3.5.2.tar.gz", hash = "sha256:8923ab59840f0d5338f1c52dba229fa835545b91c3c2f691c118e678d0fb974e"}, -] -pyparsing = [ - {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, - {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, -] -pytest = [ - {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, - {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, -] -pytest-django = [ - {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, - {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, -] -pytest-mock = [ - {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, - {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] -python-dotenv = [ - {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"}, - {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"}, -] -pytz = [ - {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, - {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, -] -pyyaml = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, -] -requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -sqlparse = [ - {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, - {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tzdata = [ - {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, - {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, -] -urllib3 = [ - {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, - {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, -] -virtualenv = [ - {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, - {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, -] +lock-version = "2.1" +python-versions = ">=3.13,<4.0" +content-hash = "01d720fdfce8827e64af3600ec62c77f72253e992119b38435cd34c42d9e448d" diff --git a/pyproject.toml b/pyproject.toml index fdcde42..993c95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,41 +1,85 @@ -[tool.black] -target-version = ['py310'] -include = '\.pyi?$' -skip-string-normalization = true -exclude = ''' -/( - \.git -)/ -''' - -[tool.poetry] +[project] name = "iotserver" -version = "0.9.0" description = "Simple IoT Server, Configuration Tool & Dashboard" -authors = ["Jonathan Bydendyk "] -license = "BSD" - -[tool.poetry.dependencies] -python = "^3.10" -Django = "^4.0.4" -click = "^8.1.2" -django-map-widgets = "^0.3.3" -djangorestframework = "^3.13.1" -djangorestframework-gis = "^0.18" -paho-mqtt = "^1.6.1" -requests = "^2.27.1" -python-dotenv = "^0.20.0" -pymemcache = "^3.5.2" -psycopg2-binary = "^2.9.3" -django-cors-headers = "^3.12.0" - -[tool.poetry.dev-dependencies] -pytest = "^7.1.2" -pytest-django = "^4.5.2" -black = "^22.3.0" -factory-boy = "^3.2.1" -pytest-mock = "^3.7.0" -pre-commit = "^2.18.1" +license = "BSD-3-Clause" +version = "1.0.0" +authors = [ + {name = "Jonathan Bydendyk",email = "jonathan@hacklab.co.za"}, +] +requires-python = '>=3.13,<4.0' +dependencies = [ + 'Django>=5.2,<6.0', + 'click>=8.1.2,<9.0.0', + 'django-map-widgets>=0.6.0,<0.7.0', + 'djangorestframework>=3.18.1,<4.0.0', + 'djangorestframework-gis>=1.3.0,<2.0.0', + 'paho-mqtt>=2.1.0,<3.0.0', + 'requests>=2.32.4,<3.0.0', + 'python-dotenv>=1.2.3,<2.0.0', + 'pymemcache>=4.0.0,<5.0.0', + 'django-cors-headers>=4.9.0,<5.0.0', + 'django-filter>=26.1,<27.0', + 'gunicorn>=26.2.0,<27.0.0', + 'cryptography>=50.0.1,<51.0.0', + 'drf-spectacular>=0.30.0,<0.31.0', + 'psycopg[binary]>=3.3.6,<4.0.0', +] + +[dependency-groups] +dev = [ + 'pytest>=9.1.1,<10.0.0', + 'pytest-django>=4.14.0,<5.0.0', + 'factory-boy>=3.3.3,<4.0.0', + 'pre-commit>=4.6.2,<5.0.0', + 'ipython>=9.17.1,<10.0.0', + 'coverage>=7.16.1,<8.0.0', + 'pytest-cov>=7.1.0,<8.0.0', + 'pytest-mock>=3.15.1,<4.0.0', + 'ruff>=0.16.8,<0.17.0', +] + +[tool.poetry] +requires-poetry = '>=2.2.1' + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "iotserver.settings" + +[tool.coverage.run] +branch = true +source = ["iotserver"] +omit = [ + "*/management/*", + "*/migrations/*", + "*/tests/*", + "*asgi*", + "*wsgi*", + "*apps.py", + "*/admin.py", + "*/webrepl.py", +] + +[tool.coverage.report] +show_missing = true + +[tool.ruff] +target-version = "py313" +line-length = 80 + +[tool.ruff.lint] +select = ["E", "F", "W", "C90", "B", "I"] +ignore = ["E203"] + +[tool.ruff.lint.pycodestyle] +max-line-length = 88 + +[tool.ruff.lint.mccabe] +max-complexity = 18 + +[tool.ruff.lint.isort] +known-first-party = ["iotserver"] + +[tool.ruff.format] +quote-style = "preserve" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 767c0be..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = iotserver.settings diff --git a/systemd/iot.api.service b/systemd/iot.api.service new file mode 100644 index 0000000..e1b533a --- /dev/null +++ b/systemd/iot.api.service @@ -0,0 +1,13 @@ +[Unit] +Description=gunicorn api daemon +After=network.target + +[Service] +User=pi +Group=pi +WorkingDirectory=/home/pi/iotserver +Environment="PATH=/home/pi/.local/bin:$PATH" +ExecStart=/home/pi/.local/bin/poetry run gunicorn iotserver.wsgi:application -w 4 -b :8000 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/iot.mqttsubscriber.service b/systemd/iot.mqttsubscriber.service new file mode 100644 index 0000000..172ca60 --- /dev/null +++ b/systemd/iot.mqttsubscriber.service @@ -0,0 +1,13 @@ +[Unit] +Description=mqtt subscriber daemon +After=network.target + +[Service] +User=pi +Group=pi +WorkingDirectory=/home/pi/iotserver +Environment="PATH=/home/pi/.local/bin:$PATH" +ExecStart=/home/pi/.local/bin/poetry run manage.py mqtt + +[Install] +WantedBy=multi-user.target diff --git a/systemd/iot.rulesengine.service b/systemd/iot.rulesengine.service new file mode 100644 index 0000000..a436837 --- /dev/null +++ b/systemd/iot.rulesengine.service @@ -0,0 +1,13 @@ +[Unit] +Description=rules engine daemon +After=network.target + +[Service] +User=pi +Group=pi +WorkingDirectory=/home/pi/iotserver +Environment="PATH=/home/pi/.local/bin:$PATH" +ExecStart=/home/pi/.local/bin/poetry run /home/pi/iotserver/manage.py rules + +[Install] +WantedBy=multi-user.target