diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11964bd..0f7d851 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,46 +1,29 @@ repos: - - repo: local + - repo: https://github.com/psf/black + rev: 24.10.0 hooks: - id: black - name: black - entry: black - language: system - types: [python] - require_serial: true - - id: darglint - name: darglint - entry: darglint - language: system - types: [python] - stages: [manual] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: - id: end-of-file-fixer - name: Fix End of Files - entry: end-of-file-fixer - language: system - types: [text] - stages: [commit, push, manual] exclude: ^hardware/ + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: - id: flake8 - name: flake8 - entry: flake8 - language: system - types: [python] - require_serial: true - args: ["--ignore=E203,S301,S403,W503,C901", --darglint-ignore-regex, .*] - - id: isort - name: isort - entry: isort - require_serial: true - language: system - types_or: [cython, pyi, python] - args: ["--filter-files"] + args: ["--ignore=E203,S301,S403,W503,C901"] + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.0 + hooks: - id: pyupgrade - name: pyupgrade - description: Automatically upgrade syntax for newer versions. - entry: pyupgrade - language: system - types: [python] args: [--py37-plus] + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: local + hooks: - id: sphinx name: sphinx entry: "nox --non-interactive --session=docs" @@ -71,6 +54,7 @@ repos: language: system files: "^(src/|tests/).*$" types: [python] + pass_filenames: false # run for every possible stage other than "manual", to prevent # a duplicate run in GitHub Actions, which also fails because # of a shallow git clone diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 6c67511..4a466fb 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -3,7 +3,7 @@ Configuration ============= -TBD. +Configuration of the machine-access-control (MAC) server is accomplished by some JSON configuration files and optional environment variables, as detailed below. .. _configuration.users-json: @@ -27,6 +27,42 @@ The schema of this file is as follows: .. jsonschema:: dm_mac.models.machine.CONFIG_SCHEMA +.. _configuration.env-vars: + +Environment Variables +--------------------- + +.. list-table:: Environment Variables + :header-rows: 1 + + * - Variable + - Required? + - Description + * - ``USERS_CONFIG`` + - no + - path to users configuration file; default ``./users.json`` + * - ``MACHINES_CONFIG`` + - no + - path to machines configuration file; default ``./machines.json`` + * - ``MACHINE_STATE_DIR`` + - no + - path to machine state directory; default ``./machine_state`` + * - ``SLACK_BOT_TOKEN`` + - no + - If using the Slack integration, the Bot User OAuth Token for your installation of the app. + * - ``SLACK_APP_TOKEN`` + - no + - If using the Slack integration, the Socket OAuth Token for your installation of the app. + * - ``SLACK_SIGNING_SECRET`` + - no + - If using the Slack integration, the Signing Secret for your installation of the app. + * - ``SLACK_CONTROL_CHANNEL_ID`` + - no + - If using the Slack integration, the Channel ID of of the private channel for admins to control MAC. + * - ``SLACK_OOPS_CHANNEL_ID`` + - no + - If using the Slack integration, the Channel ID of of the public channel where Oops and maintenance notices will be posted, and where machine status can be checked. + .. _configuration.machine-state-dir: Machine State Directory diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index ae067d0..0f83501 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -56,7 +56,7 @@ Running Locally .. code:: console - $ flask --app dm_mac run + $ mac-server --debug The app will now be available at http://127.0.0.1:5000 diff --git a/docs/source/dm_mac.rst b/docs/source/dm_mac.rst index 0ebbe0b..ec05237 100644 --- a/docs/source/dm_mac.rst +++ b/docs/source/dm_mac.rst @@ -24,4 +24,5 @@ Submodules dm_mac.cli_utils dm_mac.neongetter + dm_mac.slack_handler dm_mac.utils diff --git a/docs/source/dm_mac.slack_handler.rst b/docs/source/dm_mac.slack_handler.rst new file mode 100644 index 0000000..94d6dd7 --- /dev/null +++ b/docs/source/dm_mac.slack_handler.rst @@ -0,0 +1,8 @@ +dm\_mac.slack\_handler module +============================= + +.. automodule:: dm_mac.slack_handler + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/hardware.rst b/docs/source/hardware.rst index a68e885..2d65cce 100644 --- a/docs/source/hardware.rst +++ b/docs/source/hardware.rst @@ -37,7 +37,7 @@ This is intended to work with `esphome-configs/2024.6.4/no-current-input.yaml Installation Configuration + Slack Integration Hardware Administration Contributing and Development diff --git a/docs/source/slack.rst b/docs/source/slack.rst new file mode 100644 index 0000000..8d2dc06 --- /dev/null +++ b/docs/source/slack.rst @@ -0,0 +1,55 @@ +.. _slack: + +Slack Integration +================= + +machine-access-control (MAC) offers a Slack integration for logging and control. + +.. _slack.setup: + +Setup +----- + +To set up the Slack integration: + +1. `Create a new Slack app `_ + + 1. Create your new app "from scratch". + 2. Set a meaningful name, such as ``machine-access-control`` and create the app in your Workspace. + 3. In the left menu, navigate to ``OAuth & Permissions``. + 4. In the "Scopes" pane, under "Bot Token Scopes", click "Add an OAuth Scope" and add scopes for ``app_mentions:read``, ``canvases:read``, ``canvases:write``, ``channels:read``, ``chat:write``, ``groups:read``, ``groups:write``, ``incoming-webhook``, ``users.profile:read``, and ``users:read``. + +2. In your workspace, create a new private channel for admins to interact with MAC in, and MAC to post status updates to. +3. In the left menu, navigate to ``Install App``. Click on the button to install to your workspace. When prompted for a channel for the app to post in, select the private channel that you created in the previous step. +4. On the next screen, ``Installed App Settings``, copy the ``Bot User OAuth Token`` and set this as the ``SLACK_BOT_TOKEN`` environment variable for the MAC server. +5. Go back to the main settings for your app and navigate to ``Socket Mode`` under ``Settings`` on the left menu; toggle on ``Enable Socket Mode``. For ``Token Name``, enter ``socket-mode-token`` and click ``Generate``. Copy the generated token and set it as the ``SLACK_APP_TOKEN`` environment variable for the MAC server. If you need to retrieve this token later, it can be found in the ``App-Level Tokens`` pane of the ``Settings -> Basic Information`` page. +6. Go back to the main settings for your app and navigate to ``Basic Information`` under ``Settings`` on the left menu; in the ``App Credentials`` pane click ``Show`` in the ``Signing Secret`` box and then copy that value; set it as the ``SLACK_SIGNING_SECRET`` environment variable for the MAC server. +7. Go back to the main settings for your app and navigate to ``Event Subscriptions`` under ``Features`` on the left menu; click the toggle in the upper left of the panel to Enable Events; under ``Subscribe to bot events`` add a subscription for ``app_mention``. + +.. _slack.configuration: + +Configuration +------------- + +1. Set :ref:`configuration.env-vars` as described in :ref:`slack.setup`, above. +2. If you don't already have one, create a private channel for the people who will be allowed to control MAC (i.e. clear Oopses and lock-out/unlock machines). +3. Invite your bot user to that channel by at-mentioning the bot username. +4. In that channel, click on the channel name to pull up the channel information tab, and copy the Channel ID (a string beginning with "C") from the bottom of that panel. Set this as the ``SLACK_CONTROL_CHANNEL_ID`` environment variable. +5. If you don't already have one, create a public channel for the bot to post Oops/maintenance notices in. Invite the bot to that channel via an at-mention. Get the Channel ID and set it as the ``SLACK_OOPS_CHANNEL_ID`` environment variable. Users in this channel will also be able to check machine status. + + +.. _slack.usage: + +Usage +----- + +The slack bot is controlled by mentioning its name (``@your-bot-name``) along with a command and optional arguments, in the ``SLACK_CONTROL_CHANNEL_ID`` channel (or, for the status command, any channel that the bot is in). + +Using an example bot name of ``@machine-access-control``, the supported commands are: + +* ``@machine-access-control status`` - List all machines and their current status. This command is the only one that is usable from channels other than the control channel. +* ``@machine-access-control oops `` - Set Oops'ed status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. +* ``@machine-access-control lock `` - Set maintenance lock-out status on the machine with name ``machine-name``. This takes effect immediately, even if the machine is currently in use. +* ``@machine-access-control clear `` - Clear all Oops and/or maintenance lock-out states on the machine with name ``machine-name``. + +In addition, changes to all machines' Oops and maintenance lock-out states will be posted as messages in the ``SLACK_OOPS_CHANNEL_ID`` channel. diff --git a/esphome-configs/2024.6.4/no-current-input.yaml b/esphome-configs/2024.6.4/no-current-input.yaml index 5c92ff3..68c453c 100644 --- a/esphome-configs/2024.6.4/no-current-input.yaml +++ b/esphome-configs/2024.6.4/no-current-input.yaml @@ -1,7 +1,7 @@ # This config expects the following hardware: # # - pcf8574 16x2 I2C character LCD connected with SDA on GPIO22 and SCL on GPIO23 -# - wiegand RFID reader connected with d0 on GPIO16, d1 on GPIO4, and card present on GPIO25 +# - wiegand RFID reader connected with d0 on GPIO16, d1 on GPIO4, and card present on GPIO18 # - oops button connected between GPIO32 and ground, no external resistors (internal pullup) # - oops button LED connected directly to GPIO5 # - output relay on GPIO33 @@ -36,6 +36,8 @@ logger: level: DEBUG api: + # don't reboot just because ESPHome isn't connected + reboot_timeout: 0s encryption: key: !secret api_encryption_key @@ -109,15 +111,17 @@ http_request: i2c: sda: GPIO22 scl: GPIO23 + frequency: 400kHz sensor: - platform: uptime name: Uptime id: uptime_sensor + update_interval: 5s - platform: wifi_signal # Reports the WiFi signal strength/RSSI in dB name: "WiFi Signal dB" id: wifi_signal_db - update_interval: 60s + update_interval: 15s entity_category: "diagnostic" - platform: copy # Reports the WiFi signal strength in % source_id: wifi_signal_db @@ -134,7 +138,7 @@ sensor: binary_sensor: - platform: gpio - pin: GPIO25 + pin: GPIO18 name: "Card Present" id: card_present on_release: diff --git a/hardware/v1_mcu/README.md b/hardware/v1_mcu/README.md index a96de5a..665f6b2 100644 --- a/hardware/v1_mcu/README.md +++ b/hardware/v1_mcu/README.md @@ -20,7 +20,7 @@ The connector used for power and control is a GX16-8 style round connector as sp * M3x4x5mm or M3x6x5mm threaded inserts, qty 4, to secure lid to base. * M3x??? socket head cap screws, qty 4, to secure lid to base. -* M4x16 flat head screws and M4 nylon lock nuts, qty 6 each, to secure RFID pocket to front of enclosure. +* M4x16 flat head or button head screws and M4 nylon lock nuts, qty 6 each, to secure RFID pocket to front of enclosure. * M2.5x6 flat head screws, qty 10 * 2 each to mount RFID reader to standoffs. * 4 each to mount ESP32 carrier board to standoffs. diff --git a/hardware/v1_mcu/original_diagram.pdf b/hardware/v1_mcu/original_diagram.pdf new file mode 100644 index 0000000..b2a97e3 Binary files /dev/null and b/hardware/v1_mcu/original_diagram.pdf differ diff --git a/poetry.lock b/poetry.lock index 6588525..0c88d40 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiofiles" @@ -11,6 +11,128 @@ files = [ {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572"}, + {file = "aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586"}, +] + +[[package]] +name = "aiohttp" +version = "3.11.6" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7510b3ca2275691875ddf072a5b6cd129278d11fe09301add7d292fc8d3432de"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfab0d2c3380c588fc925168533edb21d3448ad76c3eadc360ff963019161724"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf02dba0f342f3a8228f43fae256aafc21c4bc85bffcf537ce4582e2b1565188"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92daedf7221392e7a7984915ca1b0481a94c71457c2f82548414a41d65555e70"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2274a7876e03429e3218589a6d3611a194bdce08c3f1e19962e23370b47c0313"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a2e1eae2d2f62f3660a1591e16e543b2498358593a73b193006fb89ee37abc6"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978ec3fb0a42efcd98aae608f58c6cfcececaf0a50b4e86ee3ea0d0a574ab73b"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51f87b27d9219ed4e202ed8d6f1bb96f829e5eeff18db0d52f592af6de6bdbf"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:04d1a02a669d26e833c8099992c17f557e3b2fdb7960a0c455d7b1cbcb05121d"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3679d5fcbc7f1ab518ab4993f12f80afb63933f6afb21b9b272793d398303b98"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a4b24e03d04893b5c8ec9cd5f2f11dc9c8695c4e2416d2ac2ce6c782e4e5ffa5"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d9abdfd35ecff1c95f270b7606819a0e2de9e06fa86b15d9080de26594cf4c23"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b5c3e7928a0ad80887a5eba1c1da1830512ddfe7394d805badda45c03db3109"}, + {file = "aiohttp-3.11.6-cp310-cp310-win32.whl", hash = "sha256:913dd9e9378f3c38aeb5c4fb2b8383d6490bc43f3b427ae79f2870651ae08f22"}, + {file = "aiohttp-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:4ac26d482c2000c3a59bf757a77adc972828c9d4177b4bd432a46ba682ca7271"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26ac4c960ea8debf557357a172b3ef201f2236a462aefa1bc17683a75483e518"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b1f13ebc99fb98c7c13057b748f05224ccc36d17dee18136c695ef23faaf4ff"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4679f1a47516189fab1774f7e45a6c7cac916224c91f5f94676f18d0b64ab134"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74491fdb3d140ff561ea2128cb7af9ba0a360067ee91074af899c9614f88a18f"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f51e1a90412d387e62aa2d243998c5eddb71373b199d811e6ed862a9f34f9758"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72ab89510511c3bb703d0bb5504787b11e0ed8be928ed2a7cf1cda9280628430"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6681c9e046d99646e8059266688374a063da85b2e4c0ebfa078cda414905d080"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a17f8a6d3ab72cbbd137e494d1a23fbd3ea973db39587941f32901bb3c5c350"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:867affc7612a314b95f74d93aac550ce0909bc6f0b6c658cc856890f4d326542"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00d894ebd609d5a423acef885bd61e7f6a972153f99c5b3ea45fc01fe909196c"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:614c87be9d0d64477d1e4b663bdc5d1534fc0a7ebd23fb08347ab9fd5fe20fd7"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:533ed46cf772f28f3bffae81c0573d916a64dee590b5dfaa3f3d11491da05b95"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589884cfbc09813afb1454816b45677e983442e146183143f988f7f5a040791a"}, + {file = "aiohttp-3.11.6-cp311-cp311-win32.whl", hash = "sha256:1da63633ba921669eec3d7e080459d4ceb663752b3dafb2f31f18edd248d2170"}, + {file = "aiohttp-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:d778ddda09622e7d83095cc8051698a0084c155a1474bfee9bac27d8613dbc31"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:943a952df105a5305257984e7a1f5c2d0fd8564ff33647693c4d07eb2315446d"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d24ec28b7658970a1f1d98608d67f88376c7e503d9d45ff2ba1949c09f2b358c"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6720e809a660fdb9bec7c168c582e11cfedce339af0a5ca847a5d5b588dce826"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4252d30da0ada6e6841b325869c7ef5104b488e8dd57ec439892abbb8d7b3615"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f65f43ff01b238aa0b5c47962c83830a49577efe31bd37c1400c3d11d8a32835"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc5933f6c9b26404444d36babb650664f984b8e5fa0694540e7b7315d11a4ff"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bf546ba0c029dfffc718c4b67748687fd4f341b07b7c8f1719d6a3a46164798"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c351d05bbeae30c088009c0bb3b17dda04fd854f91cc6196c448349cc98f71c3"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:10499079b063576fad1597898de3f9c0a2ce617c19cc7cd6b62fdcff6b408bf7"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:442ee82eda47dd59798d6866ce020fb8d02ea31ac9ac82b3d719ed349e6a9d52"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:86fce9127bc317119b34786d9e9ae8af4508a103158828a535f56d201da6ab19"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:973d26a5537ce5d050302eb3cd876457451745b1da0624cbb483217970e12567"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532b8f038a4e001137d3600cea5d3439d1881df41bdf44d0f9651264d562fdf0"}, + {file = "aiohttp-3.11.6-cp312-cp312-win32.whl", hash = "sha256:4863c59f748dbe147da82b389931f2a676aebc9d3419813ed5ca32d057c9cb32"}, + {file = "aiohttp-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:5d7f481f82c18ac1f7986e31ba6eea9be8b2e2c86f1ef035b6866179b6c5dd68"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40f502350496ba4c6820816d3164f8a0297b9aa4e95d910da31beb189866a9df"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9072669b0bffb40f1f6977d0b5e8a296edc964f9cefca3a18e68649c214d0ce3"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:518160ecf4e6ffd61715bc9173da0925fcce44ae6c7ca3d3f098fe42585370fb"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69cc1b45115ac44795b63529aa5caa9674be057f11271f65474127b24fc1ce6"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6be90a6beced41653bda34afc891617c6d9e8276eef9c183f029f851f0a3c3d"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00c22fe2486308770d22ef86242101d7b0f1e1093ce178f2358f860e5149a551"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2607ebb783e3aeefa017ec8f34b506a727e6b6ab2c4b037d65f0bc7151f4430a"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f761d6819870c2a8537f75f3e2fc610b163150cefa01f9f623945840f601b2c"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e44d1bc6c88f5234115011842219ba27698a5f2deee245c963b180080572aaa2"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e0cb6a1b1f499cb2aa0bab1c9f2169ad6913c735b7447e058e0c29c9e51c0b5"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a76b4d4ca34254dca066acff2120811e2a8183997c135fcafa558280f2cc53f3"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69051c1e45fb18c0ae4d39a075532ff0b015982e7997f19eb5932eb4a3e05c17"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aff2ed18274c0bfe0c1d772781c87d5ca97ae50f439729007cec9644ee9b15fe"}, + {file = "aiohttp-3.11.6-cp313-cp313-win32.whl", hash = "sha256:2fbea25f2d44df809a46414a8baafa5f179d9dda7e60717f07bded56300589b3"}, + {file = "aiohttp-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f77bc29a465c0f9f6573d1abe656d385fa673e34efe615bd4acc50899280ee47"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:de6123b298d17bca9e53581f50a275b36e10d98e8137eb743ce69ee766dbdfe9"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a10200f705f4fff00e148b7f41e5d1d929c7cd4ac523c659171a0ea8284cd6fb"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7776ef6901b54dd557128d96c71e412eec0c39ebc07567e405ac98737995aad"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e5c2a55583cd91936baf73d223807bb93ace6eb1fe54424782690f2707162ab"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b032bd6cf7422583bf44f233f4a1489fee53c6d35920123a208adc54e2aba41e"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fe2d99acbc5cf606f75d7347bf3a027c24c27bc052d470fb156f4cfcea5739"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84a79c366375c2250934d1238abe5d5ea7754c823a1c7df0c52bf0a2bfded6a9"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33cbbe97dc94a34d1295a7bb68f82727bcbff2b284f73ae7e58ecc05903da97"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:19e4fb9ac727834b003338dcdd27dcfe0de4fb44082b01b34ed0ab67c3469fc9"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a97f6b2afbe1d27220c0c14ea978e09fb4868f462ef3d56d810d206bd2e057a2"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c3f7afeea03a9bc49be6053dfd30809cd442cc12627d6ca08babd1c1f9e04ccf"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0d10967600ce5bb69ddcb3e18d84b278efb5199d8b24c3c71a4959c2f08acfd0"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:60f2f631b9fe7aa321fa0f0ff3f5d8b9f7f9b72afd4eecef61c33cf1cfea5d58"}, + {file = "aiohttp-3.11.6-cp39-cp39-win32.whl", hash = "sha256:4d2b75333deb5c5f61bac5a48bba3dbc142eebbd3947d98788b6ef9cc48628ae"}, + {file = "aiohttp-3.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:8908c235421972a2e02abcef87d16084aabfe825d14cc9a1debd609b3cfffbea"}, + {file = "aiohttp-3.11.6.tar.gz", hash = "sha256:fd9f55c1b51ae1c20a1afe7216a64a88d38afee063baa23c7fce03757023c999"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + [[package]] name = "alabaster" version = "0.7.16" @@ -525,73 +647,73 @@ development = ["black", "flake8", "mypy", "pytest", "types-colorama"] [[package]] name = "coverage" -version = "7.6.5" +version = "7.6.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d5fc459f1b62aa328b5c6943b4fa060fa63e7749e41c974929c503dc01d0527b"}, - {file = "coverage-7.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:197fc6b5e6271c4f822486cabbd91f32e73f784076b69c91179c5a9fec2d1442"}, - {file = "coverage-7.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a7cab0762dfbf0b0cd6eb22f7bceade31bda0f0647f9420cbb45571de4493a3"}, - {file = "coverage-7.6.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee4559597f53455d70b9935e25c21fd05aebbb8d540af04097f7cf6dc7562754"}, - {file = "coverage-7.6.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e68b894ee1a170da94b7da381527f277ec00c67f6141e79aa1ce8eebbb5561"}, - {file = "coverage-7.6.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe4ea637711f1f1895895578972e3d0ed5efb6ef970ba0e2e26d9fad1e3c820e"}, - {file = "coverage-7.6.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d5f036235a747cd30be433ef7ba6dab5ac41d8dc69d54094d5438c34fe8d565"}, - {file = "coverage-7.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a6ab7b88b1a614bc1db015e68048eb29b0c30ffa01be3d7d04da1f320db0f01"}, - {file = "coverage-7.6.5-cp310-cp310-win32.whl", hash = "sha256:ad712a72cd734fb4265041005011bbf61f8d6cba74e12c91f14a9cda63a80a64"}, - {file = "coverage-7.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:61e03bb66c087b74aea6c28d10a49f72eca98b95438a8db1ae6dfcdd060f9039"}, - {file = "coverage-7.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dffec9f67f4eb8bc9c5df720833f1f1ca36b73d86e6f95b422ca5210e264cc26"}, - {file = "coverage-7.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2fde790ac0024af19fc5327fd50890dad0c31b653f6d2ed91ab2810c046bfe22"}, - {file = "coverage-7.6.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3250186381ec8e9b71234fb92ef77da87d81cbf20df3364f8f5ebf7180ec030d"}, - {file = "coverage-7.6.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ecfa205ce1fab6d8e94fe011eec04f6035a6069f70c331efd7cd1cd2d33d897"}, - {file = "coverage-7.6.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15af7bfbc37de33e7df3f740cc735057606c63bbe44aee8b07339a3e7bb8ecf6"}, - {file = "coverage-7.6.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:caf4d6af23af0e0df4e40e9985f6063d7f5434f225ee4d4ed7001f1428302403"}, - {file = "coverage-7.6.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5dcf2da597fe616a41c59e29fd8d390ac2149aeed421172eef14470c7e9dcd06"}, - {file = "coverage-7.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebc76107d896a53116e5ef21998f321b630b574a65b78b01176ca64e8978b43e"}, - {file = "coverage-7.6.5-cp311-cp311-win32.whl", hash = "sha256:0e9e4cd48dca252d99bb97b14f13b5940813937cc7ec568418c1a195dec9cbcc"}, - {file = "coverage-7.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:a6eb14739a20c5a46073c8ad066ada17d91d14599ed98d724614db46fbae867b"}, - {file = "coverage-7.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9ae01c434cb0d445008257bb42dcd38112190e5bfc3a4480fde49572b16bc2ae"}, - {file = "coverage-7.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c72ef3be899f389c9f0934a9d06a28fa097ade096760102c732583c04cc31d75"}, - {file = "coverage-7.6.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2fc574b4fb082a0141d4df00079c4877d46cb98e8ec979cbd9a92426f5abd8a"}, - {file = "coverage-7.6.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bc0eba158ad9d1883efb4f1bf08f88a999e091daf30454fd5f136322e700c72"}, - {file = "coverage-7.6.5-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a360b282c0acbf3541cc67e8d8a2a65589ea6cfa10c7e8a48e318bf28ca90f94"}, - {file = "coverage-7.6.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b22f96d3f2425942a649d786f57ae431425c9a970afae784cd865c1ffee34bad"}, - {file = "coverage-7.6.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:70eca9c6bf742feaf3ee453c1aaa932c2ab88ca420f411d90aa43ae831127b22"}, - {file = "coverage-7.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c4bafec5da3498d498a4ca3136f5a01fded487c6a54f18aea0bcd673feedf1b"}, - {file = "coverage-7.6.5-cp312-cp312-win32.whl", hash = "sha256:edecf498cabb335e8a683eb672558355bb9536d4397c54f1e135d9b8910512a3"}, - {file = "coverage-7.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:e7c40ae56761d3c08f916019b2f8579a147f93be8e12f0f2bf4edc4ea9e1c0ab"}, - {file = "coverage-7.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:49ea4a739dc14856d7c5f935da90db123b77a850cfddcfacb490a28de8f87257"}, - {file = "coverage-7.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0c51339a28aa43d0f2b1211e57ceeeeed5e09f4deb6fc543d939de68069e81e"}, - {file = "coverage-7.6.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:040c3d5cf4db24e7cb890bf4b547a25bd3a3516c58c9f2a22f822199ee2ad8ed"}, - {file = "coverage-7.6.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0b7e67f9d3b156ab93fce71485fadd043ab04b45d5d88623c6d94f7d16ced5b"}, - {file = "coverage-7.6.5-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e078bfb114025c55fdbaa802f4c13e20e6ce4e10a96918d7234656b41f69e649"}, - {file = "coverage-7.6.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:559cdb21aca30810e648ac08270535c1d2e17226ebbdf90860a060d3680cb05f"}, - {file = "coverage-7.6.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:23e2dd956277061f24d9eda7539113a9c35a9409a9935647a34ced79b8aacb75"}, - {file = "coverage-7.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7c4ccb41dc9830b2ca8592e401045a81740f627c7c0348bdc3b7373ce52f8e"}, - {file = "coverage-7.6.5-cp313-cp313-win32.whl", hash = "sha256:9d3565bb7deaa12d634426f113e6b106028c535667ba7756af65f00464981ba5"}, - {file = "coverage-7.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:5039410420d9ddcd5b8566d3afbb28b89d70c4481dbb283ea543263cbefa2b67"}, - {file = "coverage-7.6.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:77b640aa78d4d9f620fb2e1b2a41b0d196120c188d0a7f678761d668d6251fcc"}, - {file = "coverage-7.6.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb3799f6279df37e369027128926de4c159e6399000316ebd7a69e55b84dc97f"}, - {file = "coverage-7.6.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55aba7ab64e8af37a18064f23f399dff10041fa3aaf201528f12004968638b9f"}, - {file = "coverage-7.6.5-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6065a988d724dd3328cb21e97378bef0549b2f8b7ac0a3376785d9f7f05dc736"}, - {file = "coverage-7.6.5-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f092d222e4286cdd1ab9707da36944c11ba6294d8c9b18534057f03e6866367"}, - {file = "coverage-7.6.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1dc99aece5f899955eece053a798e279f7fe7059dd5e2a95af82878cfe4a44e1"}, - {file = "coverage-7.6.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b14515f83ffa7a6787e725d804c6b11dd317a6bd0373d8519a61e4a587fe534"}, - {file = "coverage-7.6.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9fa6d90130165346935541f3762933dae07e237ff7d6d780fae556039f08a470"}, - {file = "coverage-7.6.5-cp313-cp313t-win32.whl", hash = "sha256:1be9ec4c49becb35955b9d69c27e6385aedd40d233f1cf065e8430c59924b30e"}, - {file = "coverage-7.6.5-cp313-cp313t-win_amd64.whl", hash = "sha256:7ff4fd7679df56e36fc838ef227e95e3aa1b0ca0548daede7f8ae6e54479c115"}, - {file = "coverage-7.6.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:23abf0846290aa57d629c4f4181d0d56cbaa45d3999e60cb0df1d2bab7bc6bfe"}, - {file = "coverage-7.6.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4903685e8059e170182ac4681ee72d2dfbb92692225023c1e325a9d85c1be31"}, - {file = "coverage-7.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ad9621fd9773b1461f8942da4130fbb16ee0a877eb58bc57532ea41cce20d3e"}, - {file = "coverage-7.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7324358a77f37ffd8ba94d3c8326eb316c972ec72264f36fc3be04cff8542465"}, - {file = "coverage-7.6.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf182001229411cd6a90d180973b345bd6fe255dbbac362100e6a625dfb107f5"}, - {file = "coverage-7.6.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4601dacd88556c94c9fb5063b9354b1fe971af9a5b25b2575faefd12bf8170a5"}, - {file = "coverage-7.6.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e5aa3d62285ef1b16f655e1ae298c6fa919209637d317934e382e9b99c28c118"}, - {file = "coverage-7.6.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cb5601620c3d98d2c98847272acc2406333d43c9d7d49386d879bd451677429"}, - {file = "coverage-7.6.5-cp39-cp39-win32.whl", hash = "sha256:c32428f6285344caedd945236f31c46645bb10faae8702d1409bb49df218e55a"}, - {file = "coverage-7.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:809e868eee27d056bc72590c69940c119775d218681b1a8ef9ba0ef8d7693e53"}, - {file = "coverage-7.6.5-pp39.pp310-none-any.whl", hash = "sha256:49145276f39f940b18a539e1e4a378e06c64a127922450ffd2fb82b9fe1ad3d9"}, - {file = "coverage-7.6.5.tar.gz", hash = "sha256:6069188329fbe0a63876719099076261ce7a1adeea95bf236cff4353a8451b0d"}, + {file = "coverage-7.6.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:108bb458827765d538abcbf8288599fee07d2743357bdd9b9dad456c287e121e"}, + {file = "coverage-7.6.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c973b2fe4dc445cb865ab369df7521df9c27bf40715c837a113edaa2aa9faf45"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c6b24007c4bcd0b19fac25763a7cac5035c735ae017e9a349b927cfc88f31c1"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acbb8af78f8f91b3b51f58f288c0994ba63c646bc1a8a22ad072e4e7e0a49f1c"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad32a981bcdedb8d2ace03b05e4fd8dace8901eec64a532b00b15217d3677dd2"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:34d23e28ccb26236718a3a78ba72744212aa383141961dd6825f6595005c8b06"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e25bacb53a8c7325e34d45dddd2f2fbae0dbc230d0e2642e264a64e17322a777"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af05bbba896c4472a29408455fe31b3797b4d8648ed0a2ccac03e074a77e2314"}, + {file = "coverage-7.6.7-cp310-cp310-win32.whl", hash = "sha256:796c9b107d11d2d69e1849b2dfe41730134b526a49d3acb98ca02f4985eeff7a"}, + {file = "coverage-7.6.7-cp310-cp310-win_amd64.whl", hash = "sha256:987a8e3da7da4eed10a20491cf790589a8e5e07656b6dc22d3814c4d88faf163"}, + {file = "coverage-7.6.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e61b0e77ff4dddebb35a0e8bb5a68bf0f8b872407d8d9f0c726b65dfabe2469"}, + {file = "coverage-7.6.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a5407a75ca4abc20d6252efeb238377a71ce7bda849c26c7a9bece8680a5d99"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df002e59f2d29e889c37abd0b9ee0d0e6e38c24f5f55d71ff0e09e3412a340ec"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:673184b3156cba06154825f25af33baa2671ddae6343f23175764e65a8c4c30b"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69ad502f1a2243f739f5bd60565d14a278be58be4c137d90799f2c263e7049a"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60dcf7605c50ea72a14490d0756daffef77a5be15ed1b9fea468b1c7bda1bc3b"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9c2eb378bebb2c8f65befcb5147877fc1c9fbc640fc0aad3add759b5df79d55d"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c0317288f032221d35fa4cbc35d9f4923ff0dfd176c79c9b356e8ef8ef2dff4"}, + {file = "coverage-7.6.7-cp311-cp311-win32.whl", hash = "sha256:951aade8297358f3618a6e0660dc74f6b52233c42089d28525749fc8267dccd2"}, + {file = "coverage-7.6.7-cp311-cp311-win_amd64.whl", hash = "sha256:5e444b8e88339a2a67ce07d41faabb1d60d1004820cee5a2c2b54e2d8e429a0f"}, + {file = "coverage-7.6.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f07ff574986bc3edb80e2c36391678a271d555f91fd1d332a1e0f4b5ea4b6ea9"}, + {file = "coverage-7.6.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:49ed5ee4109258973630c1f9d099c7e72c5c36605029f3a91fe9982c6076c82b"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3e8796434a8106b3ac025fd15417315d7a58ee3e600ad4dbcfddc3f4b14342c"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3b925300484a3294d1c70f6b2b810d6526f2929de954e5b6be2bf8caa1f12c1"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c42ec2c522e3ddd683dec5cdce8e62817afb648caedad9da725001fa530d354"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0266b62cbea568bd5e93a4da364d05de422110cbed5056d69339bd5af5685433"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e5f2a0f161d126ccc7038f1f3029184dbdf8f018230af17ef6fd6a707a5b881f"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132b5a22821f9b143f87446805e13580b67c670a548b96da945a8f6b4f2efbb"}, + {file = "coverage-7.6.7-cp312-cp312-win32.whl", hash = "sha256:7c07de0d2a110f02af30883cd7dddbe704887617d5c27cf373362667445a4c76"}, + {file = "coverage-7.6.7-cp312-cp312-win_amd64.whl", hash = "sha256:fd49c01e5057a451c30c9b892948976f5d38f2cbd04dc556a82743ba8e27ed8c"}, + {file = "coverage-7.6.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3"}, + {file = "coverage-7.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384"}, + {file = "coverage-7.6.7-cp313-cp313-win32.whl", hash = "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30"}, + {file = "coverage-7.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42"}, + {file = "coverage-7.6.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413"}, + {file = "coverage-7.6.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3"}, + {file = "coverage-7.6.7-cp313-cp313t-win32.whl", hash = "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8"}, + {file = "coverage-7.6.7-cp313-cp313t-win_amd64.whl", hash = "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56"}, + {file = "coverage-7.6.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37a15573f988b67f7348916077c6d8ad43adb75e478d0910957394df397d2874"}, + {file = "coverage-7.6.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6cce5c76985f81da3769c52203ee94722cd5d5889731cd70d31fee939b74bf0"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ab9763d291a17b527ac6fd11d1a9a9c358280adb320e9c2672a97af346ac2c"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cf96ceaa275f071f1bea3067f8fd43bec184a25a962c754024c973af871e1b7"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee9cf6b0134d6f932d219ce253ef0e624f4fa588ee64830fcba193269e4daa3"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2bc3e45c16564cc72de09e37413262b9f99167803e5e48c6156bccdfb22c8327"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:623e6965dcf4e28a3debaa6fcf4b99ee06d27218f46d43befe4db1c70841551c"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850cfd2d6fc26f8346f422920ac204e1d28814e32e3a58c19c91980fa74d8289"}, + {file = "coverage-7.6.7-cp39-cp39-win32.whl", hash = "sha256:c296263093f099da4f51b3dff1eff5d4959b527d4f2f419e16508c5da9e15e8c"}, + {file = "coverage-7.6.7-cp39-cp39-win_amd64.whl", hash = "sha256:90746521206c88bdb305a4bf3342b1b7316ab80f804d40c536fc7d329301ee13"}, + {file = "coverage-7.6.7-pp39.pp310-none-any.whl", hash = "sha256:0ddcb70b3a3a57581b450571b31cb774f23eb9519c2aaa6176d3a84c9fc57671"}, + {file = "coverage-7.6.7.tar.gz", hash = "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24"}, ] [package.extras] @@ -960,6 +1082,107 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" +[[package]] +name = "frozenlist" +version = "1.5.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, + {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, + {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, + {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, + {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, + {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, + {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, + {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, + {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, + {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, + {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, + {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, + {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, + {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, + {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, +] + [[package]] name = "furo" version = "2024.8.6" @@ -1014,6 +1237,20 @@ files = [ {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, ] +[[package]] +name = "humanize" +version = "4.11.0" +description = "Python humanize utilities" +optional = false +python-versions = ">=3.9" +files = [ + {file = "humanize-4.11.0-py3-none-any.whl", hash = "sha256:b53caaec8532bcb2fff70c8826f904c35943f8cecaca29d272d9df38092736c0"}, + {file = "humanize-4.11.0.tar.gz", hash = "sha256:e66f36020a2d5a974c504bd2555cf770621dbdbb6d82f94a6857c0b1ea2608be"}, +] + +[package.extras] +tests = ["freezegun", "pytest", "pytest-cov"] + [[package]] name = "hypercorn" version = "0.17.3" @@ -1460,6 +1697,107 @@ files = [ {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"}, ] +[[package]] +name = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.8" +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + [[package]] name = "mypy" version = "1.13.0" @@ -1795,6 +2133,113 @@ files = [ [package.extras] twisted = ["twisted"] +[[package]] +name = "propcache" +version = "0.2.0" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.8" +files = [ + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"}, + {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"}, + {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"}, + {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"}, + {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"}, + {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"}, + {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"}, + {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"}, + {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"}, + {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"}, + {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"}, + {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"}, + {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"}, + {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"}, + {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -2678,6 +3123,42 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "slack_bolt" +version = "1.21.2" +description = "The Bolt Framework for Python" +optional = false +python-versions = ">=3.6" +files = [] +develop = false + +[package.dependencies] +slack-sdk = ">=3.33.1,<4" + +[package.source] +type = "git" +url = "https://github.com/jantman/bolt-python.git" +reference = "v1.21.2-loop" +resolved_reference = "28ca29f223a1e5687c75e68ed69140c47dcead14" + +[[package]] +name = "slack_sdk" +version = "3.33.4" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.6" +files = [] +develop = false + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<14)"] + +[package.source] +type = "git" +url = "https://github.com/jantman/python-slack-sdk.git" +reference = "v3.33.4-loop" +resolved_reference = "7b0254e689d022ea52a7712e9ba35f67d35c0b77" + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -2945,13 +3426,13 @@ test = ["coverage[toml] (>=7)", "mypy (>=1.2.0)", "pytest (>=7)"] [[package]] name = "typer" -version = "0.13.0" +version = "0.13.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "typer-0.13.0-py3-none-any.whl", hash = "sha256:d85fe0b777b2517cc99c8055ed735452f2659cd45e451507c76f48ce5c1d00e2"}, - {file = "typer-0.13.0.tar.gz", hash = "sha256:f1c7198347939361eec90139ffa0fd8b3df3a2259d5852a0f7400e476d95985c"}, + {file = "typer-0.13.1-py3-none-any.whl", hash = "sha256:5b59580fd925e89463a29d363e0a43245ec02765bde9fb77d39e5d0f29dd7157"}, + {file = "typer-0.13.1.tar.gz", hash = "sha256:9d444cb96cc268ce6f8b94e13b4335084cef4c079998a9f4851a90229a3bd25c"}, ] [package.dependencies] @@ -3140,7 +3621,103 @@ cffi = ">=1.16.0" [package.extras] test = ["pytest"] +[[package]] +name = "yarl" +version = "1.17.2" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "yarl-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93771146ef048b34201bfa382c2bf74c524980870bb278e6df515efaf93699ff"}, + {file = "yarl-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8281db240a1616af2f9c5f71d355057e73a1409c4648c8949901396dc0a3c151"}, + {file = "yarl-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:170ed4971bf9058582b01a8338605f4d8c849bd88834061e60e83b52d0c76870"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc61b005f6521fcc00ca0d1243559a5850b9dd1e1fe07b891410ee8fe192d0c0"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:871e1b47eec7b6df76b23c642a81db5dd6536cbef26b7e80e7c56c2fd371382e"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a58a2f2ca7aaf22b265388d40232f453f67a6def7355a840b98c2d547bd037f"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:736bb076f7299c5c55dfef3eb9e96071a795cb08052822c2bb349b06f4cb2e0a"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd51299e21da709eabcd5b2dd60e39090804431292daacbee8d3dabe39a6bc0"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:358dc7ddf25e79e1cc8ee16d970c23faee84d532b873519c5036dbb858965795"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:50d866f7b1a3f16f98603e095f24c0eeba25eb508c85a2c5939c8b3870ba2df8"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8b9c4643e7d843a0dca9cd9d610a0876e90a1b2cbc4c5ba7930a0d90baf6903f"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d63123bfd0dce5f91101e77c8a5427c3872501acece8c90df457b486bc1acd47"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:4e76381be3d8ff96a4e6c77815653063e87555981329cf8f85e5be5abf449021"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:734144cd2bd633a1516948e477ff6c835041c0536cef1d5b9a823ae29899665b"}, + {file = "yarl-1.17.2-cp310-cp310-win32.whl", hash = "sha256:26bfb6226e0c157af5da16d2d62258f1ac578d2899130a50433ffee4a5dfa673"}, + {file = "yarl-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:76499469dcc24759399accd85ec27f237d52dec300daaca46a5352fcbebb1071"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:792155279dc093839e43f85ff7b9b6493a8eaa0af1f94f1f9c6e8f4de8c63500"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38bc4ed5cae853409cb193c87c86cd0bc8d3a70fd2268a9807217b9176093ac6"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4a8c83f6fcdc327783bdc737e8e45b2e909b7bd108c4da1892d3bc59c04a6d84"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6d5fed96f0646bfdf698b0a1cebf32b8aae6892d1bec0c5d2d6e2df44e1e2d"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:782ca9c58f5c491c7afa55518542b2b005caedaf4685ec814fadfcee51f02493"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff6af03cac0d1a4c3c19e5dcc4c05252411bf44ccaa2485e20d0a7c77892ab6e"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a3f47930fbbed0f6377639503848134c4aa25426b08778d641491131351c2c8"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1fa68a3c921365c5745b4bd3af6221ae1f0ea1bf04b69e94eda60e57958907f"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:187df91395c11e9f9dc69b38d12406df85aa5865f1766a47907b1cc9855b6303"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:93d1c8cc5bf5df401015c5e2a3ce75a5254a9839e5039c881365d2a9dcfc6dc2"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:11d86c6145ac5c706c53d484784cf504d7d10fa407cb73b9d20f09ff986059ef"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c42774d1d1508ec48c3ed29e7b110e33f5e74a20957ea16197dbcce8be6b52ba"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8e589379ef0407b10bed16cc26e7392ef8f86961a706ade0a22309a45414d7"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1056cadd5e850a1c026f28e0704ab0a94daaa8f887ece8dfed30f88befb87bb0"}, + {file = "yarl-1.17.2-cp311-cp311-win32.whl", hash = "sha256:be4c7b1c49d9917c6e95258d3d07f43cfba2c69a6929816e77daf322aaba6628"}, + {file = "yarl-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:ac8eda86cc75859093e9ce390d423aba968f50cf0e481e6c7d7d63f90bae5c9c"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd90238d3a77a0e07d4d6ffdebc0c21a9787c5953a508a2231b5f191455f31e9"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c74f0b0472ac40b04e6d28532f55cac8090e34c3e81f118d12843e6df14d0909"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d486ddcaca8c68455aa01cf53d28d413fb41a35afc9f6594a730c9779545876"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25b7e93f5414b9a983e1a6c1820142c13e1782cc9ed354c25e933aebe97fcf2"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a0baff7827a632204060f48dca9e63fbd6a5a0b8790c1a2adfb25dc2c9c0d50"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:460024cacfc3246cc4d9f47a7fc860e4fcea7d1dc651e1256510d8c3c9c7cde0"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5870d620b23b956f72bafed6a0ba9a62edb5f2ef78a8849b7615bd9433384171"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2941756754a10e799e5b87e2319bbec481ed0957421fba0e7b9fb1c11e40509f"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9611b83810a74a46be88847e0ea616794c406dbcb4e25405e52bff8f4bee2d0a"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cd7e35818d2328b679a13268d9ea505c85cd773572ebb7a0da7ccbca77b6a52e"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6b981316fcd940f085f646b822c2ff2b8b813cbd61281acad229ea3cbaabeb6b"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:688058e89f512fb7541cb85c2f149c292d3fa22f981d5a5453b40c5da49eb9e8"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56afb44a12b0864d17b597210d63a5b88915d680f6484d8d202ed68ade38673d"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:17931dfbb84ae18b287279c1f92b76a3abcd9a49cd69b92e946035cff06bcd20"}, + {file = "yarl-1.17.2-cp312-cp312-win32.whl", hash = "sha256:ff8d95e06546c3a8c188f68040e9d0360feb67ba8498baf018918f669f7bc39b"}, + {file = "yarl-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:4c840cc11163d3c01a9d8aad227683c48cd3e5be5a785921bcc2a8b4b758c4f3"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3294f787a437cb5d81846de3a6697f0c35ecff37a932d73b1fe62490bef69211"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1e7fedb09c059efee2533119666ca7e1a2610072076926fa028c2ba5dfeb78c"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da9d3061e61e5ae3f753654813bc1cd1c70e02fb72cf871bd6daf78443e9e2b1"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91c012dceadc695ccf69301bfdccd1fc4472ad714fe2dd3c5ab4d2046afddf29"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f11fd61d72d93ac23718d393d2a64469af40be2116b24da0a4ca6922df26807e"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46c465ad06971abcf46dd532f77560181387b4eea59084434bdff97524444032"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef6eee1a61638d29cd7c85f7fd3ac7b22b4c0fabc8fd00a712b727a3e73b0685"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4434b739a8a101a837caeaa0137e0e38cb4ea561f39cb8960f3b1e7f4967a3fc"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:752485cbbb50c1e20908450ff4f94217acba9358ebdce0d8106510859d6eb19a"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:17791acaa0c0f89323c57da7b9a79f2174e26d5debbc8c02d84ebd80c2b7bff8"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5c6ea72fe619fee5e6b5d4040a451d45d8175f560b11b3d3e044cd24b2720526"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db5ac3871ed76340210fe028f535392f097fb31b875354bcb69162bba2632ef4"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7a1606ba68e311576bcb1672b2a1543417e7e0aa4c85e9e718ba6466952476c0"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9bc27dd5cfdbe3dc7f381b05e6260ca6da41931a6e582267d5ca540270afeeb2"}, + {file = "yarl-1.17.2-cp313-cp313-win32.whl", hash = "sha256:52492b87d5877ec405542f43cd3da80bdcb2d0c2fbc73236526e5f2c28e6db28"}, + {file = "yarl-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:8e1bf59e035534ba4077f5361d8d5d9194149f9ed4f823d1ee29ef3e8964ace3"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c556fbc6820b6e2cda1ca675c5fa5589cf188f8da6b33e9fc05b002e603e44fa"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f2f44a4247461965fed18b2573f3a9eb5e2c3cad225201ee858726cde610daca"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a3ede8c248f36b60227eb777eac1dbc2f1022dc4d741b177c4379ca8e75571a"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2654caaf5584449d49c94a6b382b3cb4a246c090e72453493ea168b931206a4d"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d41c684f286ce41fa05ab6af70f32d6da1b6f0457459a56cf9e393c1c0b2217"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2270d590997445a0dc29afa92e5534bfea76ba3aea026289e811bf9ed4b65a7f"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18662443c6c3707e2fc7fad184b4dc32dd428710bbe72e1bce7fe1988d4aa654"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75ac158560dec3ed72f6d604c81090ec44529cfb8169b05ae6fcb3e986b325d9"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1fee66b32e79264f428dc8da18396ad59cc48eef3c9c13844adec890cd339db5"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:585ce7cd97be8f538345de47b279b879e091c8b86d9dbc6d98a96a7ad78876a3"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c019abc2eca67dfa4d8fb72ba924871d764ec3c92b86d5b53b405ad3d6aa56b0"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c6e659b9a24d145e271c2faf3fa6dd1fcb3e5d3f4e17273d9e0350b6ab0fe6e2"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:d17832ba39374134c10e82d137e372b5f7478c4cceeb19d02ae3e3d1daed8721"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bc3003710e335e3f842ae3fd78efa55f11a863a89a72e9a07da214db3bf7e1f8"}, + {file = "yarl-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f5ffc6b7ace5b22d9e73b2a4c7305740a339fbd55301d52735f73e21d9eb3130"}, + {file = "yarl-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:48e424347a45568413deec6f6ee2d720de2cc0385019bedf44cd93e8638aa0ed"}, + {file = "yarl-1.17.2-py3-none-any.whl", hash = "sha256:dd7abf4f717e33b7487121faf23560b3a50924f80e4bef62b22dab441ded8f3b"}, + {file = "yarl-1.17.2.tar.gz", hash = "sha256:753eaaa0c7195244c84b5cc159dc8204b7fd99f716f11198f999f2332a86b178"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "8eb0051a447650e122a9f50fe22c22c5e38945f4094535580463a53ff3195c3d" +content-hash = "bb5527b54027cd8299225af1a2166d69a9c772476f27ef724c00ed9464a6ff36" diff --git a/pyproject.toml b/pyproject.toml index 9fe0e79..0e7be70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ [tool.poetry.scripts] neongetter = "dm_mac.neongetter:main" +mac-server = "dm_mac:main" [tool.poetry.urls] Changelog = "https://github.com/jantman/machine_access_control/releases" @@ -29,6 +30,10 @@ filelock = "^3.15.4" prometheus-client = "^0.20.0" quart = "^0.19.8" asyncio = "^3.4.3" +slack-bolt = {git = "https://github.com/jantman/bolt-python.git", rev = "v1.21.2-loop"} +aiohttp = "^3.11.4" +slack-sdk = {git = "https://github.com/jantman/python-slack-sdk.git", rev = "v3.33.4-loop"} +humanize = "^4.11.0" [tool.poetry.group.dev.dependencies] Pygments = ">=2.10.0" diff --git a/src/dm_mac/__init__.py b/src/dm_mac/__init__.py index fbf6cc8..88cd40a 100644 --- a/src/dm_mac/__init__.py +++ b/src/dm_mac/__init__.py @@ -1,21 +1,33 @@ """Decatur Makers Machine Access Control.""" +import argparse import logging +import os +import sys +from asyncio import AbstractEventLoop +from asyncio import get_event_loop from time import time from quart import Quart from quart import has_request_context from quart import request from quart.logging import default_handler +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from dm_mac.models.machine import MachinesConfig from dm_mac.models.users import UsersConfig +from dm_mac.slack_handler import SlackHandler +from dm_mac.utils import set_log_debug +from dm_mac.utils import set_log_info from dm_mac.views.api import api from dm_mac.views.machine import machineapi from dm_mac.views.prometheus import prometheus_route -logger: logging.Logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger() +logging.basicConfig( + level=logging.WARNING, format="[%(asctime)s %(levelname)s] %(message)s" +) # BEGIN adding request information to logs @@ -51,6 +63,16 @@ def format(self, record: logging.LogRecord) -> str: # see: https://github.com/pallets/flask/issues/4786#issuecomment-1416354177 api.register_blueprint(machineapi) +app: Quart + + +def asyncio_exception_handler(_, context): + # get details of the exception + exception = context["exception"] + message = context["message"] + # log exception + logger.error(f"Task failed, msg={message}, exception={exception}") + def create_app() -> Quart: """Factory to create the app.""" @@ -58,6 +80,49 @@ def create_app() -> Quart: app.config.update({"MACHINES": MachinesConfig()}) app.config.update({"USERS": UsersConfig()}) app.config.update({"START_TIME": time()}) + app.config.update({"SLACK_HANDLER": None}) app.register_blueprint(api) app.add_url_rule("/metrics", view_func=prometheus_route) return app + + +def main() -> None: + global app + p = argparse.ArgumentParser(description="Run Machine Access Control (MAC) server") + p.add_argument( + "-d", + "--debug", + dest="debug", + action="store_true", + default=False, + help="Debug mode", + ) + p.add_argument( + "-v", + "--verbose", + dest="verbose", + action="store_true", + default=False, + help="verbose output", + ) + args = p.parse_args(sys.argv[1:]) + if args.verbose: + set_log_debug(logger) + else: + set_log_info(logger) + loop: AbstractEventLoop = get_event_loop() + loop.set_exception_handler(asyncio_exception_handler) + app = create_app() + token: str = os.environ.get("SLACK_APP_TOKEN", "").strip() + if token: + slack: SlackHandler = SlackHandler(app) + app.config.update({"SLACK_HANDLER": slack}) + handler = AsyncSocketModeHandler( + slack.app, os.environ["SLACK_APP_TOKEN"], loop=loop + ) + loop.create_task(handler.start_async()) + app.run(loop=loop, debug=args.debug, host="0.0.0.0") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/dm_mac/models/machine.py b/src/dm_mac/models/machine.py index 6694810..e17ff6a 100644 --- a/src/dm_mac/models/machine.py +++ b/src/dm_mac/models/machine.py @@ -8,6 +8,7 @@ from logging import getLogger from threading import Lock from time import time +from typing import TYPE_CHECKING from typing import Any from typing import Dict from typing import List @@ -16,13 +17,19 @@ from typing import cast from filelock import FileLock +from humanize import naturaldelta from jsonschema import validate +from quart import current_app from dm_mac.models.users import User from dm_mac.models.users import UsersConfig from dm_mac.utils import load_json_config +if TYPE_CHECKING: # pragma: no cover + from dm_mac.slack_handler import SlackHandler + + logger: Logger = getLogger(__name__) @@ -74,27 +81,59 @@ def __init__( #: state of the machine self.state: "MachineState" = MachineState(self) - def update( + async def update( self, users: UsersConfig, **kwargs: Any ) -> Dict[str, str | bool | float | List[float]]: """Pass directly to self.state and return result.""" - return self.state.update(users, **kwargs) + return await self.state.update(users, **kwargs) - def lockout(self) -> None: + async def lockout(self, slack: Optional["SlackHandler"] = None) -> None: """Pass directly to self.state.""" self.state.lockout() + source = "Slack" + if not slack: + slack = current_app.config.get("SLACK_HANDLER") + source = "API" + if not slack: + # Slack integration is not enabled + return + await slack.log_lock(self, source) - def unlock(self) -> None: + async def unlock(self, slack: Optional["SlackHandler"] = None) -> None: """Pass directly to self.state.""" self.state.unlock() + source = "Slack" + if not slack: + slack = current_app.config.get("SLACK_HANDLER") + source = "API" + if not slack: + # Slack integration is not enabled + return + await slack.log_unlock(self, source) - def oops(self) -> None: + async def oops(self, slack: Optional["SlackHandler"] = None) -> None: """Pass directly to self.state.""" self.state.oops() + source = "Slack" + if not slack: + slack = current_app.config.get("SLACK_HANDLER") + source = "API" + if not slack: + # Slack integration is not enabled + return + await slack.log_oops(self, source) - def unoops(self) -> None: + async def unoops(self, slack: Optional["SlackHandler"] = None) -> None: """Pass directly to self.state.""" self.state.unoops() + source = "Slack" + if not slack: + slack = current_app.config.get("SLACK_HANDLER") + source = "API" + if not slack: + # Slack integration is not enabled + return + await slack.log_unoops(self, source) @property def as_dict(self) -> Dict[str, Any]: @@ -248,7 +287,7 @@ def _load_from_cache(self) -> None: setattr(self, k, v) logger.debug("State loaded.") - def _handle_reboot(self) -> None: + async def _handle_reboot(self) -> None: """Handle when the ESP32 (MCU) has rebooted since last checkin. This logs out the current user if logged in and turns off the relay if @@ -263,6 +302,12 @@ def _handle_reboot(self) -> None: self.display_text = self.DEFAULT_DISPLAY_TEXT self.status_led_rgb = (0.0, 0.0, 0.0) self.status_led_brightness = 0.0 + # log to Slack, if enabled + slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER") + if not slack: + # Slack integration is not enabled + return + await slack.admin_log(f"Machine {self.machine.name} has rebooted.") def lockout(self) -> None: """Lock-out the machine.""" @@ -316,7 +361,7 @@ def unoops(self, do_locking: bool = True) -> None: self.status_led_rgb = (0.0, 0.0, 0.0) self.status_led_brightness = 0 - def update( + async def update( self, users: UsersConfig, oops: bool = False, @@ -341,7 +386,7 @@ def update( uptime, self.uptime, ) - self._handle_reboot() + await self._handle_reboot() self.uptime = uptime if wifi_signal_db is not None: self.wifi_signal_db = wifi_signal_db @@ -351,31 +396,44 @@ def update( self.internal_temperature_c = internal_temperature_c self.last_checkin = time() if oops: - self._handle_oops(users) + await self._handle_oops(users) self.last_update = time() if rfid_value != self.rfid_value: if rfid_value is None: - self._handle_rfid_remove() + await self._handle_rfid_remove() else: - self._handle_rfid_insert(users, rfid_value) + await self._handle_rfid_insert(users, rfid_value) self.last_update = time() self._save_cache() return self.machine_response - def _handle_oops(self, users: UsersConfig) -> None: + async def _handle_oops(self, users: UsersConfig) -> None: """Handle oops button press.""" ustr: str = "" + uname: Optional[str] = None if self.rfid_value: ustr = " RFID card is present but unknown." if user := users.users_by_fob.get(self.rfid_value): ustr = f" Current user is: {user.full_name}." + uname = user.full_name logging.getLogger("OOPS").warning( "Machine %s was Oopsed.%s", self.machine.name, ustr ) # locking handled in update() self.oops(do_locking=False) + # log to Slack, if enabled + slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER") + if not slack: + # Slack integration is not enabled + return + src = "Oops button" + if self.rfid_value: + src += " with RFID present" + else: + src += " without RFID present" + await slack.log_oops(self.machine, src, user_name=uname) - def _handle_rfid_remove(self) -> None: + async def _handle_rfid_remove(self) -> None: """Handle RFID card removed.""" logging.getLogger("AUTH").info( "RFID logout on %s by %s; session duration %d seconds", @@ -383,6 +441,12 @@ def _handle_rfid_remove(self) -> None: self.current_user.full_name if self.current_user else self.rfid_value, time() - cast(float, self.rfid_present_since), ) + log_str: str = ( + f"RFID logout on {self.machine.name} by " + + (self.current_user.full_name if self.current_user else "unknown") + + "; session duration " + + naturaldelta(time() - cast(float, self.rfid_present_since)) + ) # locking handled in update() self.rfid_value = None self.rfid_present_since = None @@ -392,13 +456,20 @@ def _handle_rfid_remove(self) -> None: self.display_text = self.DEFAULT_DISPLAY_TEXT self.status_led_rgb = (0.0, 0.0, 0.0) self.status_led_brightness = 0.0 + # log to Slack, if enabled + slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER") + if not slack: + # Slack integration is not enabled + return + await slack.admin_log(log_str) - def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: + async def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: """Handle change in the RFID value.""" # locking handled in update() self.rfid_present_since = time() self.rfid_value = rfid_value user: Optional[User] = users.users_by_fob.get(rfid_value) + slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER") if not user: logging.getLogger("AUTH").warning( "RFID login attempt on %s by unknown fob %s", @@ -406,10 +477,19 @@ def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: rfid_value, ) if self.is_oopsed or self.is_locked_out: + if slack: + await slack.admin_log( + f"RFID login attempt on {self.machine.name} " + "by unknown fob when oopsed or locked out." + ) return self.display_text = "Unknown RFID" self.status_led_rgb = (1.0, 0.0, 0.0) self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + if slack: + await slack.admin_log( + f"RFID login attempt on {self.machine.name} by unknown fob" + ) return # ok, we have a known user logname = f"{user.full_name} ({rfid_value})" @@ -420,6 +500,11 @@ def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: logname, ) # don't change anything + if slack: + await slack.admin_log( + f"RFID login attempt on {self.machine.name} by " + f"{user.full_name} when oopsed." + ) return if self.is_locked_out: logging.getLogger("AUTH").warning( @@ -428,8 +513,13 @@ def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: logname, ) # don't change anything + if slack: + await slack.admin_log( + f"RFID login attempt on {self.machine.name} by " + f"{user.full_name} when machine locked-out." + ) return - if self._user_is_authorized(user): + if await self._user_is_authorized(user, slack=slack): logging.getLogger("AUTH").info( "User %s (%s) authorized for %s; session start", user.full_name, @@ -441,6 +531,11 @@ def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: self.display_text = f"Welcome,\n{user.preferred_name}" self.status_led_rgb = (0.0, 1.0, 0.0) self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + if slack: + await slack.admin_log( + f"RFID login on {self.machine.name} by authorized user " + f"{user.full_name}" + ) else: logging.getLogger("AUTH").info( "User %s (%s) UNAUTHORIZED for %s", @@ -452,8 +547,15 @@ def _handle_rfid_insert(self, users: UsersConfig, rfid_value: str) -> None: self.display_text = "Unauthorized" self.status_led_rgb = (1.0, 0.5, 0.0) # orange self.status_led_brightness = self.STATUS_LED_BRIGHTNESS + if slack: + await slack.admin_log( + f"rejected RFID login on {self.machine.name} by " + f"UNAUTHORIZED user {user.full_name}" + ) - def _user_is_authorized(self, user: User) -> bool: + async def _user_is_authorized( + self, user: User, slack: Optional["SlackHandler"] = None + ) -> bool: """Return whether user is authorized for this machine.""" for auth in self.machine.authorizations_or: if auth in user.authorizations: @@ -473,6 +575,13 @@ def _user_is_authorized(self, user: User) -> bool: user.account_id, self.machine.name, ) + if slack: + await slack.admin_log( + f"WARNING - Authorizing user {user.full_name} for " + f"{self.machine.name} based on unauthorized_warn_only " + "setting for machine. User is NOT authorized for this " + "machine." + ) return True return False diff --git a/src/dm_mac/slack_handler.py b/src/dm_mac/slack_handler.py new file mode 100644 index 0000000..1b98cca --- /dev/null +++ b/src/dm_mac/slack_handler.py @@ -0,0 +1,357 @@ +import logging +import os +import time +from asyncio import create_task +from textwrap import dedent +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from humanize import naturaldelta +from quart import Quart +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from dm_mac.models.machine import Machine +from dm_mac.models.machine import MachinesConfig + + +logger: logging.Logger = logging.getLogger(__name__) + + +class Message: + """Represent an incoming message.""" + + def __init__( + self, + text: str, + user_id: str, + user_name: str, + user_handle: str, + channel_id: str, + channel_name: str, + ): + self._raw_text: str = text + self.command: List[str] = text.split(" ")[1:] + self.user_id: str = user_id + self.user_name: str = user_name + self.user_handle: str = user_handle + self.channel_id: str = channel_id + self.channel_name: str = channel_name + + @property + def as_dict(self) -> Dict[str, Any]: + return { + "raw_text": self._raw_text, + "command": self.command, + "user_id": self.user_id, + "user_name": self.user_name, + "user_handle": self.user_handle, + "channel_id": self.channel_id, + "channel_name": self.channel_name, + } + + def __eq__(self, other) -> bool: + if not isinstance(other, type(self)): + return False + return self.as_dict == other.as_dict + + +class SlackHandler: + """Handle Slack integration.""" + + HELP_RESPONSE: str = dedent( + """ + Hi, I'm the Machine Access Control slack bot. + Mention my username followed by one of these commands: + "status" - list all machines and their status + "oops " - set Oops state on this machine immediately + "lock " - set maintenance lockout on this machine + "clear " - clear oops and/or maintenance lockout on this machine + + I am Free and Open Source software: + https://github.com/jantman/machine-access-control + """ + ).strip() + + def __init__(self, quart_app: Quart): + logger.info("Initializing SlackHandler.") + self.control_channel_id = os.environ["SLACK_CONTROL_CHANNEL_ID"] + self.oops_channel_id = os.environ["SLACK_OOPS_CHANNEL_ID"] + self.quart: Quart = quart_app + self.app: AsyncApp = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + ) + self.app.event("app_mention")(self.app_mention) + logger.debug("SlackHandler initialized.") + + async def app_mention(self, body: Dict[str, Any], say: AsyncSay) -> None: + """ + Handle an at-mention of our app in Slack. + + Body is a dict with string keys, which is documented at + . The important bits are + in the ``event`` nested dict. + + The parts of the ``event`` dict within ``body`` that are of interest to + us are: + + * ``user`` - the user ID (string beginning with "U") of the person who + mentioned us. + * ``text`` - the string text of the message that mentioned us. + * ``channel`` - the channel ID (string beginning with "C") of the + channel that the message was in. + """ + message_text: str = body["event"]["text"].strip() + my_id: str = body["authorizations"][0]["user_id"] + if not message_text.startswith(f"<@{my_id}> "): + logger.warning( + "Ignoring Slack mention with improper format: %s", message_text + ) + return None + user: AsyncSlackResponse = await self.app.client.users_info( + user=body["event"]["user"] + ) + assert user.data["ok"] is True + user_name: str = user.data["user"]["profile"]["real_name_normalized"] + user_handle: str = user.data["user"]["profile"]["display_name_normalized"] + user_is_bot: bool = ( + user.data["user"]["is_bot"] or user.data["user"]["is_app_user"] + ) + channel: AsyncSlackResponse = await self.app.client.conversations_info( + channel=body["event"]["channel"] + ) + assert channel.data["ok"] is True + channel_name: str = channel.data["channel"]["name"] + logger.info( + "Slack mention in #%s (%s) by %s (@%s; %s): %s", + channel_name, + body["event"]["channel"], + user_name, + user_handle, + body["event"]["user"], + message_text, + ) + if user_is_bot: + logger.warning("Ignoring mention by bot/app user %s", user_name) + return None + msg: Message = Message( + text=message_text, + user_id=body["event"]["user"], + user_name=user_name, + user_handle=user_handle, + channel_id=body["event"]["channel"], + channel_name=channel_name, + ) + await self.handle_command(msg, say) + + async def handle_command(self, msg: Message, say: AsyncSay) -> None: + """Handle a command sent to the bot.""" + if msg.command[0] in ["list", "status"]: + await self.machine_status(say) + return None + if msg.channel_id != self.control_channel_id: + logger.warning( + "Ignoring non-status mention in #%s (%s) - not control channel", + msg.channel_name, + msg.channel_id, + ) + return None + if msg.command[0] == "oops" and len(msg.command) == 2: + return await self.oops(msg, say) + elif msg.command[0] == "lock" and len(msg.command) == 2: + return await self.lock(msg, say) + elif msg.command[0] == "clear" and len(msg.command) == 2: + return await self.clear(msg, say) + await say(self.HELP_RESPONSE) + + async def machine_status(self, say: AsyncSay) -> None: + """Respond with machine status.""" + resp: str = "" + mconf: MachinesConfig = self.quart.config["MACHINES"] + mname: str + mach: Machine + for mname, mach in sorted(mconf.machines_by_name.items()): + resp += mname + ": " + if mach.state.is_oopsed or mach.state.is_locked_out: + if mach.state.is_oopsed: + resp += "Oopsed " + if mach.state.is_locked_out: + resp += "Locked out " + elif mach.state.relay_desired_state: + resp += "In use " + else: + resp += "Idle " + try: + ci: str = naturaldelta(time.time() - mach.state.last_checkin) + ud: str = naturaldelta(time.time() - mach.state.last_update) + ut: str = naturaldelta(mach.state.uptime) + resp += ( + f"(last contact {ci} ago; last update {ud} ago; " f"uptime {ut})\n" + ) + except TypeError: + # machine has not checked in ever + resp += "\n" + await say(resp) + + async def oops(self, msg: Message, say: AsyncSay) -> None: + """Set oops status on a machine.""" + mname: str = msg.command[1] + mconf: MachinesConfig = self.quart.config["MACHINES"] + mach: Optional[Machine] = mconf.machines_by_name.get(mname) + if not mach: + await say( + f"Invalid machine name '{mname}'. Use status command to " + f"list all machines." + ) + return + if mach.state.is_oopsed: + await say(f"Machine {mname} is already oopsed.") + return + await mach.oops(slack=self) + + async def lock(self, msg: Message, say: AsyncSay) -> None: + """Set lock status on a machine.""" + mname: str = msg.command[1] + mconf: MachinesConfig = self.quart.config["MACHINES"] + mach: Optional[Machine] = mconf.machines_by_name.get(mname) + if not mach: + await say( + f"Invalid machine name '{mname}'. Use status command to " + f"list all machines." + ) + return + if mach.state.is_locked_out: + await say(f"Machine {mname} is already locked-out.") + return + await mach.lockout(slack=self) + + async def clear(self, msg: Message, say: AsyncSay) -> None: + """Clear oops and lock status on a machine.""" + mname: str = msg.command[1] + mconf: MachinesConfig = self.quart.config["MACHINES"] + mach: Optional[Machine] = mconf.machines_by_name.get(mname) + if not mach: + await say( + f"Invalid machine name '{mname}'. Use status command to " + f"list all machines." + ) + return + acted = False + if mach.state.is_oopsed: + await mach.unoops(slack=self) + acted = True + if mach.state.is_locked_out: + await mach.unlock(slack=self) + acted = True + if not acted: + await say(f"Machine {mname} is not oopsed or locked-out.") + + async def log_unoops(self, machine: Machine, source: str) -> None: + """ + Log when a machine is un-oopsed. + + This uses :py:meth:`asyncio.create_task` to fire-and-forget the Slack + postMessage call, so that we don't block on communication with Slack. + Otherwise, updates to the relay/LCD/LED would be delayed by at least the + timeout trying to post to Slack. + """ + create_task( + self.app.client.chat_postMessage( + channel=self.control_channel_id, + text=f"Machine {machine.name} un-oopsed via {source}.", + ) + ) + create_task( + self.app.client.chat_postMessage( + channel=self.oops_channel_id, + text=f"Machine {machine.name} oops has been cleared.", + ) + ) + + async def log_oops( + self, machine: Machine, source: str, user_name: Optional[str] = "unknown user" + ) -> None: + """ + Log when a machine is oopsed. + + This uses :py:meth:`asyncio.create_task` to fire-and-forget the Slack + postMessage call, so that we don't block on communication with Slack. + Otherwise, updates to the relay/LCD/LED would be delayed by at least the + timeout trying to post to Slack. + """ + create_task( + self.app.client.chat_postMessage( + channel=self.control_channel_id, + text=f"Machine {machine.name} oopsed via {source} by {user_name}.", + ) + ) + create_task( + self.app.client.chat_postMessage( + channel=self.oops_channel_id, + text=f"Machine {machine.name} has been Oops'ed!", + ) + ) + + async def log_unlock(self, machine: Machine, source: str) -> None: + """ + Log when a machine is un-locked. + + This uses :py:meth:`asyncio.create_task` to fire-and-forget the Slack + postMessage call, so that we don't block on communication with Slack. + Otherwise, updates to the relay/LCD/LED would be delayed by at least the + timeout trying to post to Slack. + """ + create_task( + self.app.client.chat_postMessage( + channel=self.control_channel_id, + text=f"Machine {machine.name} locked-out cleared via {source}.", + ) + ) + create_task( + self.app.client.chat_postMessage( + channel=self.oops_channel_id, + text=f"Machine {machine.name} is no longer locked-out for " + f"maintenance.", + ) + ) + + async def log_lock(self, machine: Machine, source: str) -> None: + """ + Log when a machine is locked. + + This uses :py:meth:`asyncio.create_task` to fire-and-forget the Slack + postMessage call, so that we don't block on communication with Slack. + Otherwise, updates to the relay/LCD/LED would be delayed by at least the + timeout trying to post to Slack. + """ + create_task( + self.app.client.chat_postMessage( + channel=self.control_channel_id, + text=f"Machine {machine.name} locked-out via {source}.", + ) + ) + create_task( + self.app.client.chat_postMessage( + channel=self.oops_channel_id, + text=f"Machine {machine.name} is locked-out for maintenance.", + ) + ) + + async def admin_log(self, message: str) -> None: + """ + Log a string to the admin channel only. + + This uses :py:meth:`asyncio.create_task` to fire-and-forget the Slack + postMessage call, so that we don't block on communication with Slack. + Otherwise, updates to the relay/LCD/LED would be delayed by at least the + timeout trying to post to Slack. + """ + create_task( + self.app.client.chat_postMessage( + channel=self.control_channel_id, text=message + ) + ) diff --git a/src/dm_mac/utils.py b/src/dm_mac/utils.py index c0b307f..0f196c9 100644 --- a/src/dm_mac/utils.py +++ b/src/dm_mac/utils.py @@ -34,3 +34,27 @@ def load_json_config(env_var: str, default_path: str) -> Any: "Loaded config of type %s with length %d", type(config).__name__, len(config) ) return config + + +def set_log_info(lgr: logging.Logger): + """set logger level to INFO""" + set_log_level_format( + lgr, logging.INFO, "%(asctime)s %(levelname)s:%(name)s:%(message)s" + ) + + +def set_log_debug(lgr: logging.Logger): + """set logger level to DEBUG, and debug-level output format""" + set_log_level_format( + lgr, + logging.DEBUG, + "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " + "%(name)s.%(funcName)s() ] %(message)s", + ) + + +def set_log_level_format(lgr: logging.Logger, level: int, fmt: str): + """Set logger level and format.""" + formatter = logging.Formatter(fmt=fmt) + lgr.handlers[0].setFormatter(formatter) + lgr.setLevel(level) diff --git a/src/dm_mac/views/machine.py b/src/dm_mac/views/machine.py index d3ac812..907d161 100644 --- a/src/dm_mac/views/machine.py +++ b/src/dm_mac/views/machine.py @@ -108,7 +108,7 @@ async def update() -> Tuple[Response, int]: } """ data: Dict[str, Any] = cast(Dict[str, Any], await request.json) # noqa - logger.warning("UPDATE request: %s", data) + logger.info("UPDATE request: %s", data) machine_name: str = data.pop("machine_name") mconf: MachinesConfig = current_app.config["MACHINES"] # noqa machine: Optional[Machine] = mconf.machines_by_name.get(machine_name) @@ -118,7 +118,7 @@ async def update() -> Tuple[Response, int]: if data.get("rfid_value") == "": data["rfid_value"] = None try: - resp = machine.update(users, **data) + resp = await machine.update(users, **data) return jsonify(resp), 200 except Exception as ex: logger.error("Error in machine update %s: %s", data, ex, exc_info=True) @@ -136,9 +136,9 @@ async def oops(machine_name: str) -> Tuple[Response, int]: return jsonify({"error": f"No such machine: {machine_name}"}), 404 try: if method == "DELETE": - machine.unoops() + await machine.unoops() else: - machine.oops() + await machine.oops() machine.state._save_cache() return jsonify({"success": True}), 200 except Exception as ex: @@ -163,9 +163,9 @@ async def locked_out(machine_name: str) -> Tuple[Response, int]: return jsonify({"error": f"No such machine: {machine_name}"}), 404 try: if method == "DELETE": - machine.unlock() + await machine.unlock() else: - machine.lockout() + await machine.lockout() machine.state._save_cache() return jsonify({"success": True}), 200 except Exception as ex: diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..3fa1818 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,106 @@ +from typing import List +from unittest.mock import DEFAULT +from unittest.mock import MagicMock +from unittest.mock import call +from unittest.mock import patch + +from dm_mac import asyncio_exception_handler +from dm_mac import main + + +def test_exception_handler(): + with patch("dm_mac.logger") as m_logger: + asyncio_exception_handler(None, {"exception": "exc", "message": "msg"}) + assert m_logger.mock_calls == [ + call.error("Task failed, msg=msg, exception=exc") + ] + + +class TestMain: + + @patch("sys.argv", ["mac-server"]) + @patch.dict("os.environ", {"SLACK_APP_TOKEN": "app-token"}) + def test_with_slack(self): + mocks: List[MagicMock] + with patch.multiple( + "dm_mac", + set_log_debug=DEFAULT, + set_log_info=DEFAULT, + get_event_loop=DEFAULT, + SlackHandler=DEFAULT, + AsyncSocketModeHandler=DEFAULT, + logger=DEFAULT, + create_app=DEFAULT, + new_callable=MagicMock, + ) as mocks: + slack_app = MagicMock() + type(mocks["SlackHandler"].return_value).app = slack_app + loop = MagicMock() + app = MagicMock() + handler = MagicMock() + mocks["get_event_loop"].return_value = loop + mocks["create_app"].return_value = app + mocks["AsyncSocketModeHandler"].return_value = handler + main() + assert mocks["set_log_debug"].mock_calls == [] + assert mocks["set_log_info"].mock_calls == [call(mocks["logger"])] + assert mocks["create_app"].mock_calls == [ + call(), + call().config.update({"SLACK_HANDLER": mocks["SlackHandler"].return_value}), + call().run(loop=loop, debug=False, host="0.0.0.0"), + ] + assert app.mock_calls == [ + call.config.update({"SLACK_HANDLER": mocks["SlackHandler"].return_value}), + call.run(loop=loop, debug=False, host="0.0.0.0"), + ] + assert mocks["SlackHandler"].mock_calls == [call(app)] + assert mocks["AsyncSocketModeHandler"].mock_calls == [ + call(slack_app, "app-token", loop=loop), + call().start_async(), + ] + assert loop.mock_calls == [ + call.set_exception_handler(asyncio_exception_handler), + call.create_task(handler.start_async()), + ] + assert app.mock_calls == [ + call.config.update({"SLACK_HANDLER": mocks["SlackHandler"].return_value}), + call.run(loop=loop, debug=False, host="0.0.0.0"), + ] + + @patch("sys.argv", ["mac-server", "-v"]) + @patch.dict("os.environ", {"SLACK_APP_TOKEN": ""}) + def test_without_slack(self): + mocks: List[MagicMock] + with patch.multiple( + "dm_mac", + set_log_debug=DEFAULT, + set_log_info=DEFAULT, + get_event_loop=DEFAULT, + SlackHandler=DEFAULT, + AsyncSocketModeHandler=DEFAULT, + logger=DEFAULT, + create_app=DEFAULT, + new_callable=MagicMock, + ) as mocks: + slack_app = MagicMock() + type(mocks["SlackHandler"].return_value).app = slack_app + loop = MagicMock() + app = MagicMock() + handler = MagicMock() + mocks["get_event_loop"].return_value = loop + mocks["create_app"].return_value = app + mocks["AsyncSocketModeHandler"].return_value = handler + main() + assert mocks["set_log_debug"].mock_calls == [call(mocks["logger"])] + assert mocks["set_log_info"].mock_calls == [] + assert mocks["create_app"].mock_calls == [ + call(), + call().run(loop=loop, debug=False, host="0.0.0.0"), + ] + assert app.mock_calls == [call.run(loop=loop, debug=False, host="0.0.0.0")] + assert mocks["SlackHandler"].mock_calls == [] + assert mocks["AsyncSocketModeHandler"].mock_calls == [] + assert loop.mock_calls == [ + call.set_exception_handler(asyncio_exception_handler), + ] + assert app.mock_calls == [call.run(loop=loop, debug=False, host="0.0.0.0")] diff --git a/tests/test_slack_handler.py b/tests/test_slack_handler.py new file mode 100644 index 0000000..aa850e5 --- /dev/null +++ b/tests/test_slack_handler.py @@ -0,0 +1,700 @@ +"""Tests for SlackHandler class.""" + +import os +from pathlib import Path +from time import time +from typing import Any +from typing import Dict +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import Mock +from unittest.mock import call +from unittest.mock import patch + +from freezegun import freeze_time +from quart import Quart +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay + +from dm_mac.models.machine import MachinesConfig +from dm_mac.models.users import UsersConfig +from dm_mac.slack_handler import Message +from dm_mac.slack_handler import SlackHandler + + +pbm = "dm_mac.slack_handler" + + +class TestMessage: + """Test the Message helper class.""" + + def test_message(self) -> None: + """Test it.""" + expected = Message( + text="my text", + user_id="U1111", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + assert expected.as_dict == { + "raw_text": "my text", + "command": ["text"], + "user_id": "U1111", + "user_name": "User Name", + "user_handle": "displayName", + "channel_id": "Cadmin", + "channel_name": "AdminChannel", + } + assert expected == Message( + text="my text", + user_id="U1111", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + assert expected != Message( + text="other text", + user_id="U1111", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + assert expected != "another type" + + +class TestSlackHandler: + """Test SlackHandler app mention.""" + + @patch.dict( + "os.environ", + { + "SLACK_CONTROL_CHANNEL_ID": "Cadmin", + "SLACK_OOPS_CHANNEL_ID": "Coops", + "SLACK_BOT_TOKEN": "btoken", + "SLACK_SIGNING_SECRET": "secret", + }, + ) + def setup_method(self, _) -> None: + """Setup for each method in the class.""" + self.quart_app = Mock(spec_set=Quart) + self.slack_app = AsyncMock(spec_set=AsyncApp) + self.slack_client = AsyncMock() + type(self.slack_app).client = self.slack_client + + async def users_info(user=None): + res = MagicMock() + if user == "U1111": + res.data = { + "ok": True, + "user": { + "is_bot": False, + "is_app_user": False, + "profile": { + "real_name_normalized": "User Name", + "display_name_normalized": "displayName", + }, + }, + } + elif user == "U2222": + res.data = { + "ok": True, + "user": { + "is_bot": True, + "is_app_user": False, + "profile": { + "real_name_normalized": "Bot Name", + "display_name_normalized": "botName", + }, + }, + } + else: + res.data = {"ok": False} + return res + + async def conversations_info(channel=None): + res = MagicMock() + res.data = {"ok": False} + if channel == "Cadmin": + res.data = {"ok": True, "channel": {"name": "AdminChannel"}} + elif channel == "Coops": + res.data = {"ok": True, "channel": {"name": "OopsChannel"}} + return res + + self.slack_client.conversations_info.side_effect = conversations_info + self.slack_client.users_info.side_effect = users_info + with patch(f"{pbm}.AsyncApp") as self.m_async: + self.m_async.return_value = self.slack_app + self.cls = SlackHandler(self.quart_app) + + def test_init(self) -> None: + """Test class __init__ method.""" + assert self.cls.control_channel_id == "Cadmin" + assert self.cls.oops_channel_id == "Coops" + assert self.cls.quart == self.quart_app + assert self.m_async.mock_calls == [ + call(token="btoken", signing_secret="secret"), + call().event("app_mention"), + call().event("app_mention")(self.cls.app_mention), + ] + assert self.slack_app.mock_calls == [ + call.event("app_mention"), + call.event("app_mention")(self.cls.app_mention), + ] + assert self.cls.app == self.slack_app + + async def test_app_mention_valid_command(self) -> None: + """Test when the app receives a valid command.""" + msg = "<@U12345678> status" + body: Dict[str, Any] = { + "event": { + "text": msg, + "user": "U1111", + "channel": "Cadmin", + }, + "authorizations": [{"user_id": "U12345678"}], + } + say = AsyncMock(spec_set=AsyncSay) + with patch( + f"{pbm}.SlackHandler.handle_command", new_callable=AsyncMock + ) as m_handle: + await self.cls.app_mention(body, say) + expected = Message( + text=msg, + user_id="U1111", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + assert m_handle.mock_calls == [call(expected, say)] + assert self.slack_client.mock_calls == [ + call.users_info(user="U1111"), + call.conversations_info(channel="Cadmin"), + ] + + async def test_app_mention_invalid_message_start(self) -> None: + """Test when the app receives a valid command.""" + msg = "notMyUserId status" + body: Dict[str, Any] = { + "event": { + "text": msg, + "user": "U1111", + "channel": "Cadmin", + }, + "authorizations": [{"user_id": "U12345678"}], + } + say = AsyncMock(spec_set=AsyncSay) + with patch( + f"{pbm}.SlackHandler.handle_command", new_callable=AsyncMock + ) as m_handle: + await self.cls.app_mention(body, say) + assert m_handle.mock_calls == [] + assert self.slack_client.mock_calls == [] + + async def test_app_mention_from_bot(self) -> None: + """Test when the app receives a valid command.""" + msg = "<@U12345678> status" + body: Dict[str, Any] = { + "event": { + "text": msg, + "user": "U2222", + "channel": "Cadmin", + }, + "authorizations": [{"user_id": "U12345678"}], + } + say = AsyncMock(spec_set=AsyncSay) + with patch( + f"{pbm}.SlackHandler.handle_command", new_callable=AsyncMock + ) as m_handle: + await self.cls.app_mention(body, say) + assert m_handle.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.users_info(user="U2222"), + call.conversations_info(channel="Cadmin"), + ] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_status_admin_channel(self, tmp_path) -> None: + """Status request from someone in admin channel.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = True + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + mconf.machines_by_name["metal-mill"].state.relay_desired_state = False + mconf.machines_by_name["metal-mill"].state.last_checkin = time() - 10 + mconf.machines_by_name["metal-mill"].state.last_update = time() - 90 + mconf.machines_by_name["metal-mill"].state.uptime = 86400 + mconf.machines_by_name["hammer"].state.is_oopsed = False + mconf.machines_by_name["hammer"].state.is_locked_out = False + mconf.machines_by_name["hammer"].state.relay_desired_state = False + mconf.machines_by_name["hammer"].state.last_checkin = time() - 60 + mconf.machines_by_name["hammer"].state.last_update = time() - 60 + mconf.machines_by_name["hammer"].state.uptime = 120 + mconf.machines_by_name["permissive-lathe"].state.is_oopsed = False + mconf.machines_by_name["permissive-lathe"].state.is_locked_out = True + mconf.machines_by_name["permissive-lathe"].state.relay_desired_state = False + mconf.machines_by_name["permissive-lathe"].state.last_checkin = ( + time() - (86400 * 7) + 240 + ) + mconf.machines_by_name["permissive-lathe"].state.last_update = time() - ( + 86400 * 7 + ) + mconf.machines_by_name["permissive-lathe"].state.uptime = 360 + mconf.machines_by_name["restrictive-lathe"].state.is_oopsed = False + mconf.machines_by_name["restrictive-lathe"].state.is_locked_out = False + mconf.machines_by_name["restrictive-lathe"].state.relay_desired_state = True + mconf.machines_by_name["restrictive-lathe"].state.last_checkin = time() - 10 + mconf.machines_by_name["restrictive-lathe"].state.last_update = time() - 600 + mconf.machines_by_name["restrictive-lathe"].state.uptime = 3603 + msg = Message( + text="<@U12345678> status", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + expected = ( + "esp32test: Idle \n" + "hammer: Idle (last contact a minute ago; last update a minute ago;" + " uptime 2 minutes)\n" + "metal-mill: Oopsed (last contact 10 seconds ago; last update a " + "minute ago; uptime a day)\n" + "permissive-lathe: Locked out (last contact 6 days ago; " + "last update 7 days ago; uptime 6 minutes)\n" + "restrictive-lathe: In use (last contact 10 seconds ago; " + "last update 10 minutes ago; uptime an hour)\n" + ) + assert say.mock_calls == [call(expected)] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_status_oops_channel(self, tmp_path) -> None: + """Status request from someone in oops channel.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = True + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + mconf.machines_by_name["metal-mill"].state.relay_desired_state = False + mconf.machines_by_name["metal-mill"].state.last_checkin = time() - 10 + mconf.machines_by_name["metal-mill"].state.last_update = time() - 90 + mconf.machines_by_name["metal-mill"].state.uptime = 86400 + mconf.machines_by_name["hammer"].state.is_oopsed = False + mconf.machines_by_name["hammer"].state.is_locked_out = False + mconf.machines_by_name["hammer"].state.relay_desired_state = False + mconf.machines_by_name["hammer"].state.last_checkin = time() - 60 + mconf.machines_by_name["hammer"].state.last_update = time() - 60 + mconf.machines_by_name["hammer"].state.uptime = 120 + mconf.machines_by_name["permissive-lathe"].state.is_oopsed = False + mconf.machines_by_name["permissive-lathe"].state.is_locked_out = True + mconf.machines_by_name["permissive-lathe"].state.relay_desired_state = False + mconf.machines_by_name["permissive-lathe"].state.last_checkin = ( + time() - (86400 * 7) + 240 + ) + mconf.machines_by_name["permissive-lathe"].state.last_update = time() - ( + 86400 * 7 + ) + mconf.machines_by_name["permissive-lathe"].state.uptime = 360 + mconf.machines_by_name["restrictive-lathe"].state.is_oopsed = False + mconf.machines_by_name["restrictive-lathe"].state.is_locked_out = False + mconf.machines_by_name["restrictive-lathe"].state.relay_desired_state = True + mconf.machines_by_name["restrictive-lathe"].state.last_checkin = time() - 10 + mconf.machines_by_name["restrictive-lathe"].state.last_update = time() - 600 + mconf.machines_by_name["restrictive-lathe"].state.uptime = 3603 + msg = Message( + text="<@U12345678> status", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Coops", + channel_name="OopsChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + expected = ( + "esp32test: Idle \n" + "hammer: Idle (last contact a minute ago; " + "last update a minute ago; uptime 2 minutes)\n" + "metal-mill: Oopsed (last contact 10 seconds ago; " + "last update a minute ago; uptime a day)\n" + "permissive-lathe: Locked out (last contact 6 days ago; " + "last update 7 days ago; uptime 6 minutes)\n" + "restrictive-lathe: In use (last contact 10 seconds ago; " + "last update 10 minutes ago; uptime an hour)\n" + ) + assert say.mock_calls == [call(expected)] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_help(self, tmp_path) -> None: + """Help or unknown command.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + msg = Message( + text="<@U12345678> help", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [call(self.cls.HELP_RESPONSE)] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_oops(self, tmp_path) -> None: + """Oops command.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> oops metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", + text="Machine metal-mill oopsed via Slack by unknown user.", + ), + call.chat_postMessage( + channel="Coops", text="Machine metal-mill has been Oops'ed!" + ), + ] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is True + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_oops_already_oopsed(self, tmp_path) -> None: + """Oops command when already oopsed.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = True + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> oops metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [call("Machine metal-mill is already oopsed.")] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is True + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_oops_invalid_machine(self, tmp_path) -> None: + """Oops command with invalid machine name.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> oops invalid-name", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [ + call( + "Invalid machine name 'invalid-name'. " + "Use status command to list all machines." + ) + ] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_lock(self, tmp_path) -> None: + """Lock command.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> lock metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", text="Machine metal-mill locked-out via Slack." + ), + call.chat_postMessage( + channel="Coops", + text="Machine metal-mill is locked-out for maintenance.", + ), + ] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_locked_out is True + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_lock_already_locked(self, tmp_path) -> None: + """Lock command when already locked.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = True + msg = Message( + text="<@U12345678> lock metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [call("Machine metal-mill is already locked-out.")] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_locked_out is True + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_lock_invalid_machine(self, tmp_path) -> None: + """Lock command with invalid machine name.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> lock invalid-name", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [ + call( + "Invalid machine name 'invalid-name'. " + "Use status command to list all machines." + ) + ] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_clear_when_oops(self, tmp_path) -> None: + """Clear command when oopsed.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = True + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> clear metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", text="Machine metal-mill un-oopsed via Slack." + ), + call.chat_postMessage( + channel="Coops", text="Machine metal-mill oops has been cleared." + ), + ] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is False + assert mconf.machines_by_name["metal-mill"].state.is_locked_out is False + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_clear_when_locked(self, tmp_path) -> None: + """Clear command when locked.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = True + msg = Message( + text="<@U12345678> clear metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", + text="Machine metal-mill locked-out cleared via Slack.", + ), + call.chat_postMessage( + channel="Coops", + text="Machine metal-mill is no longer locked-out for " "maintenance.", + ), + ] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is False + assert mconf.machines_by_name["metal-mill"].state.is_locked_out is False + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_clear_when_clear(self, tmp_path) -> None: + """Clear command when already clear.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> clear metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [ + call("Machine metal-mill is not oopsed or locked-out.") + ] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + assert mconf.machines_by_name["metal-mill"].state.is_oopsed is False + assert mconf.machines_by_name["metal-mill"].state.is_locked_out is False + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_clear_invalid_machine(self, tmp_path) -> None: + """Clear command with invalid machine name.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + mconf: MachinesConfig = self.quart_app.config["MACHINES"] + mconf.machines_by_name["metal-mill"].state.is_oopsed = False + mconf.machines_by_name["metal-mill"].state.is_locked_out = False + msg = Message( + text="<@U12345678> clear invalid-name", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Cadmin", + channel_name="AdminChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [ + call( + "Invalid machine name 'invalid-name'. " + "Use status command to list all machines." + ) + ] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_handle_command_non_admin_channel(self, tmp_path) -> None: + """Admin-only command from a non-admin channel.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + msg = Message( + text="<@U12345678> clear metal-mill", + user_id="U5678", + user_name="User Name", + user_handle="displayName", + channel_id="Coops", + channel_name="OopsChannel", + ) + say = AsyncMock() + await self.cls.handle_command(msg, say) + assert say.mock_calls == [] + assert self.slack_client.mock_calls == [] + assert self.slack_app.mock_calls == [] + + @freeze_time("2023-07-16 03:14:08", tz_offset=0) + async def test_admin_log(self, tmp_path) -> None: + """Admin log method.""" + self.slack_app.reset_mock() + self.slack_client.reset_mock() + setup_machines(tmp_path, self) + await self.cls.admin_log("some message") + assert self.slack_client.mock_calls == [ + call.chat_postMessage( + channel="Cadmin", + text="some message", + ), + ] + assert self.slack_app.mock_calls == [] + + +def setup_machines(fixture_dir: Path, test_class: TestSlackHandler) -> None: + fpath: str = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + ) + with patch.dict( + "os.environ", + { + "USERS_CONFIG": os.path.join(fpath, "users.json"), + "MACHINES_CONFIG": os.path.join(fpath, "machines.json"), + "MACHINE_STATE_DIR": str(os.path.join(fixture_dir, "machine_state")), + }, + ): + type(test_class.quart_app).config = { + "MACHINES": MachinesConfig(), + "USERS": UsersConfig(), + "SLACK_HANDLER": test_class.cls, + } diff --git a/tests/test_utils.py b/tests/test_utils.py index cf27669..082ccb0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,7 @@ """Tests for dm_mac.utils module.""" +import logging +from unittest.mock import Mock from unittest.mock import call from unittest.mock import mock_open from unittest.mock import patch @@ -7,6 +9,9 @@ import pytest from dm_mac.utils import load_json_config +from dm_mac.utils import set_log_debug +from dm_mac.utils import set_log_info +from dm_mac.utils import set_log_level_format pbm = "dm_mac.utils" @@ -67,3 +72,41 @@ def test_does_not_exist(self) -> None: "path to your config file." ) assert exc.value.args[0] == expected + + +class TestLogHelpers: + + def test_set_log_info(self) -> None: + mock_log = Mock(spec_set=logging.Logger) + with patch("%s.set_log_level_format" % pbm, autospec=True) as mock_set: + set_log_info(mock_log) + assert mock_set.mock_calls == [ + call( + mock_log, logging.INFO, "%(asctime)s %(levelname)s:%(name)s:%(message)s" + ) + ] + + def test_set_log_debug(self) -> None: + mock_log = Mock(spec_set=logging.Logger) + with patch("%s.set_log_level_format" % pbm, autospec=True) as mock_set: + set_log_debug(mock_log) + assert mock_set.mock_calls == [ + call( + mock_log, + logging.DEBUG, + "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " + "%(name)s.%(funcName)s() ] %(message)s", + ) + ] + + def test_set_log_level_format(self) -> None: + mock_log = Mock(spec_set=logging.Logger) + mock_handler = Mock(spec_set=logging.Handler) + type(mock_log).handlers = [mock_handler] + with patch("%s.logging.Formatter" % pbm, autospec=True) as mock_formatter: + set_log_level_format(mock_log, 5, "foo") + assert mock_formatter.mock_calls == [call(fmt="foo")] + assert mock_handler.mock_calls == [ + call.setFormatter(mock_formatter.return_value) + ] + assert mock_log.mock_calls == [call.setLevel(5)] diff --git a/tests/views/test_machine.py b/tests/views/test_machine.py index 509104b..c967482 100644 --- a/tests/views/test_machine.py +++ b/tests/views/test_machine.py @@ -1,6 +1,8 @@ """Tests for /machine API endpoints.""" from pathlib import Path +from unittest.mock import AsyncMock +from unittest.mock import call from unittest.mock import patch from freezegun import freeze_time @@ -10,6 +12,7 @@ from dm_mac.models.machine import Machine from dm_mac.models.machine import MachineState +from dm_mac.slack_handler import SlackHandler from .quart_test_helpers import app_and_client @@ -279,6 +282,62 @@ async def test_oops_without_user(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (1.0, 0.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + async def test_oops_without_user_slack(self, tmp_path: Path) -> None: + """Test oops button pressed with no user logged in.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": True, + "rfid_value": "", + "uptime": 12.3, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0, 0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.OOPS_DISPLAY_TEXT + assert ms.current_amps == 0 + assert ms.uptime == 12.3 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is True + assert ms.is_locked_out is False + assert ms.rfid_value is None + assert ms.rfid_present_since is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.log_oops(m, "Oops button without RFID present", user_name=None) + ] + async def test_oops_released_without_user(self, tmp_path: Path) -> None: """Test nothing different happens when oops button is released.""" # boilerplate for test @@ -400,6 +459,75 @@ async def test_oops_with_user(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (1.0, 0.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + async def test_oops_with_user_slack(self, tmp_path: Path) -> None: + """Test oops button pressed with a user logged in (and relay on).""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "hammer" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + m.state.relay_desired_state = True + m.state.rfid_present_since = 1689477200.0 + m.state.rfid_value = "0091703745" + m.state.current_user = app.config["USERS"].users_by_fob["0091703745"] + m.state.status_led_rgb = (0.0, 1.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + m.state.display_text = "Welcome,\nPKenneth" + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": True, + "rfid_value": "91703745", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.OOPS_DISPLAY_TEXT + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is True + assert ms.is_locked_out is False + assert ms.rfid_value == "0091703745" + assert ms.rfid_present_since == 1689477200.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.log_oops( + m, "Oops button with RFID present", user_name="Kenneth Hunter" + ) + ] + async def test_oops_released_with_user(self, tmp_path: Path) -> None: """Test nothing different happens when oops button is released.""" # boilerplate for test @@ -541,7 +669,8 @@ async def test_lockout_no_user(self, tmp_path: Path) -> None: m: Machine = app.config["MACHINES"].machines_by_name[mname] m.state.last_update = 1689477248.0 m.state.last_checkin = 1689477248.0 - m.lockout() + with patch("dm_mac.models.machine.current_app", app): + await m.lockout() # send request response: Response = await client.post( "/api/machine/update", @@ -603,7 +732,8 @@ async def test_lockout_with_user(self, tmp_path: Path) -> None: m.state.status_led_rgb = (0.0, 1.0, 0.0) m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS m.state.display_text = "Welcome,\nPKenneth" - m.lockout() + with patch("dm_mac.models.machine.current_app", app): + await m.lockout() # send request response: Response = await client.post( "/api/machine/update", @@ -664,7 +794,8 @@ async def test_unlock(self, tmp_path: Path) -> None: m.state.display_text = MachineState.LOCKOUT_DISPLAY_TEXT m.state.status_led_rgb = (1.0, 0.5, 0.0) m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS - m.unlock() + with patch("dm_mac.models.machine.current_app", app): + await m.unlock() # send request response: Response = await client.post( "/api/machine/update", @@ -712,12 +843,76 @@ async def test_unlock(self, tmp_path: Path) -> None: class TestReboot: """Test when the machine reboots.""" - async def test_reboot_with_user(self, tmp_path: Path) -> None: + async def test_reboot_without_slack(self, tmp_path: Path) -> None: + """Test reboot with a user logged in (and relay on).""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + # set up state + mname: str = "hammer" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 12345.6 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + m.state.relay_desired_state = True + m.state.rfid_present_since = 1689477200.0 + m.state.rfid_value = "0091703745" + m.state.current_user = app.config["USERS"].users_by_fob["0091703745"] + m.state.status_led_rgb = (0.0, 1.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + m.state.display_text = "Welcome,\nPKenneth" + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "91703745", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.DEFAULT_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 0.0, 0.0], + "status_led_brightness": 0.0, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.DEFAULT_DISPLAY_TEXT + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value == "0091703745" + assert ms.rfid_present_since == 1689477200.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477200.0 + assert ms.status_led_rgb == (0.0, 0.0, 0.0) + assert ms.status_led_brightness == 0.0 + + async def test_reboot_with_slack(self, tmp_path: Path) -> None: """Test reboot with a user logged in (and relay on).""" # boilerplate for test app: Quart client: TestClientProtocol app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) # set up state mname: str = "hammer" m: Machine = app.config["MACHINES"].machines_by_name[mname] @@ -773,10 +968,11 @@ async def test_reboot_with_user(self, tmp_path: Path) -> None: assert ms.last_update == 1689477200.0 assert ms.status_led_rgb == (0.0, 0.0, 0.0) assert ms.status_led_brightness == 0.0 + assert slack.mock_calls == [call.admin_log("Machine hammer has rebooted.")] @freeze_time("2023-07-16 03:14:08", tz_offset=0) -class TestRfidNormalState: +class TestRfidChanged: """Tests for RFID value changed in a normal state. i.e. not oopsed, locked out, or set to unauthorized_warn_only. @@ -834,6 +1030,65 @@ async def test_rfid_authorized_inserted(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (0.0, 1.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: + """Test when an authorized RFID card is inserted.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "8114346998", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": True, + "display": "Welcome,\nPAshley", + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == "Welcome,\nPAshley" + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value == "8114346998" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user == app.config["USERS"].users_by_fob["8114346998"] + assert ms.relay_desired_state is True + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (0.0, 1.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.admin_log( + "RFID login on metal-mill by authorized user Ashley Williams" + ) + ] + async def test_rfid_authorized_inserted_zeropad(self, tmp_path: Path) -> None: """Test when an auth RFID card is inserted but < 10 characters.""" # boilerplate for test @@ -951,6 +1206,76 @@ async def test_rfid_authorized_removed(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (0.0, 0.0, 0.0) assert ms.status_led_brightness == 0.0 + async def test_rfid_authorized_removed_slack(self, tmp_path: Path) -> None: + """Test when an authorized RFID card is removed.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "hammer" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + m.state.relay_desired_state = True + m.state.rfid_present_since = 1689477200.0 + m.state.rfid_value = "0091703745" + m.state.current_user = app.config["USERS"].users_by_fob["0091703745"] + m.state.status_led_rgb = (0.0, 1.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + m.state.display_text = "Welcome,\nPKenneth" + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.DEFAULT_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 0.0, 0.0], + "status_led_brightness": 0.0, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.DEFAULT_DISPLAY_TEXT + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value is None + assert ms.rfid_present_since is None + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (0.0, 0.0, 0.0) + assert ms.status_led_brightness == 0.0 + assert slack.mock_calls == [ + call.admin_log( + "RFID logout on hammer by Kenneth Hunter; session " + "duration 48 seconds" + ) + ] + async def test_rfid_unauthorized_inserted_zeropad(self, tmp_path: Path) -> None: """Test when an unauth RFID card is inserted but < 10 characters.""" # boilerplate for test @@ -1006,6 +1331,71 @@ async def test_rfid_unauthorized_inserted_zeropad(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (1.0, 0.5, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + async def test_rfid_unauthorized_inserted_zeropad_slack( + self, tmp_path: Path + ) -> None: + """Test when an unauth RFID card is inserted but < 10 characters.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "91703745", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": "Unauthorized", + "oops_led": False, + "status_led_rgb": [1.0, 0.5, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == "Unauthorized" + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value == "0091703745" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (1.0, 0.5, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.admin_log( + "rejected RFID login on metal-mill by UNAUTHORIZED user " + "Kenneth Hunter" + ) + ] + async def test_rfid_unauthorized_removed(self, tmp_path: Path) -> None: """Test when an unauthorized RFID card is removed.""" # boilerplate for test @@ -1123,12 +1513,136 @@ async def test_rfid_unknown_inserted(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (1.0, 0.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS - async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: + async def test_rfid_unknown_inserted_slack(self, tmp_path: Path) -> None: + """Test when an unknown RFID card is inserted.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "0123456789", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": "Unknown RFID", + "oops_led": False, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == "Unknown RFID" + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value == "0123456789" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.admin_log("RFID login attempt on metal-mill by unknown fob") + ] + + async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: + """Test when an unknown RFID card is removed.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + # set up state + mname: str = "hammer" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + m.state.relay_desired_state = False + m.state.rfid_present_since = 1689477200.0 + m.state.rfid_value = "0123456789" + m.state.current_user = None + m.state.status_led_rgb = (1.0, 0.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + m.state.display_text = "Unknown RFID" + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.DEFAULT_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [0.0, 0.0, 0.0], + "status_led_brightness": 0.0, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == MachineState.DEFAULT_DISPLAY_TEXT + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value is None + assert ms.rfid_present_since is None + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (0.0, 0.0, 0.0) + assert ms.status_led_brightness == 0.0 + + async def test_rfid_unknown_removed_slack(self, tmp_path: Path) -> None: """Test when an unknown RFID card is removed.""" # boilerplate for test app: Quart client: TestClientProtocol app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) # set up state mname: str = "hammer" m: Machine = app.config["MACHINES"].machines_by_name[mname] @@ -1184,6 +1698,11 @@ async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: assert ms.last_update == 1689477248.0 assert ms.status_led_rgb == (0.0, 0.0, 0.0) assert ms.status_led_brightness == 0.0 + assert slack.mock_calls == [ + call.admin_log( + "RFID logout on hammer by unknown; session " "duration 48 seconds" + ) + ] @freeze_time("2023-07-16 03:14:08", tz_offset=0) @@ -1359,6 +1878,75 @@ async def test_rfid_unauthorized_inserted_zeropad(self, tmp_path: Path) -> None: assert ms.status_led_rgb == (0.0, 1.0, 0.0) assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + async def test_rfid_unauthorized_inserted_zeropad_slack( + self, tmp_path: Path + ) -> None: + """Test when an unauth RFID card is inserted but < 10 characters.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "permissive-lathe" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "91703745", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": True, + "display": "Welcome,\nPKenneth", + "oops_led": False, + "status_led_rgb": [0.0, 1.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.display_text == "Welcome,\nPKenneth" + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_oopsed is False + assert ms.is_locked_out is False + assert ms.rfid_value == "0091703745" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user == app.config["USERS"].users_by_fob["0091703745"] + assert ms.relay_desired_state is True + assert ms.last_update == 1689477248.0 + assert ms.status_led_rgb == (0.0, 1.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert slack.mock_calls == [ + call.admin_log( + "WARNING - Authorizing user Kenneth Hunter for " + "permissive-lathe based on unauthorized_warn_only setting " + "for machine. User is NOT authorized for this machine." + ), + call.admin_log( + "RFID login on permissive-lathe by authorized user " "Kenneth Hunter" + ), + ] + async def test_rfid_unauthorized_removed(self, tmp_path: Path) -> None: """Test when an unauthorized RFID card is removed.""" # boilerplate for test @@ -1540,7 +2128,7 @@ async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: @freeze_time("2023-07-16 03:14:08", tz_offset=0) -class TestRfidOopsed: +class TestRfidChangedWhenOopsed: """Tests for RFID value changed when oopsed.""" async def test_rfid_authorized_inserted(self, tmp_path: Path) -> None: @@ -1599,6 +2187,69 @@ async def test_rfid_authorized_inserted(self, tmp_path: Path) -> None: assert ms.relay_desired_state is False assert ms.last_update == 1689477248.0 + async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: + """Test when an authorized RFID card is inserted.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.is_oopsed = True + m.state.display_text = MachineState.OOPS_DISPLAY_TEXT + m.state.status_led_rgb = (1.0, 0.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "8114346998", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.is_oopsed is True + assert ms.display_text == MachineState.OOPS_DISPLAY_TEXT + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_locked_out is False + assert ms.rfid_value == "8114346998" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert slack.mock_calls == [ + call.admin_log( + "RFID login attempt on metal-mill by Ashley Williams " "when oopsed." + ) + ] + async def test_rfid_authorized_inserted_zeropad(self, tmp_path: Path) -> None: """Test when an auth RFID card is inserted but < 10 characters.""" # boilerplate for test @@ -1902,6 +2553,73 @@ async def test_rfid_unknown_inserted(self, tmp_path: Path) -> None: assert ms.relay_desired_state is False assert ms.last_update == 1689477248.0 + async def test_rfid_unknown_inserted_slack(self, tmp_path: Path) -> None: + """Test when an unknown RFID card is inserted.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.uptime = 10.3 + m.state.last_update = 1689477200.0 + m.state.last_checkin = 1689477200.0 + m.state.is_oopsed = True + m.state.display_text = MachineState.OOPS_DISPLAY_TEXT + m.state.status_led_rgb = (1.0, 0.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "0123456789", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.OOPS_DISPLAY_TEXT, + "oops_led": True, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.is_oopsed is True + assert ms.display_text == MachineState.OOPS_DISPLAY_TEXT + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_locked_out is False + assert ms.rfid_value == "0123456789" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert slack.mock_calls == [ + call.admin_log( + "RFID login attempt on metal-mill by unknown fob when oopsed " + "or locked out." + ) + ] + async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: """Test when an unknown RFID card is removed.""" # boilerplate for test @@ -1967,7 +2685,7 @@ async def test_rfid_unknown_removed(self, tmp_path: Path) -> None: @freeze_time("2023-07-16 03:14:08", tz_offset=0) -class TestRfidLockedOut: +class TestRfidChangedWhenLockedOut: """Tests for RFID value changed when locked out.""" async def test_rfid_authorized_inserted(self, tmp_path: Path) -> None: @@ -2026,6 +2744,70 @@ async def test_rfid_authorized_inserted(self, tmp_path: Path) -> None: assert ms.relay_desired_state is False assert ms.last_update == 1689477248.0 + async def test_rfid_authorized_inserted_slack(self, tmp_path: Path) -> None: + """Test when an authorized RFID card is inserted.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + slack = AsyncMock(spec_set=SlackHandler) + app.config.update({"SLACK_HANDLER": slack}) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + m.state.is_locked_out = True + m.state.display_text = MachineState.LOCKOUT_DISPLAY_TEXT + m.state.status_led_rgb = (1.0, 0.0, 0.0) + m.state.status_led_brightness = MachineState.STATUS_LED_BRIGHTNESS + # send request + response: Response = await client.post( + "/api/machine/update", + json={ + "machine_name": mname, + "oops": False, + "rfid_value": "8114346998", + "uptime": 13.6, + "wifi_signal_db": -54, + "wifi_signal_percent": 92, + "internal_temperature_c": 53.89, + }, + ) + # check response + assert response.status_code == 200 + assert await response.json == { + "relay": False, + "display": MachineState.LOCKOUT_DISPLAY_TEXT, + "oops_led": False, + "status_led_rgb": [1.0, 0.0, 0.0], + "status_led_brightness": MachineState.STATUS_LED_BRIGHTNESS, + } + # boilerplate to read state from disk + with patch.dict("os.environ", {"MACHINE_STATE_DIR": m.state._state_dir}): + ms: MachineState = MachineState(m) + # verify state + assert ms.is_oopsed is False + assert ms.display_text == MachineState.LOCKOUT_DISPLAY_TEXT + assert ms.status_led_rgb == (1.0, 0.0, 0.0) + assert ms.status_led_brightness == MachineState.STATUS_LED_BRIGHTNESS + assert ms.current_amps == 0 + assert ms.uptime == 13.6 + assert ms.wifi_signal_db == -54 + assert ms.wifi_signal_percent == 92 + assert ms.internal_temperature_c == 53.89 + assert ms.last_checkin == 1689477248.0 + assert ms.is_locked_out is True + assert ms.rfid_value == "8114346998" + assert ms.rfid_present_since == 1689477248.0 + assert ms.current_user is None + assert ms.relay_desired_state is False + assert ms.last_update == 1689477248.0 + assert slack.mock_calls == [ + call.admin_log( + "RFID login attempt on metal-mill by Ashley Williams " + "when machine locked-out." + ) + ] + async def test_rfid_authorized_inserted_zeropad(self, tmp_path: Path) -> None: """Test when an auth RFID card is inserted but < 10 characters.""" # boilerplate for test @@ -2466,6 +3248,24 @@ async def test_oops_post_no_machine(self, tmp_path: Path) -> None: assert response.status_code == 404 assert await response.json == {"error": "No such machine: invalid-machine-name"} + async def test_oops_exception(self, tmp_path: Path) -> None: + """Test oops POST.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + assert m.state.is_oopsed is False + m.oops = RuntimeError() + # send request + response: Response = await client.post( + "/api/machine/oops/metal-mill", + ) + # check response + assert response.status_code == 500 + @freeze_time("2023-07-16 03:14:08", tz_offset=0) class TestLockApi: @@ -2539,3 +3339,21 @@ async def test_lockout_post_no_machine(self, tmp_path: Path) -> None: # check response assert response.status_code == 404 assert await response.json == {"error": "No such machine: invalid-machine-name"} + + async def test_lockout_post_exception(self, tmp_path: Path) -> None: + """Test lockout POST that raises an exception.""" + # boilerplate for test + app: Quart + client: TestClientProtocol + app, client = app_and_client(tmp_path) + # set up state + mname: str = "metal-mill" + m: Machine = app.config["MACHINES"].machines_by_name[mname] + assert m.state.is_oopsed is False + m.lockout = RuntimeError() + # send request + response: Response = await client.post( + "/api/machine/locked_out/metal-mill", + ) + # check response + assert response.status_code == 500 diff --git a/tests/views/test_prometheus.py b/tests/views/test_prometheus.py index 8c30510..ac620e1 100644 --- a/tests/views/test_prometheus.py +++ b/tests/views/test_prometheus.py @@ -14,10 +14,35 @@ from dm_mac.models.users import User from dm_mac.models.users import UsersConfig from dm_mac.views.prometheus import CONTENT_TYPE_LATEST +from dm_mac.views.prometheus import LabeledGaugeMetricFamily from .quart_test_helpers import app_and_client +class TestLabeledGaugeMetricFamily: + + def test_no_value_no_labels(self): + g = LabeledGaugeMetricFamily("name", "doc") + assert g.name == "name" + assert g.documentation == "doc" + assert g._labels == {} + assert len(g.samples) == 0 + + def test_with_labels(self): + g = LabeledGaugeMetricFamily("name", "doc", labels={"foo": "bar"}) + assert g.name == "name" + assert g.documentation == "doc" + assert g._labels == {"foo": "bar"} + assert len(g.samples) == 0 + + def test_with_value(self): + g = LabeledGaugeMetricFamily("name", "doc", value=1.23) + assert g.name == "name" + assert g.documentation == "doc" + assert g._labels == {} + assert len(g.samples) == 1 + + class TestPrometheus: """Tests for API Prometheus view."""