pax_global_header00006660000000000000000000000064152253503760014522gustar00rootroot0000000000000052 comment=0f0f993f088788d91dd693f4e9ec487c1fdeca2e slidge/000077500000000000000000000000001522535037600123355ustar00rootroot00000000000000slidge/.gitignore000066400000000000000000000004051522535037600143240ustar00rootroot00000000000000__pycache__ *.__pycache__ *.pyc dist/ .pytest_cache *.bak .vscode docs/build persistent/ .idea .mypy_cache .old requirements* .ruff_cache *.egg-info .coverage htmlcov /dev/slidge.sqlite .python-version slidge/__version__.py .zed docs/source/api/auto build/ slidge/.pre-commit-config.yaml000066400000000000000000000022421522535037600166160ustar00rootroot00000000000000default_stages: [pre-commit] repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - id: check-merge-conflict args: [--assume-in-merge] - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.10.3 hooks: - id: uv-lock - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.15.1 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.24.0 hooks: - id: commitlint stages: [commit-msg] additional_dependencies: ['@commitlint/config-conventional'] - repo: local hooks: - id: mypy name: Static type check with mypy entry: mypy language: system pass_filenames: false files: .*\.py - id: prettify-xml name: Prettify XML in test strings entry: python ./dev/prettify_tests.py language: system pass_filenames: true always_run: true files: tests.*\.py slidge/.woodpecker/000077500000000000000000000000001522535037600145555ustar00rootroot00000000000000slidge/.woodpecker/audit-dependencies.yaml000066400000000000000000000002451522535037600211740ustar00rootroot00000000000000when: event: [ push, pull_request ] labels: platform: linux/amd64 steps: audit: image: codeberg.org/slidge/slidge:ci-3.14 commands: - uv audit slidge/.woodpecker/container-ci.yaml000066400000000000000000000013401522535037600200120ustar00rootroot00000000000000# Build a container with a virtualenv that can be used for tests and to build docs. when: event: [ push, manual ] branch: main path: - Dockerfile - pyproject.toml - uv.lock - .woodpecker/container-ci.yaml matrix: PYTHON_VERSION: - "3.11" - "3.12" - "3.13" - "3.14" labels: platform: linux/amd64 steps: build-and-push: image: woodpeckerci/plugin-docker-buildx:6.0.2 settings: repo: codeberg.org/slidge/slidge registry: codeberg.org build_args: PYTHONVER: "${PYTHON_VERSION}" tag: ci-${PYTHON_VERSION} target: ci username: slidge password: from_secret: CODEBERG_TOKEN cache_from: codeberg.org/slidge/slidge:buildcache slidge/.woodpecker/docs.yaml000066400000000000000000000006321522535037600163720ustar00rootroot00000000000000when: event: [ push, tag ] path: [ "slidge/**/*.py", "superduper/**/*.py", "docs/**/*" ] labels: platform: linux/amd64 steps: build: image: codeberg.org/slidge/slidge:ci-3.13 commands: - uv sync --all-groups --all-extras - cd docs - make html publish: image: codeberg.org/slidge/woodpecker-publish-pages settings: token: from_secret: CODEBERG_TOKEN slidge/.woodpecker/lint.yaml000066400000000000000000000013051522535037600164060ustar00rootroot00000000000000when: event: [ pull_request ] labels: platform: linux/amd64 variables: - &python-changes image: codeberg.org/slidge/slidge:ci-3.11 when: path: [ pyproject.toml, uv.lock, "slidge/**/*.py", "tests/**/*.py" ] event: [ push, pull_request ] steps: update-venv: <<: *python-changes commands: - uv sync --all-groups --all-extras ruff: <<: *python-changes depends_on: [ update-venv ] commands: - ruff check - ruff format --check mypy: <<: *python-changes depends_on: [ update-venv ] commands: - mypy commitlint: image: codeberg.org/slidge/woodpecker-commitlint when: event: pull_request depends_on: [ ] slidge/.woodpecker/package.yaml000066400000000000000000000024631522535037600170410ustar00rootroot00000000000000# Build a source dist and a wheel. when: event: [ push, tag ] path: [ "slidge/**/*", "pyproject.toml", "uv.lock", "README.md" ] branch: main labels: platform: linux/amd64 # We do not need to build several packages for several python versions # since slidge is pure python. variables: - &image codeberg.org/slidge/slidge:ci-3.13 steps: changelog: image: codeberg.org/slidge/woodpecker-generate-changelog build: image: *image commands: - uv build codeberg-pypi: image: *image environment: CODEBERG_TOKEN: from_secret: CODEBERG_TOKEN # Here we don't want the whole workflow to fail if the file already exists. # This happens when we push commits and a new tag at the same time, resulting # in two packages with the same version identifier being produced by the "tag" # and "push" workflow. commands: - uv publish --index codeberg --token $CODEBERG_TOKEN || true pypi: when: event: tag image: *image environment: PYPI_TOKEN: from_secret: PYPI_TOKEN commands: - uv publish --token $PYPI_TOKEN codeberg-release: when: event: tag image: woodpeckerci/plugin-release settings: files: - dist/slidge* api_key: from_secret: CODEBERG_TOKEN note: CHANGELOG slidge/.woodpecker/test-postgres.yaml000066400000000000000000000010721522535037600202640ustar00rootroot00000000000000when: event: [ pull_request ] path: [ pyproject.toml, uv.lock, "slidge/**/*.py", "tests/**/*.py" ] labels: platform: linux/amd64 variables: - &ci-image codeberg.org/slidge/slidge:ci-3.14 services: - name: postgres image: postgres environment: POSTGRES_PASSWORD: password steps: update-venv: image: *ci-image commands: - uv sync --all-groups --all-extras test: image: *ci-image environment: SLIDGETEST_DB_URL: "postgresql+psycopg2://postgres:password@postgres:5432/postgres" commands: - pytest tests slidge/.woodpecker/test.yaml000066400000000000000000000021141522535037600164160ustar00rootroot00000000000000when: event: [ push, pull_request ] path: [ pyproject.toml, uv.lock, "slidge/**/*.py", "tests/**/*.py" ] matrix: PYTHON_VERSION: - "3.11" - "3.12" - "3.13" - "3.14" labels: platform: linux/amd64 variables: - &ci-image codeberg.org/slidge/slidge:ci-${PYTHON_VERSION} - &only-once when: matrix: PYTHON_VERSION: "3.11" event: push steps: update-venv: image: *ci-image commands: - uv sync --all-groups --all-extras test: image: *ci-image commands: - coverage run -m pytest tests - coverage report | tee coverage.txt - coverage html coverage-badge: image: *ci-image commands: - uv pip install anybadge - COVERAGE=$(tail -n1 coverage.txt | awk 'NF>1{print $NF}') - python -m anybadge --label coverage --value $COVERAGE > htmlcov/coverage.svg || true <<: *only-once coverage-publish: image: codeberg.org/slidge/woodpecker-publish-pages settings: token: from_secret: CODEBERG_TOKEN html-root: htmlcov prefix: coverage <<: *only-once slidge/CONTRIBUTING.md000066400000000000000000000102451522535037600145700ustar00rootroot00000000000000# Contributing We are really happy to welcome new contributors to slidge! Before starting anything, please join [our chat room](xmpp:dev@rooms.slidge.im?join) to say hi and see if anyone is already working on the bug you want to fix or the feature you want to implement. ## Quickstart To start hacking on slidge, you need to: 1. Clone the repository: `git clone https://codeberg.org/slidge/slidge`. 2. Install the required dependencies. (see below) 3. Optionally, spin up a development XMPP server and use the mock slidge-based "superduper" gateway to a fantasy chat network. ### Easy mode: docker-compose The easiest way to achieve 2 and 3 at the same time is to use the provided `docker-compose.yml` file. Run ``docker compose up`` (or ``podman-compose up``) in the directory of the repository you just cloned. It will: - spin up a [prosody](https://prosody.im) instance configured for development; - launch a mock bridge to a "superduper" fantasy chat network in a dedicated container, with hot code reload; - spin up the movim web client at https://localhost:8443 (you need to accept the self-signed certificate). You will then be able to connect to that prosody using any XMPP client, using "test@localhost" as JID and "password" as password. Besides Movim, we recommend [gajim](https://gajim.org) which lets you start a different profile than your main profile: `gajim -p slidge -c ~/.local/share/gajim-slidge`. To avoid having to accept self-signed certificates, you can add prosody's certificate to your local store. In debian, you can do that with: ```bash # download the certificate curl https://codeberg.org/slidge/prosody-dev-container/raw/branch/main/localhost.crt | sudo tee /usr/local/share/ca-certificates/localhost.crt # set the right perms sudo chmod 600 /usr/local/share/ca-certificates/localhost.crt # import it sudo update-ca-certificates ``` ### Slightly harder mode: setting up a virtualenv In some situations, developing in containers is not optimal, for instance if you want to attach an interactive debugger to the slidge process. slidge defines its dependencies in a [PEP 517](https://peps.python.org/pep-0517/)-compliant `pyproject.toml` file. This means that you can use different tools to set up a virtualenv with the appropriate dependencies. We recommend using [uv](https://docs.astral.sh/uv/) which is fast and has some nice features. By running ``uv sync --frozen --all-groups --all-extras``, a standard virtualenv will be installed in `./.venv`. You can then activate it with `source .venv/bin/activate` and launch `slidge --help`. NB: If you want to test slidge against an XMPP client, you will need to set up a local XMPP server, and launch the `superduper` slidge-based gateway to a fantasy chat network. An easy way to do that is to use the `slidge-prosody-dev` container: `docker run --network host codeberg.org/slidge/prosody-slidge-dev`. With it you will be able to launch the "superduper" bridge with ``slidge --legacy-module superduper --jid slidge.localhost --secret secret --debug --home-dir ./persistent``. ## Guidelines slidge uses these tools to ensure some level of code quality: - [mypy](https://www.mypy-lang.org/) for static type checking, - [ruff](https://docs.astral.sh/ruff/) to detect common python mistakes and enforce a consistent style, - [pytest](https://docs.pytest.org/en/stable/) for automated tests. Commit messages should be in the form of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), with some additional types defined in [./commitlinx.config.js](https://codeberg.org/slidge/slidge/src/branch/main/commitlint.config.js). This makes it possible to automatically generate changelogs on releases, it is worth it! We use [git-cliff](https://git-cliff.org/) for this. You don't have to install git-cliff locally, the magic happens in CI. We recommended setting up [pre-commit](https://pre-commit.com/) to ensure that these pass for each commit: `pre-commit install && pre-commit install --hook-type commit-msg`. Make a new git branch, commit your changes, push it to your fork and open a pull request! NB: we also accept contributions without pull requests. Just push your changes somewhere and tell us where to pull via the group chat. slidge/Dockerfile000066400000000000000000000034551522535037600143360ustar00rootroot00000000000000ARG PYTHONVER=3.13 ARG DISTRO=trixie-slim ## Install dependencies in a virtual env FROM ghcr.io/astral-sh/uv:python$PYTHONVER-$DISTRO AS builder ENV UV_PROJECT_ENVIRONMENT=/venv RUN uv venv $UV_PROJECT_ENVIRONMENT COPY uv.lock pyproject.toml /io/ WORKDIR /io RUN uv sync --all-groups --all-extras --no-install-project ## CI environment for slidge, where we move /venv to .venv FROM ghcr.io/astral-sh/uv:python$PYTHONVER-$DISTRO AS ci ENV UV_LINK_MODE=copy ENV UV_PROJECT_ENVIRONMENT=/woodpecker/src/codeberg.org/slidge/slidge/.venv ENV PATH="$UV_PROJECT_ENVIRONMENT/bin:$PATH" RUN apt-get update -y && \ apt-get install -y --no-install-recommends \ git \ make \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /root/.cache /root/.cache ## Dev container FROM builder AS superduper-builder ENV PATH="/venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 # libmagic1: to guess mime type from files # media-types: to determine file name suffix based on file type RUN apt-get update -y && \ apt-get install -y --no-install-recommends \ libmagic1 \ media-types \ shared-mime-info \ git \ make \ && rm -rf /var/lib/apt/lists/* ## Mock gateway container FROM superduper-builder AS superduper COPY dev/assets /io/superduper/assets COPY slidge /io/slidge COPY superduper /io/superduper RUN --mount=source=.git,target=/io/.git,type=bind \ uv sync --no-dev --all-extras ENTRYPOINT ["python", "-m", "slidge", "--legacy-module", "superduper"] ## Dev container FROM superduper-builder AS dev # prosody certificate for localhost COPY --from=codeberg.org/slidge/prosody-slidge-dev:latest \ /etc/prosody/certs/localhost.crt \ /usr/local/share/ca-certificates/ RUN update-ca-certificates RUN pip install watchdog[watchmedo] COPY ./dev/hot-reload.sh . ENTRYPOINT ["./hot-reload.sh"] slidge/LICENSE000066400000000000000000001033331522535037600133450ustar00rootroot00000000000000 GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . slidge/README.md000066400000000000000000000076231522535037600136240ustar00rootroot00000000000000![Slidge logo](https://codeberg.org/slidge/slidge/raw/branch/main/dev/assets/slidge-color-small.png) [![woodpecker CI status](https://ci.codeberg.org/api/badges/14027/status.svg)](https://ci.codeberg.org/repos/14027) [![coverage](https://slidge.im/coverage/slidge/main/coverage.svg)](https://slidge.im/coverage/slidge/main) [![Chat](https://rooms.slidge.im:5281/muc_badge/support@rooms.slidge.im)](https://conference.nicoco.fr:5281/muc_log/slidge/) Slidge is an XMPP (puppeteer) gateway library in python. It makes [writing gateways to other chat networks](https://slidge.im/docs/slidge/main/dev/tutorial.html) (*legacy modules*) as frictionless as possible. It supports fancy IM features, such as [(emoji) reactions](https://xmpp.org/extensions/xep-0444.html), [replies](https://xmpp.org/extensions/xep-0461.html), and [retractions](https://xmpp.org/extensions/xep-0424.html). The full list of supported XEPs in on [xmpp.org](https://xmpp.org/software/slidge/). Status ------ Slidge is **beta**-grade software. It support groups and 1:1 chats. Try slidge and give us some feedback, through the [MUC](xmpp:support@rooms.slidge.im?join) or the [issue tracker](https://codeberg.org/slidge/slidge/issues). Don't be shy! Usage ----- A minimal (and fictional!) slidge-powered "legacy module" looks like this: ```python from cool_chat_lib import CoolClient from slidge import BaseGateway, BaseSession from slidge.contact import LegacyContact from slidge.group import LegacyMUC from slidge.db import GatewayUser from slidge.util.types import XMPPMessage class Gateway(BaseGateway): # Various aspects of the gateway component are configured as class # attributes of the concrete Gateway class COMPONENT_NAME = "Gateway to the super duper chat network" class Session(BaseSession): async def login(self, user: GatewayUser) -> None: self.legacy_client = CoolClient( login=user.legacy_module_data["username"], password=user.legacy_module_data["password"], ) await self.legacy_client.login() class Contact(LegacyContact): async def on_message(self, msg: XMPPMessage) -> str: """ Triggered when the slidge user sends an XMPP message through the gateway """ sent = self.session.legacy_client.send_message(text=text, destination=self.legacy_id) return str(sent.message_id) ``` There's more in [the tutorial](https://slidge.im/docs/slidge/main/dev/tutorial.html)! Installation ------------ ⚠️ Slidge is a lib for gateway developers, if you are an XMPP server admin and want to install gateways on your server, you are looking for a [slidge-based gateway](https://codeberg.org/explore/repos?q=slidge&topic=1). or the [slidge-debian](https://git.sr.ht/~nicoco/slidge-debian) bundle. [![pypi version](https://badge.fury.io/py/slidge.svg)](https://pypi.org/project/slidge/) [![Packaging status](https://repology.org/badge/vertical-allrepos/slidge.svg)](https://repology.org/project/slidge/versions) Slidge is available on [codeberg](https://codeberg.org/slidge/slidge/releases) and [pypi](https://pypi.org/project/slidge/). Refer to [the docs](https://slidge.im/docs/slidge/main/admin/install.html) for details. About privacy ------------- Slidge (and most if not all XMPP gateway that I know of) will break end-to-end encryption, or more precisely one of the 'ends' become the gateway itself. If privacy is a major concern for you, my advice would be to: - use XMPP + OMEMO - self-host your gateways - have your gateways hosted by someone you know AFK and trust Related projects ---------------- - [Spectrum](https://www.spectrum.im/) - [telegabber](https://dev.narayana.im/narayana/telegabber) - [biboumi](https://biboumi.louiz.org/) - [Bifröst](https://github.com/matrix-org/matrix-bifrost) - [Mautrix](https://github.com/mautrix) - [matterbridge](https://github.com/42wim/matterbridge) Thank you, [Trung](https://trung.fun/), for the slidge logo! slidge/commitlint.config.js000066400000000000000000000015331522535037600163200ustar00rootroot00000000000000// This files defines the allowed "headers" for the commit messages. // Following the rules makes the changelog generation easier. // They come from angular conventions, unless commented. const Configuration = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ 2, 'always', [ 'build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'compat', // workaround to play nice with non-compliant clients or servers; ideally reverted once fixed upstream 'cfix', // fixes an unreleased commit, should not appear in changelog 'imprv', // improvement of an existing feature ] ], }, } module.exports = Configuration slidge/dev/000077500000000000000000000000001522535037600131135ustar00rootroot00000000000000slidge/dev/assets/000077500000000000000000000000001522535037600144155ustar00rootroot00000000000000slidge/dev/assets/5x5.png000066400000000000000000000010431522535037600155420ustar00rootroot00000000000000PNG  IHDR iCCPICC profile(}=H@_SVP,q*BZu0 4$).kŪ "%/)=B4-hm&bQ1^;^х"YƜ$r|׻0j}ѣf,DYf6mp'tAG+qι,̠L\+MFzrp9r""I@A@pI"ȱ +zŠv|(0SC Tn}#̄c2WG}ΜaMz9SOU}>U_ciڕ0J h #m5hX)tZ56fa n<^lX @3v OᨑRf0n]$ZbvT`iďk0^yLld\k7C} 69Tġ'XSMoo )DhG Γg@,zO 1xw3D3&6xv_fVz~tĘ1c)N@&Qxj'͏67qSK߲OAΠKˌ :hJx3Ob\|F5{)jLY G'/X>:.[Bs.\ 㕞7V/"cL,Gx щV,cbۛץi?-i'T6!c Jtx8|HӁY+1t9acx_0n^OUsxi\08y&#m &+Gm5x,qI& &_Q-an8+|fP56'd/%UxP# 8n`1E;0ke}q`ܰG /3%kl|g/cLC8)m`WC&n.4Xax40>A-[hiK>͋@`'\A݄сc ƶIsBdVYopΎ_ǑWXoˊv4b, lY?1>1Af}I.{ R5:'F-vI8#.yx h`@TF#HjK0|8)\jKyfa|40|E擟1Q.s&nƫD'Gƛl Dyb']xp@p)BPm}.;#*kfXa#3q큐;w_=Ut}Yy:7^@ "? 1D;O|֔ pj- pT ' .'y #p- 0 )AOr2Q8 h 䃵{#끳׈j]>#b~ \MdV90US#à-=ڛ8NOv3Ա$|0.?ܵ2EjZ7Lүcq,yNH YMvݧw4+I'3f؟Oᡮ|+Xă@ `f r'U|mcl+`B!d 2C9B ]7!,l6{f p-p<E D=܈I`bs5Fʼn!.ljQ` ɽC{y]ˮv1މ?nBe)>q$0ばh/0FAۻ2_#ۢ>n! eyt;3&'a 2QCŧu,PC6ȷ|{}תwx&4b&-V ]RïC5"#|֫݀H-NĎ}{IZgBO HLdОY-I?1oQ81-Tu9$D=Tuuy7KRy ]Ą)Iq52QN~)C_ØF "p,bsjJ j]`LkVO sa:ۂ3L,A\mC]YWcp0h'" ԢGk-Zg#Cli\! %]usOs}S٭ 8˗qр =gijoHs u3$}eB/Go<ڱ_Z,?|X+R!lPrK*]SfVܷ J*u)O8Â,Ɉ ܈ђmѣ 3\1 ),mID8T mB|Ijv赦U\ ܀{\bfLJ^"ɳK5 -=WJUBcCr+2.&c;0!f !O,#eP|;1 r)OxӰmѶԶLp_|. _vS^)Y)pttȇik)k[`1_ǏINAc/bnڼۈ\-3Vj.he1Ic#ᬮJX`rX f]`Y jy ~&q-b u؛h3^G"4obHIL iAM&Xx1JQ-|{RA ;aDd0 Z1ms>1[|݆H^zMf=X*#S<<"e!⫝ߕi8U*_ޱi'|TGюXxq3Te-}P :i!M< y^X 삗3?WU='w#ƚWjl5X7XS[p2fau%j[`d dn@0 A$6kS4W^'O.xLrnMt/F $l bjϗD\*/˜=T8;/rfOc3ײތq!7I$;VbwH~5^vDt#P\IQ&FNWcxcV ua wf!x2t\6et}Np7Mxij#Wp!vMv7-*$_8q8ؿ3!`UiJ۬F(T_h MJ6 y*x_Dܚ'_~ۙ6_B<0M0O|Ey1NI {ß6l͉]EjF0P :Qch3E+ :RN:PMF]'&.LޚHd!3Sf8\oYC&ۢ:<9z+s 0_NxbQz. hmnd؉e3![y^N2Es& q;l=53BYwƕrnCj9Jy&3x?A3 )20Y>uၤ0a6i#=bG;LY+{3D~3 $ӢD lkXgz+шy k9r`~:4ӊѫͼeb@RQX&cqX$Y[ gL]x|}z An=A۸ց:zp; Bb2gYqNHv0ӈ+%0㧝ЀJy'Mĸ]U8YyD(",J⼡ϤEg,\-Va^G6ͤ--$lJ/ӁjV)m7S07C3-" 1W=Jj Wʸ8*=`i[`Tu*a7*F^wҤKYL$C\l b^mϧn=no0l}TiV3Z=iE #FM)4艿+ca0ūɀCj=F 3.^|7~N%$TJ@4\dy>cauqpaɼ $5*Yr8X !rv."| b+1vmc2-ZIV݈!NxU*QYѺoEɎh2/kMb0ac M)<2vl旧7Yp&Dl N#އ{Qg^Xc5 H'ۈVO~uOsg/ &ɷPo|L?E-wix%ىIxPSg |YTf')HġIuTQp1qJ,m+hj6p 4UT}>9?u338}2(to {y c&.gS6 glHA>|`i5o<8SV0)2*Vq *Qog9ËC)P6k L;+>5Y/^_;v4_ˋS@vQ9,mѧ-:YSUy:߰<ԬYMͪ6a&gFw|C›;/8 1VS~!fjf+PR'G\*π Ƨq nq!" Jb=]s۝֟ E)7¢?US|ay-{e3fdN߇1Gɦ 71ux4{xN?mCX`RSn{1sLN0ݾg,R&LFhg њ鞊ω*i@1sE`P^i]8=aϢk0og45 {-Ŀ1`76jFȃV'PO3nEFoj/FSs)8EoMfǃߪO J3D<50.t@g6T|ס? x$lZBxɝ|8pg4Wln<đF^UxH,g4pfw#Nܱk,BP ǖ=+ߟm d D]QqA˥( EMp%Ƨ$رh3;ߧ n0ѢQ B;3R` Fp1>'$+3YlB^VxgL8OOĖ)Y/XX>2wR mF'krH} Z wm mNDL,qw˷M=xBAayݓmC8c}hxavGo $𒉝Ϳx[&VUxD:$'bL+eeC l@cnwgGV"vt?XX҅WdfuaQڟو〭 VO9`w3S36BB#im&s[}&Nx>[k5=zX`F'/rNqR=U[4~Z}^An.}ۇͷϼo3)Lmu}7 XvM A45x%]Zں!a /JjrQ}͇}ܵd7C8BYtߒG}M;8 >Z cYj9o.č>GT ّh]pS!S~Y ̓7n8 Lnι<"49rEQG!zotߺxDPJ*i^u˰$a!uIF|3ðOxx .U(#fn\z xa#1mX?Ûˀ#(.7s3opQ~QOǷxu7"z-aw_W@q-AONM _U~߆\|ϤY?"ak>Q` 8 x۲ltP0GZH^ &#b{Lliؘ/NNpU( 4 &|h̿|G2Mxm) u8HN`P4_5GO)Sx\Dw⅑SD|;@/)!p5ڲ/&c଄;i5^;ycIIlB\K~B| ;x#Z |gKg`VgMϓ>k΁D|G=¢\iq`%۫pinP¯]WQr< E 4{Z0-we&93 M"~Ǩ> |ħ) sHq.5N7?NҫԸQٸIA7 iQpiۈ6ۂ)Vmi!LV ӟyDs^| Y,ѱ ¶lм@QJ'G 7; 8e=[Qt: hЙOso*p!pRa Πq.~#$hKrF)Q !.ȏ~P;1gBٱR1p) [JDCRLAc tr gx<>4b ^&g݁yU  6I|*}w$*ܽ}=x2*km VLHaH=x Єۭ;CaJFQo*9WRKƍå Ci_4ÈFċj 5>k(ixoxCk 3P1#n؄@tC\ŔBdB! ی1 e{x<T_˜&/m#J J%e^6,Vc,.xJ6aHV=67eICx-ܳn?򔁕|nԺ1Ě&-S){!nt倬 p6ѨbQj`loCs p20>ԶkķF&6d6!A3<9)JI%΋~Fr0)̱(;3UUqJ3\9/J|r!uj Iau[m@1a [%3B֗1X:Ӕ21Sl!D3pޔ]ܞ%4Q%7F.V'(k!/ѐ=.N',6v<@~q>OƤЦtEXtAuthorTrần H. TrungU.y%tEXtdate:create2023-02-11T06:35:40+00:003%tEXtdate:modify2023-02-11T06:35:40+00:00BSOtEXtSoftwarewww.inkscape.org< tEXtTitleSlidgeqIENDB`slidge/dev/assets/slidge-color.png000066400000000000000000001065331522535037600175160ustar00rootroot00000000000000PNG  IHDRtz pHYsatEXtSoftwarewww.inkscape.org< tEXtTitleSlidgeqtEXtAuthorTrần H. TrungU.y IDATxy\eϩ-BfG(N@E0@wGP}3QQeAeIw'`2HXtQDEeW$!Kw]G7B[U}^yk{{{q RV$oTm{4`x+ `U}Wr2S9.^ U?t׿0viKO\yWd~bEdST_j7s&eIz 1v: @ciO0ǁJl9Nl5OB41PD6=1y `ע$Qݛ9&̍J7rm 2rX3>QLkb+u@1>;rTq@ h95̍U(6vtZw<<<+0}}9cWv[mQd) cMH?(b)gМ^~{TE7SR!mRHb8cy7~gΟq!>#3mXP'M?sfq ]r'o9 x#πqD.2l-u8"Dk7b^3lj7>g-`a } 'Ś؟ Z6΅f7ΰ&%I*L\W0rΜsw;Mi$f<<ȟ/u<>7HR3ӧÚ);6(Q]QTh<Q4R|*;3ĕ$l@TU-98[;=;kX¹O`^_i-ZS0N-vLf׸{G0 +S9G4HqDy3c?r|uBnD1&I ~W]Z|03~ p3x 9YsV"eR (@c8#DʔF-%*\zcU~8ә"=q5pi9"=3;/_o4u10g]qsExHey"dB+g9$:6;7g[pe:uExHei Ԋq:DrtfIoo.[)Ÿ: v Nc2c8͹Ggl]VJVُװyc1?rh9y"luwDVq>HG4$ˁ/1n8^ ه\]+XŢ Th82Wb,N;줕37=]`l=o`d:1>zU3D*!ʫ(`Np>&oR?ߖ2Zaн7Vyb׼0kN w39f'hP&Zm8nsr !zx3}/ubk}-O)~G q @ႍ)B 0{'n^4W0mL;b={o/c$.cl3I,3#4HǓèf>&#qZm1ވ }hg=)t zdtsБЌl]˶G~狍^X4j0FQ@WDŽF#aة^N^gM U(6uW"7Nk3gٳ-ېm91kN/FQoby}gp$mq+`G0Z-^U9@m35*(6v\AtGpx6>1%+osE ߍxLbu!TQ)kNF:ݖ=X 7oaodq."Ûl)m[{7׫}>6e#8G[m[$[m]__xw#Sbؐ*8@G_ t}:p* Nf`FC^.泏M[iaysa^ak"S`vG jEo<"?V`3QδpI14y3_')l'Ǥזr-"x2މ~tk8D4Ɠd;9Cᜈ(U_Q\W!ԟǠ[8f.z-ay^q;qN7Vb ѬKtw>fߏ`,Y_>ـ):2l< a]Q@+q|_:N ʧp*;p9nݤ8z8zCo̧wb7Q:ÜG0;|~XR$#rc'ީ(R=R>{T}*?QfM.%-܎w2?<\8ƞA@Xة 9% `ӿE 섩괩Tv.l'z^.ވ~v.(]458q0okՖ,8PT*]ͼ8(s7[8A*g4\l̅ W| IN^.JNh{'nEYVa1㥙ELRZ}3>wT@5[!TSF1s<ݷ@1N3%$AOXg2;0a.^0̑4Nx Fw:(bdo{ScC?pD*B5zf0 x#oեٞz?FSAp. ncV[V{gk~`Y@gU6>Ng">IzscjU!򊰛Yʹ8Rй2U֍''of~3TPxprGTmF-ǯ37މ+DqU>h+tϬlХld8=Q$R֪H*Nͫ\n?~g2`^PٖvR,v`:AqR6b(:}/-N!w.=pDW ?ZoSW\1-ÍEXXiw( r$ֱޜ_E ?PDWT8Hla\*缐W4o$q!ywzOj^nC Qt8!r)H#]ǃNQH8eȔc(@-iZ`m+iDzЉ=/d>zF>kL&މL;C$o/p?,#eVOVTi/ŖϲE~YYaS)(f idPAǘyEE$;e,"e)U sCV$`l 6l|8g- +Η,8Җrjw2oO gz˃mHэ,{2ydAʜqoa$"+H/_p[We[8[X2z.B@J/@m+7P{$(_~#UȆCM( ( xkEmt#^\5UE?L 9?1/Q NvBQ%:QuDZi7vK4HQ=\*/gHEy`;O`Ȟ@-t0&&L~`ITč%:Q8gI0RG!O_OnHɸ)+aˠ@SPa wGle5ZX>sƧVZsw14g2G&Rz'n,щ"gc/aߕTd* ۺ\ ǽx=_D/syTOň 蝸y| cV9~"Kq~r8ZX/E_1y35XE인D'#S.K[1q1oz޶, BℜzNAE#8]?~`e4y e=!  ˃X|tVSo0f-@X-8*)o+lu:u)1GقPuW& xX1.NuΧڻC& uUzɖGER4R7.wSSC$Ibg:*N iOq^gl]cJ4b9UVS VryN,I5>L.`-q(cX^Q$xyG;q 㒀gN,"̮夹B%k1.UZ@-w5q%NODQD$qɭ(> 52zVOeObjx8!?eht' ^B恛/,冝d'DQD$\QL+:0@;_SoD,R>!Y3$މE>ܚD'f';DH5 G1EI t(KMj`6L+weuNkf6=v=8- Jͧwb}{'n,щ"GHg|Hb6ry()L t;q lxEX ȧrp;ƭ!!x8^^z.0jX`-*D'nɧxCT\؊bM2+xM,9U`koVy x q'pL!V\D[󊕐'Lgmåv' O90PDDND1Eo8#mEmoYsq9[xG!O[xAе}KJ])+TM,wƒ(RbE ""U'fdVOSY<WV)77s!5o0 [E GY|*8'X (щ{2nBDDB!EWgĭ0od3#>$7 AY0oZEdq!P? w8A$:Qi@<>aE<""U%l{U]wؘ{ĭ=;_+'d`>O,.2!pr-h#,c{'.%,5(M I0""դR(gtKyB`]+k1ϏuQppT!cLL%TG VRp"wUN2dF*W6YEyw]1Dz00 ;9nwl"|)q&Y 1X=n% FWq`iTuxB~MGˀ n9 ,w~8V'S9UӪ.#ӷA= Dzg^Eʛ9xg.J|gݪɼ yN>րi(Ah4#GDX{#G 7x;wEMay?TQ%GM{'n2H%44Hsl]`>SY1%\wd ռ9|z'ȾM%>Q|;Bq#"۰v &n,/V.E LvB*>tUb (OC&`p7Ǧ`{i~_*0gpv2!hD[\$_` |aNtv{8Ddiˡ1M5Ɨrb>!88 IDAT>Nv'g,LDSޑnnY'p2w܍q1O gzUM2( ;`3xxȉ 8{h% )L5QQ̣w*vXc yE 0hď>I|JV99ZD)Dqe=Qx?E#ID6cZ&cm tf"Lsەi,f+emx?hŹ oOFrwB-⨕EH~Hu#"1ljnKbp^J@Eq3%ty#[oq/m\\{'n<E+"Nt{е"Gy\ԡH,;b|+xމW. _~}Z/c|VZMORčMfG2"u鐿SLb"(WڊOa/@[z'ngZ&h?Ƙd=|6lny<{'MjlEE4ԾQ^/=>%(#EgDna )N`!q>jKy&1eNL3YW}^hXȩgR(&RDQt^މASqC80x-\B HXU8%"f|j<$QLpSnJ) b+jְӀ !Z`6üzhW8&>&FV⍦0(ukU͹~9ODT(YiH^b;~k,1{`=7+7s0;ߨcwh5ʧwb)D%^`kRC>cnqG"+J qɦ5;1gqM8L#;Iq`=i=|nl")qŸɖѐ{;Vm$pԢFb29_RVO2 5:?R"~,wbTUh\C/rt"%lEQ*w,X#UxJ;qc(+go#MЈ v*b3S㄀_U.a9X@&|"omx\)L索(:[N '{󀔱9 蝸No ڈӸsc/x\v=$A,e<k,ROtčunW"M n٨",EWE1Q,zEѧVnnHw;qce(W%!'̙QޝG,1omSpUHȅ=9D1ʠǴ,XcUa7VQ"@-1hqo*њmG2R ˮ#DމձN:jӁuEzd l?7vU"=W®Qi9,椢Us8kp5f%h ;qc(d;lA85)LR *\b"%t bcz'J1N xv;5TEL6"?:ܙen?ERY#`D ,FE`@zyNITqKٷI;P;0JdnjO;(x]XDJhE-OòXcHJ`WqJnu6`}N@y.M!STQL\+GbN(,ROwqgGŗe'CI[lihme'H2(jbR%8#`1"Uśm>mq~WLP"@_ݞvRQt02]~ySRv®QV'XQmCTs\aHi{ guqB<GM(xo6248ߍ3Ż|69g3%Kdr&N$$Q4Yaoa?4)>A}D]R ~1uV˲m6*_J ڃ8hMR|t֭^`kKfH 0D1T(ǵ|`%bdɍB:/ؒEUO=o\{.nvxl$mΉ8s֏eE˯i*o(u`"M='Rz$R%a]eK/_35g:}AS`ו:0ž\bByG[Q4"맆6Uה(nV[fCbp8ōyA7vM>M;Bݵ !qΕo?: 鶘'JHT(nCL/n xlxsYVf:}AcwǗ:0*Z%?ɫ(+>C$gHqR%%e;lAC=_bC8`vM L8sVoK D RX+0|H#a{JHԔ(V[mgowT3D3?2]~wc_:01`R3z깋VKnqgP8c.p.v?᫕3 ,d105o5LDǞnlX`_}}40HYz*ZR,yxxa2Zm+Q8ߌpuk̪ꇢ*-Q|)y+/ݥqé0TM]>jz鈔J8(TUCvIݎհG`cx5 rGl?T1QrJʳlMgg87~otG (R( Ϸڋ֙Sk6Ge@Ա1=z~z;}J2a㨢(RA(ʫe` #: 7*a " gWEQ(Qx=aۛFKv8?i+5-H%Q(eۭ7nyUb`pnf[v~HȻת*"El?y԰%Ew77"NE*DQ"=˞k6^Il4Lf!5(T%ϱ"j)^bac^T4WEQgM,{HS|Z3m3%Ry3ǐ8l7R-I(kDzv9:L uĔ(39aR o133M~rTY%M]48zӿbQ)<{e? 1 W*Ƿcԇ)1ӌR!NɥBn2JUNEqAgL1CYYީߑn*uizn񁉳ǭnaM.ԯ\Mԭ#a]eT -, lWxJ Ӡ[tf|VY^p 4}8M])Klŧ,v,RD6ݓ8?xT*ŏ&L@Ub3~n6RǒH.x3bdgluI3}X~|2ka2j)e e?nÌlR9/vw^ʺ}i< < 1fp;a1gY8߲^h̭7؁1>Mtjs5OsO78ܼV=Hq>vvLq8Ü0rbjܸf~CU̒3[>BȌ$P]+"iը UTEqBaQeЏ1:fWǝ$k `=H;s\lMxOD-9z8XI^6X/?™qR~o{mFݮ)$Iܒg/6M;kCKYl;@ۜPf 2(XLJi\(J2xCqDJUq@TEo@EBPTA@A9s49{8):eg}<H09z3bܷ){Rۮ+Aov:d}Ik͢iQ¾1ì崦|@fnq"4M39a-{!t̐S\lv1 _ IDATä;RNnU)(%kcQ.,5g=)̓%`8RuzϜ6BF&T{4lqBf1(f9YڊCrN*{rtÎN?[\!pEڮ_1Bs D2=#tYI*P?rHV+։x85% |R*2jf #aTE|/71vp@rN֌b6-pjC3r..w,@}]o "mGGo/̕Md/p6LOM)(j,v,<_xL)uTG _sa,)el|R[7W~3rkn|quyFrR-'. I,՘lz\[Ű ;q&OEKϕvˎsnsei]83'QH_![B)yzlА9 VNNzwQلJw)*~!>h9ŭkCŰfQ}qjTCTX\i|qqu!\񘎩b 2[PD:]z+.y&=X$7"d8a>"r0!#$K1[gܚq&nUEjyC޲ɤH{W->I. tzqO#L>T#jغPO:4,B+MDk. IiFQܖѐ;|RDiSSQ^v~GA\L6f8ҳp+/_+'W- `CDҔ}s,ǪV_5"oFc,lEeè5SsPPM.>&F1l*Y(23ʞMlN "3Hw=yGޑh?@"\myI` L441|I_kEgw,$OYڣgK+ɥ Qt48D#<8X$k=$|P3$Z-DX$|p<חgoo\S7ʛҖi`E=#^|qmBa8tglYڣPTnM(3ʱI30le [zNQxBK$S$-!+̕ygs ߡm?w< \\4:tƗ)bF\|vvIPq5W(fbFQKLU,|*VҳGE}}dqrN8<yh 3B|` ve6pe\2\2AJTjsK.nƌTZ"óISq3O=+GF"!%JTEzd~.;IH\p! .!ppC5q:(J&"xpړqؾ/Us"(rY96u}nt,pPaO/?SRm>a(%݈Iu8V^+*_/dihoA%O}qMxȄa3Gff^0qT8#_˘Vh= Őzǃ}nH"V;Hڥct|hN1=p^o]?,SR )v\ؼ>tit7UfÅ`aG9t?DAj!T"OәQ< R!rLn( C?Jotϑ܁ڿy{Oo\ޯܫoԦqq?7r/Uj7u8 U~V,ADzobBaezΔ2(,Г*LZO[!^$B} h~ W@x_L 9"n0B1"=;.,6eb9TD>MN=h#MnHjSGSp`@y(\^e^K ב}ۓDp wpx.+Ljr( vI.3Q]zi?nғr_jhM5~_ T?"GLD EB1I;uLz}2[jk!Lz ŠXbS忒qR<氅u:ضNRNxh:F g:RHb{X?jԥ 11#SZtY̑U">"D<Lnb"íq"]ڡwyL\'>If5q+8C$l+Bml-?ѱH͝!3ؑ&gp{Lj?jt/p=-Ui]$s\{9B|et%^tr!ļL^LOL{YEKČi*mL~3 eR\dh^wnYGL[^W'}~T͇ G;,54f#fz;gx cK'">fIE-268aL2bQ&3[%FQqQfɔCܞ,3S1D2,Nq[z|űPH6DoSіm$l[ Efw % yT9!'7$"x5}seIS/P.6CCōȥքP*eQoRvpH(0*\oM@銺<5. kf +|JfSG\S>0=F/_Z:+>Vhо3 =#R794@Hu>&Z7q@.edFQoy;8XYH_-2<*: ((Dj?$r*̕K-53u$R){cޗw[wZkFM\.̵&q9z.]3.BjP̌R!]tj c7yj04dϩT4q}A# eRQt2'6H#޾G Uz3@L E\fbpNS0_|Y"n3AgFy\fKgS\ :RK9 ڬPʤ4qܣoOЙS^Y萯>R-QB&zwV/[V,V t ?}:6q fgMxaCS~68LP,tȳD?}r/s-)A8w\|Mijg6== s=18(DpO\$Hmz~M]iOmPf8MpF@eI!zU/|=&<=-.u0ֺ~%r sa/L㌢T .=3w8vf5`sDĬd FĞhqxV*?n>D*Ph$brf&,ԤJ|xp“ yf\g<]  8)Q~pC91L*6\${9K~8\ѐ〾r-˜)9] !M81_M\%$"KsC(YwSe{SQN3HvӨ E;ӨScNxyRUy@ioٹ%iEp/|W>_ |#Zњqұ-E,al1ٌkEMaF@9s*1#u-=ga'%IJtg3@Y򜸽#zB EΑSG~ cF ߩ@(=>!I,մ ?R*c(9Js |jjY%@;{Vx/{85#ck]ƅp][>Y(Ȋ-zNzB cv@ܗR),Gsao[ZndqbIi@zn8(m-ISn:Q(,tO9 o8>;u[(,?S6p5|tQ;u>>ioBN}nWn^u^ET37<ݥgʗ⦖u|S,ː\ P)-U|A;GPŗ2?Zהy5S'/ )1[~QS]4'-MS&VLoF@#p{aGiIS.:Éqi eأI|_ahCN+I3b_(:Q1oҀ;ۻ_2=vti.@ߧQ+ 2yqMڣ0RecOA&{ !B/E#|YgɭG:fB[Wd*r(kBf (NGŤ,n޿[;KV0ک-TΖbm~H;?vqkBe x ,M?'O&pטe:̲Ip)tjf2S(BX xnwI+㙰PwZA!e}^+O'lOfBaн6WfEW +/0:5f2)ߩ#HnuEo/s{g)g|vmsJn="`)8!d&OۙD:ܯ[}lN]\ rpKzu(s"@ݲD51ŐzkBa4~ҩm,$Y`t平a/S 7AUQ"\6pˊ)D?7jMI勶 < 賂1nu!K&@VE\~4 \Էzlv R+ҧ4lUBhS}&Ȭ N6'<.e$ D{663$Jf29voO~z6ƫJW#?Ȳ@g#Fr H5t.b+EsI_C^& IDAT|TE#fvŢL'HvN0n%bߡ:~ղP_+HGq3Ӟk/}yORN%ޤpP=O;N:1\BxN:ilڃu'Ⴞ̶+,iz\:@: i z& ,7 ԴlP@9qbפin2ϕJ(= 51ZC:VB#(Vϖz;dU>.,lSh]_wj[VrNlYN]YUdko@ 80W.D$rD4XW!ŅfJ;6NEbO6."!ixTHr]rkKvId&j;!\FR{_.E(N 3J;=4{ k_ $&TõE[Ksنo$y}y$aqSՕoxg5Z/ԇP^qؿ C' 3c-3yտo_93Y+(r!Pgf{9"zw^@8[rϼvDr !W\#e)[_W/vR׍J^~Iw \X'ͧ* Ef1U OrŸEy6hVw^'j 5*P {qR3PUA8lG4O dVՀPnFݨ|ZfWn۳v #9"u&Чzz香ǔq(¯poB8_L=q6xV^#=}.I I%庸#]bh8?V7זC/BqoQ6 T(",'`Y۟cpU=&liAC #EF+{쉰$sKpUډ$ W5RrAC%/i7M'RZ=ZclDV.Ɖ?%޶W ,yO1ܒ$3)jC(Ȁ#dIe|۳K7 p=|!Ϝ.O1cjmjc(:XP$@߉5b~6e3$pic#+Y.ǽAM3xsJ"wqi}qm}7 p~?UO<@hY%R:|C-7DNۘD1('P*}}?C乖G+Iy%iKc=n˟+z;b-A29 z 9jدB`B)P>xΑ>$b7IM3ykB)~?XDS?9j|QiZq_K?oϋDƀw^j%ӿ?D hDxSom[Ez?,GX Bv'(mI <:FwJTL5q.=¢!ƕs⿨KDŽ, h:~L| c(χ<\JՐ:&(eWC^]ʐg;z;߁oĞ',/ AMiKl@v_~lM;4S1sѠ2{m4n}|(&6yFy3Y?w9S˭nSxZ2c;r,yI<ԶrO%t)-\*p.^2uӿ4TܴiD42@+ 93$,7OR'doJtd3[ؿ0O>VE"8Eb_5! +4*3sB"G4fKcx3HRn@y }K=uv6CmC!ގRW.!^TV(sgKoa\8n bϨb#{:y6*HbY$!gT&3Y@;m/fX}Y-HAg7~GnJ;9v}+du^(+ta=~v.)na=zϒ|[2;WU8JK*R)DS ٧o\bD=zt;I7'c Kf7i:SgnRUM'~}YJە,O޳Տ~4rX(Vf^xv1:ΑW:P܁۾yrna=')atS˄Tisr@ga=;5׳q k5C7~pׁ,x eFLt,QEoRd)cCK;mx,Aѯb}v EΑYCtJ |y͕m ő:G rs_2&Y Q c9p5TFF.7n?K0 pYxLfgoJ煦&erO6aM'ٲdsunމp4Jw [a-qD#BfC/t zwj{;'ϒ~(B9w\;WH^CފrTf;=\"pl] IDB+vk±(wGcCEA~?N@Ӊ*\P%mrJ~7A×v^#4HK:}Z5Z5lb!U͓k$n$~ĎB 0x*G^k# Xx<1 `J;JS9ws)a` !],~ mg:DzF-NT+qp6B1rp*!oBʃA{y{O~SF /~QΓ˼ҿm|srz+M]?oep! J{B /̕mG1$JOf#NwEdصN8U(P* 쬛1wKP{ kgc1j4; V 0WVS~;/u$Qve FiHs ZZVay˥c˟Y* Ec1:4ܶbYA@ͱ>cLuBf1SP4Yh1Tfޣhd1SFzSlFѼcL5:BѼcLurQgV(c1)ڌy)+1Ƙd{MlV(c1 EcLu^(ҳy+1Ƙry}y BcN.YP4/a1Se:\E2V(c1 `y)+1Ƙj:,;l<+1Ƙj8r.bѬ4I; vb 9Ғc%P1Rm_Ib+hL;'ʐ5iaJ@]ġ;ci&qQ"S؃O@:҈0j ul, h~a(%o co;mt"9f*6eZlQC/[3a$e5Bq.?VJJ(~ l@GYZ[ J&{;}t>01h/7b?6|DƤ32/^F5{[ݛח.7ji+Y_ٚ"{:\5SI>c:; lt '[r0fKަ磊GPKl1&@E+mdfK^yHk0>'o1f)|YN8FlMw=1FQ&pKJm﫧J7|TXnzߞFhrn20עM^zKͶm+%1?۽JLcO'Hv~ a͑1ٗx",^Bg!uGzJohVd# j~V;CIH'Lchd,CL KLktʹ5S@RO^!klvz6G[d=  Ry>\`'֐e[S,kH9A(QFM싲/p4hYbSǫ 9 8O@! !&ة= :(|VJA#`5*A-VkM6okɽl]g^߻ڮCx]Lz8V&PL3MBx 1hZ~7%c2GypK/Ѝr&6mg:p`>,=|oFؾ{t]F|34Xn^?c2Kq˶WT 1ll(fAov.(J]&1UF:!|Wzy(ds;p`򛑩V(Vm4㲡2lO7e M;cҠs\ wۃ2 B;`A;/}\cy{OoUYi'cLʶYqM%NE"_H4b,AiK V$c=&"fueKfP"#lVH7sNƘJoewJ}ERdAP+ͶYXetz2"%(n~v2TA=E*LeS<z*96f۬PF/I*r`q;lV3}mLt*bir?ޒ2Ys˭P4e [TH7)sɦgmjZv^odbu+gYh豼#mu1٣ob,:1ɖq)[e{1Y38^> dy ɘjebu/S|,2 T@-9w+O*fbuXg\.K2<ǘi_XD`Z!+SPn{$zp0h;Gr!{gPfo^V(VW%n8[@|1G ?Wf̘~# SPR`߄?vSt !qx?E>5!I: -~1 j%%r =L 1UK@C_W2+a91#MzxK.YX4&mY ̔Gn=tq:5I:f?٘Z` ֶ(s?zkL4#\@,y) FNJBь(V!?)V1 M|qjLdJiL`LMB9btsԄ~wYB|Lr 5rE? Z`b5m6w?7NSB.vr=HoLdBabv^ SgbO):Ác貓@Vā;e)O+M&QX~cb8mKdZgbP!ScP"z*9?Hz˘SEx<+][bYNv6YXM {h SHꇯs'\)a BzuFqLB|C^c⌉ag xe+M1i1ob3+:-n3N15I31t!2@B-CQƕB&^&KCcj&ČrTLr1aq'i'`cٗ%ޕ3#˘Zm{. ۠iM{]CYʻdej(VHT?H4&&2.rdL qqR15 Ōia#%!cjs20Cya2L0 ͣR25 Ŭ <#?'}cL-4_HxkNcgxl6gbn^%k$dL$~!ɲ"+v] ,\I7?1HS~q8&SZOScbVX{XVؒ16! {c2Ha7˒3(K,SP O@ekot,y_9SĻys=2S,!Fu\xwXMx/%o9z c,%Wz|Cc^dbT&/]oLL|[k/$ƸL@dEBnOgϐI3؉VrAs?xg*Nar܉ ng=2f V(fE?CO2&B0`+kN+<,&9V(fN7a}[qDz; ~IDATk"qMEL!B~>)1[ebipPWHCSsHɱlٰ`#^)ZKexlU] m6(oN!>r2&ob0ܼ!#VukKx9_d)?ϘBBi;1ho@l؞ 7)vF1}1#fKHx3 oC]n~}`2:F_/єFEn<,1O :Q&K{":QC?M{kv T؅!nZ*ƌ}fƣ ?g3f$g̏\͇o _n?%ߘB1e:g(Iӭ1UG'L$P*4PᄜZ&$3% cv TZ(rN[Ӛ`lc2O1094nHf;C 84B^]GE% 2fPLN@[~T23Tm7{oO56m,`26yD鱙DS$jц#]xq(;l& B;.a,iQHىoD8'"<̐8Q7KL//F `4TB& 2\igڳ{s8ts>ku7ףeϚW]s(kqח \iL~NeoPOm]]^{W.ߢn;Zw0p%|[}_n{udzVFC]LJ{rA2k^짪_vt-\n!QJ^F_o~Zv->P!QS[;]6;lu:|-V}E[]\zzkJX0jZ=V݊_}rO@a.;FI 6F?뎇.V'=RdEf6VEO*]mtauZ>3Vׯj+.5[\XyO=Cm4:}̎٨nntّUf7ѱfZ-we5Ɩզ#yfoٞoe^wN#6zuW5{]-D.oցP n aۓ⼧;[Jy˓^usVm6z/nY5st?]m%^VjuT޾`jS\5ZD՟џ:`*󍽡ه{#՟t4,{ݞ(6{ͺC`OZTVխҊ{+QLEc=npXw,Zic9K{*Qqr|}տ5zOO_غŞKݽEViݱp]TfkҺўLbh'H% k>#uN/7M]F'Ewmnj,5eRFF>5ֺh_%ߨYtSjvsmtKuxu|:ՉuߛW j;wW>ݢOSsB$vcGt;֡~#8hG6znFIFkǽjHsǽfG;5;hcG,ؿ<~\ufgk{;z|:f;ޞXc/I7w;ҪsvejbJ[v5wwy;f#$❿?_?ܯҡbp<^=ڿP]\?U=?SmL䞩W]hvFW-_;;fg[tEg`gL9DvyO7#lqJsTpYϙ>G0糱BJs-l]i]lvk7::mtfx3=JnZ`?5Ϣ=kIENDB`slidge/dev/assets/slidge-mono-black.png000066400000000000000000001165111522535037600204170ustar00rootroot00000000000000PNG  IHDRtz pHYsatEXtSoftwarewww.inkscape.org< tEXtTitleSlidgeqtEXtAuthorTrần H. TrungU.y IDATxy|L?d2dC"{d{kc%bi.hjhZCT"$v&Qb"HY$L&/T'rνsfy=ч}νyϹ "F' E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "hBBZPP(V(@+ E "he;ݝ<<<Ύ\]]ŅёT*ۓjz)Raa!=y򄈈ѣG߀S۶m֖N:E#YYYQ-aÆT\9JIIXrww듯/U\<==ˋ<<6l͞=jԨͥӼyHVs " QpN 4WP͍wjΝ3gPff&L;>}j֬߾}7oNM ,RիW_)88?$ C&j׮Fי)..fW^e4ikӦ {-=Oeł Xf |tEQΝ}D^^^S2":}4ٳKΝbi^zQtte{Mw9#FٳgIT Z1Fmڴ'Oʜ j՜QFl֬Y,!!A9󕖖"""YŊ/c#Gx",#~?F FP(yԯ_?߿?Ndh4ڹsETre{.YY 1Ft-ywEjذNܾ}?iS"UZ}]9rd)Jѣуh֭n::vMӭ[7E"(ݻw˗˘uEVVVy{{ ّ=Ӄѣz?dBQ/'L@ݻw|ŅFEF7nвehŊ;5hҤ4mTLMR鼎e~Q޽wޡ&MP5t 駟hTTT$z;ܯ{ڲQFD?~̙ݹrGLL=oGffFិcРA,--M06~꼆=M8nܸA+WzNɢ/_ONnݢŋ=O˗y *Ȑ h42db -_6oL*UVaaaԮ];QPBRч~H7nܠEQ*Uxdh„ t5ٙwJsttys0>b E}.qoF; 2uMz(_oSbb"-Y*W;(ARѧ~JW^ÇBdĴ>!`Dt]t?w7S:toN~~~Ӂkǩy0kQ֖~givqQYi%ӗ_~I}NG/٤h(33T3JJ'''&J%겧1hժÇ/ C1ݝݩrN*U"OOOQ988H2iXYYє)SC4dq Fښ&O; ~7у)Y(Z[[ӗ_~IӧO7!r\3h4,11Z90BoĄ#ԩ>}:5V^T*%y]y#; ?=w9ɓOOOm@Oxxx%KΫҜ={Vwcǎ<|[ߟr BRΝ; ׯskۻw/sttB"CTݼy{rEÆ EII~~~ہl֬Y,''Gsu&No;1vXVe=JNNf=k߾=S*G?]rŠ}cOf*UB"CR%''s]Xh>)Eہ*7 WMߜwT*… e?/䰕+Wh׮ gO>5HNNf^VPDc؈s#XFF>))44{[|K.,!!AsHm۶I7+T*ۼyAٳg٘1c3v{8;;qƱwիso"Ng.!!{rĐ!CDǫΝ˽-Fv>w>|/߷o_)߁r=e޽ukRȑ#ٵkd?NIII|\ZPDc888]p{rġCDǫvŽ-D@@۹sLvv69s&srr|4--M;wakk߯yX֦Mm5P*,44ݺuKc츷W[PDc8::_s:Yqqx!oElDAA[|'孭?H*eX[[;vu`^ѣ]vi̙Þ={&ۺu+W"YԩSs:Ν+/JSvmmBHʕcSNezEFF`hԨT߱RB`֭&333d;4a111G1c6(>cǎ㞻ammݻ'/J3bBH*?}nl߾թSG~;wN`⫯im6Ž}>˓xjֲeK+(*T㞻(U ( 6`,RұcX۶mKݗ;+,,- N,;Z4hdoso%GzdJ 99+W{^ E1סC.e}0Ǭ]v}6v!VZ8Q^BBBʼbG ^AAsww;[|nK.1mC<}ڵ_[|9cJ*lr]R +**_~) ?Z> {G:uXddFI>dSNe666{yAەhĒ G6nhTMgϖ83Xݹ"8SjϞ=s*tŋeۇR=S6o<]nРK4wbIJe:`%)Spo;v_B\x(F0枻aeen޼)III/СuϽ͙ٙ3G+lҥC8̝X2ѹsgK,..f&L&D"iƽM(UVڹs'ܥnݺ nsAR\Ш_`Rѣ%y'sll,_1c I4i׶PDcԨQCi˖-s7*V(xݻwg^z Z\F_%ZnE%]rEГB܉%5j׮-;{A&X}6S*ځBaQV-Q7r]wY֖eeen~~>srr^KdNOOg&Mb֒UbEVPP h2x?@d=<Ȯ\"ЫW/n@0ƨ]RDD$AmdZyfAVh" ^^^l=,u/tBN,.(,)))bŊۃ朐b% c ___QpZ֥KA چ9\7ptt̅lS<Ν}v9%vܩAe͚5t1e ZU(>K+W䞻>˴k{/s999J" 6aAONqq1dڸqc[r'4ׯ/hO?Ľ-iF'Ծ .PDcsvvvǂy2m6A vsBBBBصkD%8qkN9ϝ2Ȅ|ᇤP(ƽ{hƌeƢ>Sӻwo 0VV"$pK˗5k\fƍ5h Aˁ:t@NH˗/S߾}utQ 3/JEݸq#jY^ WWWI猛;:Vƈ"C׆h"ݻw jF܉Ύeff<.{9|}}%y955=Z'̝2LfDqT\9qI(#0F ,k}kkk (fi#ԭ[7A۷޽[rϞ=;v=_оʗ/Oͣ ON:b Çŋb2!$ٞ={իzmse`FL #,,-n:z9,,L6T*=^JSN%[[[QQմb iӦQNNę^ŊgϞ<ᲢFz?Ē9-$&N׹dqaѴiSQo{bŋKMM-sŬVZK.LJk[ΝVĈbpp,\dnlBŢKX҈bӦMaÆݼy3 vqq1m޼  2Dv(00OzÇe˖4p@~NeݻwSZZ٘ȥ=z~~~>EDDH 4:sJ%ծ][ŒL%B֮]}J)))wqq0d #+V7|Sв;wWvv6m۶ic.i̙׽Tn]3fAKe焄pF_(굾Qtu={&a&FCCCFвR<)رcޗ) +W_MvvvG-[>}p,ԫW/A }0J*F](Sjچ>gϊ^f`,aDQ(Nff&ܹS˗\qzzeիGH^mx'sPP!CHRZ=5jdr&O6&&tgT/-z-fYg1777@={|ά_y,cnݺ|^2بQ.$~mjݺduttdeٳgˋ{? 6=x@ԹcO>ecNNN#&4h ϨG===Z_PFFDـ)7nNRRRhĉ2f`:yDZ[Pɓӧw &H_cӤI:z([<<MmڴP`` 3cԅQC 9>&xtڵ&C3AıJJJ9s=p5mڔFMߗ!KѠAjܸe qYNNNe`ymDQPPh IDAThe]F'N9֭[Ge.Wre|[K.]I5ky3gPǎk׮]c;$.>}J*W,Q6F۷oOނcЂ -;m4ruu9#aZnMK 6y7nРAe˖ta4R*J 6p0ר E)שSGL,( }X_gҥUtwws Sdd$;vڵk?iӦQz(22^ѫW/rww,DP(H 1E;;;߿e8` i邖=z4mVݝ-ZDS4|]6͟?H/BbILL˜MٌPfֺuJ 1`lyYb,VVV믿 ZE 4uԗoTibںu+խ[MF2ej*VH={5kdF. Y}`^#aaaʒuaԩS-[~}OeƆ&NH)))4o>Qs}}}"6l(8/{/ښ={Vp7VNb bIIIb3c<6w\ʽό9T*KOOԧ/+=F5D%ۂ@V(lْedd6l־}{"JqƉ{[V-^PU^{%^z,//Opݻ]BΝ;'HVfMd ,o4i=W{eƭ[D/poQZSXJ#AmHOOgsFh?P1f4Rdsll,|8^UZ?R(l,!!Aa}i׮]^zƔbǎҥKs(3~gNj+..fۂ@h s*׭[S;-[=g8q%cyyyqϝX=j8p}Wlԩf7oy;%8qݝ ?{Z{eF׮]:_Xd \ EGGG.1>H1<<}>R 18dkYBCCIR\QQoHKSTR>Lݺut͋B׈bXXY[}=cջwo;$.hСarvvݻwӘ1c$.ågbJ#aaa;tȜ X*}ĒLP$"sJ]kkk_)22ʗ//.=SQlҤ 5lPв2RJf3wbI&_(͚5<(˶CBBٳԦMY`NpټʈQOҎ;d,.s'nذI, Ţ"8p ]~]תUh޼yNKKFU* XB]8,t;qƍ&1wbIfW(mٲƌ#_]v Eb#C ![[2܉ 's;$,VZEAF*ڵkGKM4} ͋( ͛7e,.s'_2g#=-֬YCpu҅Μ9C6l ҳy1źu ~Gq4t)2BׯdVVV4d|2Sݺu _cKE~ ͥ۷˜ X,N, E"(ڵ+eddl*FA sNL ͋(*J ֭[1w"Ȣ{ԧOA۷߿/sFBرcԬY3:AkeeEo;vwxJ.=cGcǎTJA˚>0n+VUV ^ޔ^*)nݺEm۶s)**?$.y͋(:Tr7oޤ8KceeEk֬U ZΝ;Ȝ|,P$"ϧaÆј1cduhɒ%@.yH ͋1(T* M#$eeeE+V} ^gɒ%\ށ.+_Xbu҅(,,Ν;GǏC  Xҳy1-Zs%YsJ7n{O:O>+Wʘ,P$"FѶmxB[ӃhԮ];&͋1(vE:t/4l0t 4HuW^M2ef](Ӏ(44ԠOEՕFM񔔔DSN|6ol^qDQ[uƎK/ j bccCUV͛{GtZnUVMmeddМ9sdpy'`,6nH˗th޼y7Ё(""vIyyyS ͋1(V\Y &L@&L1#>sҗŏ(z뭷hذa鼤T*{aJOOM6oMvvvS\z6/8sL'hռӐ E-֯_O>>>xb^r;C۷o .=cQe>222hСfw b)?~L&M͛<\Իwo4Zf 書y왨jęqDє󗝝Mz7oNE2(py }RJJ tJJ#Gz1EEEѰaՕwj`Ay1?.\ >HMM.]ЩSx")EEEQzhƌ;z1Ҹn:JKK{رcˋwj`>sw!}(׏d.oNM63gNEr(uOs̡Zj7|C999S* uޝ-[Fwܡ'OԩSϏwj`rrrhҥgѢEda#DD)))Ԯ];:y( c/GgmmBCC٩St@G]v>y{{s"777Oi;QnݺE;v;vǍiosHiT/7 *ƍ{ZF4{ly28p:w:=M2EJEoծ]ۛHTӓI1zQZZݻwRSS)55._LW\)W+Vd9KMMG/iiilٲe}LPpWKsQ,{f[la׮]c… dsC;w.= JBBB؉'t#klܹ-Z9ӎÇ|^Ι3{B,2PմuVjݺ5uЁVjU8q";wΝ;G18" E9rϏΝK睒h?۷)66 71B@nܸA_|UVvJ[n54rP*ԥKڼy3=x-ZDkHXAh`8@$ooo9s&ݾ}wZ/_&NHɴw^zښwZ ( EܹCf͢ZjQNhՔ;-Q{}vh֬YTR%i0(@qq14j(})77wjxyyь3Ν;AuE@hd (** F^^^4rHڷojީƆ())~wjѢ@QF,;;֮]K=zJ*Ѱahǎ;5XYYQԩStaի) i@|N1`^P(LZ~=׏^^6{(::N<0(MP~~ԭ[7Zh]~wjhт8@ԼysGP(B>|}}ߟ>c:x NL]tSNѶm( w:PFINN R.]͍Ok֬4ީJPP~(!!VZESˆ"P4k999}vzӓׯOӦMcǎcwzT*ޣd4i)J)X4( E DϧvQ5>*((ڿO?DG@X,( E u_W^AÇhUV_Ņw:#B(++"""O>TJ7nQqq1ʊFMԩS'X( EҲe(((WN}]zwZTjU瓍 t,F"*55,X@D={[>VVV4e:~8sR`DP(BcGaaaE&MDn4mڔΞ=K>,F"ɓ'xbjРuؑverъ+h͚5 ( EԻwojРsyȑ#iTbE) "yAzKJJw}jժEϧ,?((N֭K6m2C/*6oLo0wQ4Oڵ~Cqqq{n_cǎS#CDvإK!z{Fbbm{.:FhB/^ffee=W5ѣGI&4~x3g999ɶ/ڵklUZL-[$JOO#GЖ-[ )P(TZ5 *3988/_IVSVVѭ[t=YFpYzNNNTV-Y&Pr^˾rss)//ݻG<;wPNN$>|8M0ˌ9RSS髯d`b4YC Rll,T@իWEQpp)((^zeݏ^z:JUV)#J%͜9>SȠI&ц 8dge˖ԱcGjР988^QQݽ{.^H.\ٳgݻz噛sNkצ7o_sL-Z֭[SVYfTR%sm)!!ѣc#L~~>UV222tއ9ruu *B?'' (//s>)6VZrA,++K[xׅ\zV*lΝe^\\&M=_C7+%' 8 ;v:>Bpsscqqq|(oݺŜdC(OԨQ矢nYTTkڴ_w0_ IDATK̆p.6  anݍ:|2ٳ'#Fڹ5kBe6mb +P(L}РAsÇ}^>H M<=}T5~bm۶ӼO/6S֛Oϟ?=7|T*ن $ j4ֺuk{P(&%%: akk6n(uǘRhM||LAAAS1J1Kŗ_~I ///ީp% >Ξ=KM6坊,C'Our$ k}TJJ ]tCF`h/F$̙3^mZ*sQ]6:u5j;eJEJ%-]hR(4w\Zv-9::NGVkצǏS=2jժK<ˣlrpp gggrrr"ggg$___=/!L-o4`SPvvVe̟KRI˖-w*B4l0ڸqAmԅ-Z B򤧧Spp0=zT?1/TPP ,(:88PTTʲtXt%''SnnJ%ըQnݺԦMj۶-yxxȒsqQgggڹs'uQmgeeQll,?~]FߧSZZN}LUT!Viӆ:w,9Idr֭[74 (55(;;rrrΎʗ/OdooOJ֭[Gjn*^bر>f̓S"d!CH٨Qz;tPPK RQz `(SlԨQl۶m%331%ؑ#G$Ǐx5،3Xjjm?WޞKX\\9s&0`שΨR ԩ;v,[p!BjCϷkJ*Un+W#olٲEŋ E^l۶-KٳgjSi؈#AaoozVZŞ}XddWNZZ %KHX֬Y3m""֮];[dbbbE( zjsiiiwʕcFb$my&X!rokOlfkk=O cWs(sssE| gggvMQyҥK/zl޼yѣG޽{$$f͚%I{_z=ڢQFlƍLVj[TT6oViӦ1;;;nQ*lzgșIP2lllܼy3<cO< E4nܘ{E,6p@mlܸqΝ;zΝ;RV=VXXȾ[B &|||Dqǎs'"6p@ -_yxxpoӋpuudtȑrʿJ7|Sf 2{㉦M>}pi9b/6hЀ{%{QRrr2]ΎM8QRHIIކE=uQWjX[[jgdd$}||$yݻwYN cϞ=ݾ, `"ڴiS旍ZSiT79YKP(=T*YBB0˗YʕEg׹ׯ_{iaccN<ױݴiFsZl[ymkkΞ=bȨ+*WZ%g~;vZ=WE``$\^nꅢQ =zv0fcOtQ_r{ޥŏ?(3gr۠olFDDp[f͚eR7`yyyڪhXÆ ʍSL+8#&&FU2~J S/mmmE79::usXժUCs >11{ޥEgϞmb pn9wQEEEl^LsO d(GQHHeeL՚5k$NPP$۱$bBdO?A$ΆcXzuڳg^?1#07o{5k֤rI0?Pz4vXbIP(.X@Թ9ydc`rppehgϮ]… :Wn]]p)WN*U^1Fqqq2fӔ6 4sg#ݺu Zwt32~988tz˗/%KȐi1WOԺ.]O>DbٳE+U1O>>>:@=)+0EEEv|}}%rEw}ZpS. ͜9S.]D'O>!d3J$++G2d˗)//O5j$i-[hAGT.;PR+t:Jݻww}g_N/ekji^IGJ: ^P:Q"EWURKQP@ *T5RHH% {v~3g~ޯBsf<{̙&6է(#PY\ <.##CoԗZ EF#邈6WpQ^^nz%&ݻ2C̙3)>>^~eIDD...\^Onnnɓ%`+WnsͣSRG1/_.锐s>}ufmsqkY;W=WTTpĴP1bەӔ)SlB2VKG{:R֩⣏>JV=t-NYˣ,Y3}RGcd49gS?Էo_3 ~z2.jQ6mY^{*\kbKxb.ٓ.\H> 6y/ȩFi߾}\. r8ط8Y{xxp>HQs1cHVz::(rW^[f L {άϝ*z7H,1سgOIwYt)*iҥt9zǹ}QzIԳ 0ƎK<.ۖ'k{wwwN5(Jwqq1رClN=t:3fY6KRF hҥ d,7776mM>]ϗҐ!C{ے Ő֭уNKff),,i4(m jQ ƍݞ={L =zPxxYۨ Eggg2dۭYFU,t:7n͝;BBB_YYI.-X@/|wtJ5HҥůŇ5K*VKzlEm#ݻw=o^mz)~tqQ?(CիW+2G .|9x ꫊_aP6l}W ";wE)n_b/4hСfmn:e2zyfosaxբE Z`>GJJ W_YsܹsHNjǒ ΪFtb6 @6IM#ڵz~ii)mڴI/qJ 0m}4144ΝKƍ|@K,=Y`UU ~ѩiDёرc dc4h*7z[nmJںuBF3f̠K.s=ǽH4W_QTT͛7MfڵI[4!SE*#P xQS%泩iDBq۶m eb}J(J#|A+ht57o"8p:vHcǎ =ׯܹs)**~aZbuP(Y%''S&AM[l)i;{+2hk׮}ڵK5z1{YLݻ7:uV\iִJIIqQ=̙37GGO2e ӈ#h}Ccms>"lN5(hmcvw((iӆϟ7n(uS:t0{۷+:t@{ݻwS۶m_PP@3gΤHZ~U\Ti։rڸq# 8_Kj<?_.8ebl}D1--JRˈ#cǎQ=_=Yf4|Rˌ Zx1iӆ DoK'˅=8f͚[BFaoEiw=D^HNf͢84i/uٿ?uA<ĺȾ41F?3=C4a*((]5jۤje@5(J;=>2ieD1((Hv\322Ҭ[@)MѨرlԨYO7oT$j 6ӱc̾8}b])>c:w BթS'Y_pFRˈ" SÈbDDYoI)|3wDQi>>>4o(JdQqqX7ggg4Wb6k'8S%;wL 6P&MY۾}cǎ4i$Rxx9ڽ{7Oέ-=O=퓒n R(J)~***6ZE~Fwe6J'WWW),,?Пi=qU1bSb ,VnݨO>m)SR E{=FBQ缔BQ(/͛7.\@ÇWB"={6iӆ6nȽ}kaBqϞ=tEn͝;W6vXm2?jQ2i EN'i> s  &e^Gz=͘1hƌޏ`?6mJfͲ/ 2dŊܹ39[{~... Œڹs'-(J4[7H%U;)l(H iZzg4otRÇHoAfͲ)(,Z(s]\Һu^"lFw}Wv;|OHVZFKKKG2GBQ-N9R ; ]WWW1c]v^y;S=qرcosh"2kܸ1}Gu:t(SW損RˈBQ$k9.lobxxfeeH[<;,,Z-M0h޼yt?OP^ܹs<YPz[?" µM~NNNOv;ǛLjQrHȾPJm2G1!!W(ԢE I)U(}pLL 9s֬Y#4z}ѿojݺ5O< qo|_~I-[&X7xdh"7(JC@@LZF]f7kLLDd4iBm۶U$y͛7?.L!b||<^^^m6{CWM4zKv;/^"etIZF͚ؠAB1>>^ͱw^j߾=!փBߧ4m6nܘnjW)VKk֬!^/9s}|yS˩g96M-#1GS6m$mdo"ovT Ţ"9s&v~aZf ngx ޽vΝ;gbSR ŦMrĺeDϝ;wV()::ZҶJ(^|Y뒗G](B$ѣiɒ%(mP.]hΜ9\z뭷^o2)T 륖uQ~XLKLLdg~]!6k֌/^y-c4aEn+'|bц4hЀ~.ٳo!+ RϽ eddD-~K.v VPqN?9^ˬ"rNH/"}g &ȤiӦM&JzW8dr1 ѥKަEv>=jVaU֤UP$":xc"_Vηj*ںu"mO8*C IDATY#yspp 6Pnݸl2x"6(53jT100Pv" b:}Y 4HlՎ(? \_yyy4sLjӦ !rd"ƍ=~xںubr4 ^̥ׯٳGM#.\\A$TŸc eb,kpD޽{6QUUE+VH?]ߗY VS(иq`tjҤ"2>C7nF#=s]{E"*+i;Q,>|QLNNcǎqikϞ=ԡC2e pif5"ݻi銵ߪU+:~8U@Y{=W/_N5(^|Yҕ{V ӲeK\.;5 ;Viذ!GVJDD}!fU"UV)־o/ŕojHk֬rj.\PdNMM#F3"##)""B˴i$o+P,++3{ql'ʞo?Hz 6mj -*_%/ݻW?۷/sqkNڄiD?8LKӦMgSDP5jԈ P6b8;;ӄ dcŌ I:-#fbee% 6\ߟ.\@CUq~iԯ_?Nktޝp? ,޷Dll}KMMwu:uJygh4H[x'%;Gy+KOO} ]~]Ro}nG!<:#22ryύ7ؤIVo{ 6k,VUUxn۶t:}OP:ڷo/߶رcÓ'O2777}oP?c >\X#FsFF9pqqaG/jsuuޟ۷/,}ݜ8q"cXw%[PP`wŞ~ivV\\̪Xbb"OXƍx<67<<<$Cᡰ-Z5iD~PC9s氢"EիWYFWjPLNNt'4oF#y~eqq1{{sZJRRYY)owףG݇,ְaC' *++GѣjYtU V[tR6vXޏ+RBIZ mMDÆ ْ%K/cŋ,44Tx E{%JNcW~0 <xWVU__ghٲ%[n+rA+rCbff׽{w;::ZsƌPf.])**_J?lWvMڵɝK GGGaÆz_xî]&Ń``bꮺ^^^lرl݊Amƍ6L Ek@3M&)nrqh/JKK%>vK7X9ѯ_?V\\?,β/xLOOg:u~x۱cwe?'OfcǎetS6yU_PL.]Nt{I}`v##""޽[rqM5#Gܧ[ZڐD={ֹsg}m֬+,,Շb.TtI{ӢEx!~G^zFviujOi/٘1c_Ͳ˜/`P{(u5}ꩧ$ڭ[?lܹLV̑%Kב#GN+| ]*kx˹ N}*([b\tIUС;q~Ϟ=WNwȦOn<$&&;lĈ,::ڢ߂c=|MqFY͞=988^)j/k۶֭[3lGpp05kE#i~VݠF#[~= Q<6mڰ[ r-xݞuV.}2lڵ9~R# -_UUUq3)wѸqcI@``l/d ,`SNeGf=z`:t`I&ALJ9991OOO˚4iš5k:tvƏyj*cحx:RBQZ˖-^;\R5J\\\ن 2Zl)i=D~1p@Y˯0vٳ=wqqa#Gd;v쐝#c ~[ҶN׳ÇJJJ؂ l\<:x $KĨQ|˨dS)BQI>͚5h4\\dz_|έQFge}/xW\YXUW^~/2UVV֭['vc6mz< 4isrr}֭;Ϗ]xavj*֦Maj޼9裏d_S[YYb_b<==7M:Ep1zٳSXjժYۤQXXB՟K6""]9#ٳĭͪ*:x ޽Ν;GFFsqq!ooojԨEDDPӦMUVShh(|t:t(eee_E-[4+WP͕H~Ν˭|:p={\BYYY[nnnG!!!I͛7|PƎK6l ggg*++3VZŋ&GPPܹڵkǽ3g7|C6m$ݝ|I8q"uڕ{SO۹+ۂ#00-[LD_ZZ;vM-{SP󈢫cn'u"s XFFuSZv-svvR.믿3sw3+))...ڱƑ~k8p^ŋŋO<_M6e ۵k+sv5X[ƍi4zhjӱ[EEEd?>NG5(zxxPAAm)55sF 0~'rpp w13g͚5oLfwyEF{4uTIшNE|4h5?TRRbv[QQQ3=n\\\ᆪ'x"N.]xLʢ7oR^^ ___񡠠 jݺ5EGGSvAYVVFC;w*Ҿo E?m]II [h]^q\e#9^>—---lK}iJJ >\5cKjj+7iDq+t:[dͽ.JMMUD@@{wAfO… UQ$X*:d~<n&M*|I;v_[[nDž'ѱcGY\SR<%PNNNw죏>bqqqK.\Ⱥu!k sn7w\˖-3" }駒u[BB{饗d70yC۷1//tWLLǴI ^ϦL8ڳg֭)RDEEɓ'uֱ+W~q;{6qDָqcۖɩ^ϙ7n0!!!f݆*;;ϛWhтmܸQXxq6zh?>>>,++ޏgU#JELL ۼyEFx"1cy<Ƕ Z-4h;q#CYzb]!<6p@}Y$;UTTsα+Wdz͛]S,u^qZYYz)KKKv<ؑ#Gܹs_~rquuyFac~ծ^p-\S -ZPkl޼wB६(!!.]D.\ .ŋBHNGi&eee4vXڸqo߾~z 43о},e988P׮]cQ۶meO?oN2۰ahe_NFcǎ)5h4RnݨuPPPN#OOO""*--2*,,"JNN$JJJsӧ%3^ B[n5{;ggg~wss!CO?M#GGGa :p}yfIV E+)00BBB( BBB(00)88ݝՕŅz] *((377 )33ƍDiii$,,ON'?ޣ/Niĉ4d"VK׮]-[ʕ+6AAAԶm[jժhт"##ӓ˫yyyTZZJiiit JLLXx2 =$$LB 0~GZnU~m#G 6V___zGGGyڵkG:Nʢ]vю;~7o*xrPAƍu֙MUU6<==k׮ԱcG ???ISll,?.\@GSNh䜹rlNMQUU@&QPP@+wLJ"""AJ>>>JzfjFYYQVVeeeQ||<%&&(4" rqq1{J2Q\:q4ЊNCMG8ѩNXX8gk3ٳ)""g`c<==3gO>uՌhBBCC%m9\.fh4rJoP$7۷իW*K#` Bq4ydM̓>H NIAp){ͣ{ NEFFJ"¥PLJJ @NZ- F'.+VЍ7x4pO": H5-.fSnݢ={y4`˗DtVI&BŀmԦM裏"E̝;Wt VM6420D"6m5jԈON'O<)͚5;ѩXvI֭[Vu("{ZK.ԥKj޼9EFFRƍIǬeT;o=e4)??s7777rrr"NG5 mԯtm[JvjRΟ?OӧO]vNO4x`;}4uA@/IM~W3Ԕܚ[rMNK5HԴiSjڴmD999EٔCTXXXSUAmII }wI:1z7>_Vm_~ԇ 5nܘ7nL;wzWRQQAGW\3g/B'Nj$&&Je˖-OOTO0Wh˭[Da51MHHw]]]iӦM111Թsg2*d+ٳtY|2%&&RBBڳuޗ SҴiӬt~:]~͛TPP@TYY5w{5S_Ϊy|O{:iРAOSYtJSUUENӁѣ&?_%;;GǏcǎlIDATӧq!׏v)i^{.]9# gcEEEV~5yzz [u֭[{1 J~n<GXmO!1٦M}f4޽{٨Q@X*Xeeb 6m4.bǎ#[?jCx1rHVXXcŲJJJزeXTT}@-,Y¼lh4vMIϓ $ 8D6mXjj*e@/_`z/ee˖+GXo`mqݻ*$eѷ~K͛73fPff իWٳ[`z)yXAhC._L#F&._LݺuѣGSjjtBHHyxxpi+99zI׮] EP EsZh4`0ТE}taX=zpi'''G)))\utԽ{wۣP~7<==Yvv6N2effbQG|ײ_gSN|tQs'22RxQAj*iё#G}+p4p@L0S}mII )@P(ڨ׋NlBztѩXaÆ6.]J7n͠A$o{ 2[BF]|҄=g}FÇ}a󟲶?r c8 Evq!;k,zkkХK֭sssiȑTYY1+PZ(~D'IJJ1hʔ)rJ>.Z[:u*srN;F?9f#6,''Ǣ E"@=oߞ  y۷ӗ_~1#PY\xrss9f ?'X"F#i|z8gjO^C8f stt4o<<-hٲ% :ToN95JG :9"O>D"tRD r>ujR`0`@X`Dц*~K`kڵkGO?mcꫯb;#FQO8A7n*6UV}0a1{ [pB˙lذʀƏ/k~O"`k";|Z..\`@-+uWZZÅ!>bbbdnZx?(ڨG}t:v}pmh4ѣܹs\zΝ+y˗Sff&nj@ƌCnnn@~7BCCe_m@5H~3???}@NǮ^*<))t:}A'0hȵ,XM{H3gΔʕ+͛36l5mTVk׮%)#«U?e}ɓ'^'BK~UTTJgDM:uJ``<~ T@pf͚z#SZZVG dD۶me-S_ :O>w)N=V3 4zh Ç~(kŋs쭷ޒի9dHx:c jAz ߿_x=L$/UfcUqpttd/_:|駅!>4 ;} B!<طo7,"/^:EdFDlԨQqQBfO!:v(h4 e7oޔZ\l~ ć_ B!<ذa7ŋ e۵k'1c %Zh!/U2qƬR֛I||){1ƚ5k&/ѿ.ϥk &BxvZYo$IIICx?53~y}A ///$TRR!N=X-h̘1ژ4ir>͘1"""d?r? ng…!#+VbͲq[NxGdd$+--=c:t6lQJJ sss̈́"&&Fo$yyy,00Px?ݻ|߼yiZA`4rHATOafhZvYo$ӦMBr`g͛7 BL888pcرCx6@/7˗/a\>clʔ)˖-*..f7ͅfD`` u떬7zJx?ýZͅ a7nk&? ̈z#9y$h4@9+kpmx]atԉۅPvsTOQѣ e\>ܫ:tHxHnSXpp>!l3JxzzҚ5kHHnĉcY؟+VPhh(6Ϟ=˵=n{njРcӍ78dw(UbԸqcYm,ZS6i̘14bP~~~oc?ۭvDFFDn*_ K.{n}A = #Bر#y뭷 aW!<4i7ɓ' x衇XYYע)ׯ_O21an$Vo^"!<Dڵ++//)O"((z$'' +o8;;s;#tSN1WWWCWb+ӤIںu+999qi/--㹴`/\]]i֭K4oöI&wQLL vSSSiРATRRµ]:VAm6&g0Թsg< EH3f̠ .p/o߾ε];ݻwS-ʵ=[o~Y1ɉ***,V~f͚Eۿy&Ӈ.]ĽmݻwS۶mɉ{6lEё"##- +M4.\@_"Eb~~>=ctymF]vQiUvl^M6р<~˖-鯿`miܸqрɓ=@}PϏmF:uR1kя?H]tChƍֺuk8p 9"4h9rD^cDEE8'v}E 5Yrr|}pvvfc˗/zG`;v;Bxvݻwg7o޴țhd@XSh46eVRRba}W~pttd;wfSNe[laEEEBqqqiӦQ+'`W1~xiÇ#l۶m} lj'cqXcǎ0prrb/&e!|? ٙ͜9{| |?RhZz)6k,i&όFC}m۶1777 GO#""8qB{KJJb/"OK+2e KHHs2V+|)Y-ٴiتUرcDŽB/̙t:}@+4/VK/}.::D۶mÇٳgTtZh4ԩ 6&LnGp13gXn/HaaaNM6MRXXiZ8''}YڵkTBQ![+VP׮]Er_UUUK'OK.ѕ+W(..Jtzuh4HQQQM۷޽{SXXd+**}ў={诿+WPJJ贸qvv&@ ` 0 R``Mȑ#4j(:`P(rHs̡t:td~:PJJ %%%QJJ э7@t`\\\(,,aÆ5EDhh(Pxx8T-\BIMMTPP@%%%T\\LyyyANIQ󧏏O=<SZt)effN@6U(>tai q-YV\dbXѢSd4~իW/[+ݰBCSRR] rMztU  Tbb"m޼6oLǎ#lcl6U(ݞsN-b4̙3sNӢS6W(EEEL} Xk׮N;۷n޼):%dbvСCG-[R DVTTD'OGѣGرc!:-UB6777 @jРP@@܍ח|||jPQQA [.\k׮`*Uh.FCFMz\\\LJ\\\HדQ}'"rrr777rrr"""///jj˫kQjj*effRjj*SBB]z]F(8C^oggCJ5vww'GGǚ{zzNOkrX` (//eggNPR(%{%Fsjvߩ]y{{F#{̺~~vs^JJJnǸ`0P~~>UUUQaa!SIII6TUUuVVVUa@b* 5RkkW{/0@rgd)TVVgZmw~eQ^^^Ϳ,j.@m($ E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` E0 "BLB&PP(I($` En^`%,Q`3OGIENDB`slidge/dev/assets/slidge-mono-white.png000066400000000000000000001163631522535037600204700ustar00rootroot00000000000000PNG  IHDRtz pHYsatEXtSoftwarewww.inkscape.org< tEXtTitleSlidgeqtEXtAuthorTrần H. TrungU.y IDATxwX?PPPP+V0AŎ l5^07Do,7Q7,Q!zEcłFEH/Ufggv_Ó'xgX9G#B3ADDDDT z+ """"DbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ1Q$""""(VLH+&DDDDE""""Ҋ"iDbHDDDDZ٨Iddffٳg4ŋFVV222PH888`mm kkk899PT)D].::GEVV5kN:AѨY|:u xT8::)z.]xܿ<@RRq=<|يJBRPtiTRX"WWѢE9?<@߾}5k͛7CY&M۷_#BCC[[[#"%hAwD=ŋK.G;i4xxx86lM4aERO)))hڴ)nݺ+V60hyyy:t(V^v;vDdd$ )%&&"::Btt4nܸvHh4D&MдiS4i 4`Q;wƞ={٦cǎ!?j;x`XBለP(*8p{lmmѴiS 4svoctMT۷k׮ GD\p5B^^ǎC bccelٲ/^T;Ν;sر#\\\hi GD5 .Aaʕ EDDDQO ؼy3lقׯɰFǎ={ZsMG呟/FAbb"*Wl`d{=tLŊߘBD%{.V\+W͛jck.ڵ  ѲeK޻w$ʞ={0|p"k++^7##HMMųgϐL|RYYYL2{)E{bؽ{gvpXl-[jժaĈ6l/vhqY9s.''Gcrsseffb_qYܾ}[Dr+RƍӧFZgDŠ+P^=t;v`?*U¤IvHիWuR+++&nܸ*UBPP6oތ7o2I$eeea֬Y2d>,/YG?>WCҥKjdQ>}iӦr;v,OCRӧOu>ɓ' DB:)Ɏ'A0|p>T;"qI2Q"'' ,@j0fܽ{W,ZFF͛OOOL0iiij$t1ǟ)!.SK,Q; BݻWqL!22=z4 ZjaAd#Z8E߿3fP; "Qg0Q]: 00׮]S;zcРAALL5VѣH4IǙ_s!233waR;,Y`ooE ίy,\FFgC'N@1aL6 jDdvXQnѢE,*pwwc-:Q<}4BBBv(o5jpssC2ePti.]e˖EҥZ://)))HII$''֭[G||<^x!#??faTZU퐈 +oŜ9sHH._[%4muA5PF ԪU e˖U(S ʔ)ooﷶsIŋqq\t(L;u4h_~S;""""yD7(V7xGGG|)qFtUhQeggGGGϣh$/p}M\2GH>o߾8tj1XYYvhѢ|||ТE ԬYw#P*T:tرc8qJ|iii߿?< r(HO HheŹsu|\\ʔ)#S4d꒓1}tŒĆ ^z78x 'iK*}*U ]vEѡC1ŊCvЮ];--۷c۶mqcZl޽+V'2RgIsE166'Oԫ4&tŏ?(i4(.^G60i͚5ѣG-Z0+h߾=ڷoW^Edd$V^mНDvލcǎ(]KdN&\Q\|}<{LHT`ҥo~O? Ibb' &&W^ٳkI6jBXXA9A˖-qMH]\wXvHٲѰaC5J$uRJՇY'999߿?~'ըQ#,^IIIXd 7n9M/V\$,X+WVׯ_GVz?"-[Ȳu&s䄑#G">>+Vw$Ld-[&K?/_2~qqqիZjǏKxk'dvbnn.})˖-{EX[[r kIkxqa|G#H$VҥKe_HHH/2./^YPZ5̛7O+++!../{E?ۨډ"K/FB|Ljoggg,ZkiӦҥK˵0}tE&27(%77WhY#u`Z*s}شi=lР{=1dVw޸r nbիcΝXd dڴi8uV}vܿ_Tۺujw}B"#!~7ԭ[GÇ%#G`˖-U":]&lM6aҤIYdIlٲ7oFrd j!///Yŀrj"cŠ_Nb)Y$-Z$޽{ 'ЦMQ5i&9r-[|ku։v}?ExU :TG^zA``l}ԩSNa{u|嗲IdnXQkՄݻwj۷o_l+V,;woxW"88-ZRJa̙x" ;bڭ[7Y70D˗u}رcVl}tXb&O,kK,Ql9`EXbD8p 4 z%ëވ܏#BCCPzŋqyQ˱vbA&(~嗸x,}i4̚5 ׯ7!$ޔ)Sxb٪ `„ M^Qʕ+ES6m Gv%9624|7R^3mmm1b$$$`̙prr}+DkĂL:Q'yh4̛7׿d鏔1l0믲]pׯ/"sc`UV%KPF L>]pY,\:+z˲vbA&(?DFG!2R| ߤI-[D+˗/{{Qq999ؾ}H9۶mCݺu1|pdn޼9>}Iډr;&(~w?齋F… 1rH"C6lM&K_7o/"K_DDqs(>~X:uB_͛7)hժzL5kbӦM8vZjWLjXI&3g,}?~,}a}ײ?~`RXrEqV[O3h{kqfǫ̯2t駟p%Q3 cܹST[%&3fֺ0ܹ={g;;;;{o|H"&dffrR0|pYg2;VpQFھL.Q<,*U`Æ fג9::bÆ (Z}-]ṪVO8+Wjۯ_?k7?t̚5 j’%K0Aub̘1HII׫̛6mQN777QgRT?]MԩSe ?XQܺu RhύMNIwQQQh֬q ԩS[n+couDׯk'd2bjj*֮]w?s̑2=z@vԩSHKK)""fEU+++߿v]vs:7su]vOrxb\pA%%رCtS?d 6轟s-$SDdƏ񹹹'"gidݻWT[mk'jShQQZ^|I-2y)\X1ի6lA^; If󙿀ԬYS>8 S4D*g2y@sxx>MܫY&f͚%zRz&saaĂL"Q?Z<==EmDM`ԨQz~!2mVQ9;;~ҡC+Wve1K~4hÇcbddޓX XY咞>C^k׮I$JdN,xƊjۧO7tAd=$ΝCv˗/Km۶8y$6mڄի;w ɬIdNwx ;7774mTyyyHHH1""dI.k'J< f4g߿!Cqz=W۷hҤ|JډJObyŗ/_=D&k׮z/S$DKH =ggg_նߦnݺ>^v NҹKsVZXr3Xx1Ο?~`quuUtĂ>Qx%L5jfnT,agoooԯ__h'b|$U^=M"CuD-E@Ib-ZLYDDjժS"33SR[ɓ'm6g2&%%;vV(u?S>Iȴ5lP 41"d EU%JW^zu֨SNΟ?'O}>Sset_|!V0#''vpQ'ׯ_|z[hH]`ښ`ŋ/ٳ2Ҡ˦TULMM>ʖ-%KtOf}NK.ΨE9Vghq}Ejȴ{EQ3 䁐8;;nÆ HJJHY5kO?$z łk׮O>1*\xN,Ȩu \\\dL… uUJ̛7OL9WsssERF 4o\s+VLYYY?l56gϞ/dQuOf]ɐk'dԉ_/.y+2+Wƾ}DUիÇdɒsEqݢ >… n'O`رhڴ);&???;w/F2edprssEp7ډu#S'W^=;w.SZ5̞=111(_ 's(YFT;kkk|GSiiifwvv6~GTV C^^}ѣطoҰv!(6p@ΨES_qrr)2uŋW_}ܾ}GšCp ܸq&LP13׊ӧOuVQm;tȱcNJj'=c ̄nݺ/%S[F l޼GCsjquXH"!sRbEu֜BZQܸq#DUӥKQk޾}[ܹsh׮z7n|;,XK.w D]NTy(vԷIOO)""#㌽(vIF 44TTɓ'hHѤIDEE|BCC#GV Un:Qk'ؠo߾H;NTs"!"Fs.]dٺXn&(ڢO>zѣGi*8CdX\Qܼy3222D5a:v\ŊԩSqM \Sck'd" :T~ۇ۷Njb5׌)WŮXzuQ+3fG_hQ|HHHIPX1e)V]]]jĂLܸqcYqq[?"0Q4/ZQ}6>,1Xkkk_^Yvvv1bnܸnnnotY;_~F[5qd'!!Ç/"?R37jKcpQ[YY!$$;wDF µkװpBxxx!ډ: Xد_?~7l؀ Ebŵk׊jgk'CK.h4/rJUVjWn]Yl}8}lY:E&IJhT}=;&zbS&H̚5KEqlڴ 5kT8J˕;wj;x`яݝG![U1++ zNjCIŋ￯p4ʲſ/\z#GmѴiSwVZooo,8`D$StoVYCpa/_^>,͋ ΙشiAAAFv.<<<`̟?uQD ԪU ŋW;D#KΝp41ɻQ^=y&C$Dz6/VQijgD5ƭeeeZjsG&M$ܹsN4C;[[[cѢE\zxl}Y=S(];J*U8Tb...FvbA&(@-d_&667I^ǡgbJd۷OT )"''ׯն(RϤ3f@Jd޽{h۶-+kDCEJVE1<<h4F?yLbĂLpoLڵ+/^,kDCŔ*ڵoUTQ8Tb}ĂL:QVZ᫯\|ӧOeqټJEٳT8dz>|h6k'dwI&O#""ШQ#?~\ ͋TVq+@!Kډ7@D0 6n܈ի+͛7Ѻuk% Tz6/PQ D  ~` @2eF>f(M3߲e ?77fB˖-qEAd8l^LsN<@ܹspႨ{hVwgooolٲE111h֬>3*v"Sġgb EU*UUV GCN,:t5k}?Zj^/h^ѓ GHد_?X; ;cɒ%-)Vq4ܸqCLߝ-[ ǺuPvm 2qqq9/1гyR!6sUGGG[hR}fvbAf(@ݱo>*U`ʕ+@.MCX+yyyXnAAA\;gl۶MTN:lٲ G ;hذAϛHM6رc7S"cǡgb(XbOJy}wŅ  ;L2>|89vHD:гy1ƊER.YYYXf ի7tСCQD "3 N ֯_WWWgϰdj uٳvXDpټcEQM -Z1c 665$Jvv6޽,_F 0p@ܹsG\]]7(vO?v8z144_}={lMH"]qټcE c FD݌3/ޝ (S "##%K{nnnn۷/"##vhDгy1Ɗ"WhCU; Y0Qbqƌc x~WݻwGDDCf+kV"}bڵfs_3P ΝӧO:\ؾ};QL 2v8EJ:VHHXQ4999aǎZڡȆb!ׯCa֭RӧOrJt...޽;֬YgϞYJ*| eŞ={~H ߿͚5S;Y1Q{|2[899;48ܹ3-ZӤmo^hH_R%03[l!E޽q4iDPdDQopM|(^!*;;{#PB4ofB||ڡ*^8F)FرcaER 9͛+~.h4С8͛7&`ڴiy&+Hn'ODXXj֬ ///|78{ڡ:u*4h _|___#"WWh]M659^M"={6nܸF^~W;S#|X`Gʕ+#001 /_ћړ'OЯ_?ٳG[[[믿ƤIff9Át:f„ ={B]RR>$&&"11ϟ?G^^ %J@ѠhѢppp;ʕ+xxxN:U-DQF?ƒ%K?nnnݻ7___P%l߾8{,RSSQ\9o#FQ!ڷotLXXf̘PDDdhLH8q|ݻ7 $z8tg"mСC:_cڴi EDD1"((ǏGTTzikwżyаaC4jsi -"2]"if/&M6D||<&Ne˪dgϞ_| *SNشiw!2cLTV }ܹ}!(( H(!//{Ň~+",, jED23qp(5i&ܼySNHNNƬY鉀DFF"77WH(E/_&MBbb"|vXDVB۶mtRܿ[nG}GGGC޽{2e *V\zU퐈HV)Rw5k )) VBN`ggvh:Bxx8C""HDLݻw#99ݻ7M'yyy@ӦMѮ];ڵK퐈HAt>E"DD(Q ͛#lݺÆ CҥM'QQQҥ 5k`DdX(EdooݻcŸ{.ۇqƙԖhN:vӧOig̙xcΜ93ۇM>@\\Q(E3qa߾}x16oތ!C]Jl޼OvHDVY+Vz˗tfΜ-[BѨl2ԬYsE^^!Y4V[.BCCq_еkW)RD^S78Y,V*_<>Sl߾Xz5u[[[Cۉ'ШQ# >iiijCdqXQ$"&ggg`۶mHJJ/6mJ_|,Y^^^R;"Š" Q)U>SDEEΝ;QV-;wСCL8999jCdXQ$"&Vʕ×_~8:t @ѢEU'??3g΄URHDLI֭[#<<III;w.Thذ!.]Z DE"bH:)Y$ƌ/"** ]vUe/^`ذa:t( ~~"K"1Q$ڴi۷ҥK2d*X:uǏ ~n"s'ǰHd^(ԩ˗͛ C% z(4o׮]3y+DDdSlY̘1 } h09Q$"&$%KbƌqFiE>}lٲ #2w(ERLٲe`š>} 22Rs;Vӑ#G0n8uڵ+F! v d;#F 66Vs!""=zP\r˗u:wU("" ϟ?+Wv EDcǎfXlQEƁ`|||p,X|o@FPPI%ݻذaNOn 111”)Sƍ FS4hl20Dӧk/Ɖ'#|׊ j_V(&$$vvv:]Cǎ[>Ə/8emm-tE߄^}߿߀?iu߿_P,Yυ۷oN}}g*Dw1?ʖ-kJ͛'ܻwOh&(~gK(!Bɒ%C2eڗ9$SNtLNNV;t٤ 'O/z M Zҹ~򆓓#[Nhذ۾lmm]?v} 0@埴|233իW 7Vyeee%tM8p?B\x7N{V;,իWի+yA/ (vEko>C[^^0o#!66ϓ'OT|%%ԫWOJ%KJzk׮j.|o@W5kmˤQF!//sƳyY&N8֭[w~~>K%e6'aݾ}:t1cLZ{ݻwǙ3g$c n'%%!$$ڵ3:rz)rrrt>NILDDDDnݺ4iΟ?x{{cΝjJ׬YG.] )C~d IDAT֭[0aZ,IǙWFzv(۷oGӦM%oN( 9sF jd0j ѣGѬY3j8tݺuCbbdꫯ~ ,z)sfgg5k(޲epqdeEVVAmuZ.RQ|w//^P;L2[FLLeǎܹsÉ$gFRRի ѐX[[cӧcȑ&w34fR+X<{ :u† (MTT{=رCPTcJxhSNUdJ5ddd`ܸqٳ'=zj,&(޽{? 4p4dhXv-+kϟǪUdӒYBE1)) m۶šC(i4~Oka’իѨQ#m۶yrj1_tXXmV ooo""C{UYlӦN2/_OKeń4k .\P;eJI?aȑMfAĉ1h r`ݪЧN--"""ФIR{.-Z?\,9W=zlhPBTZK䄌 HKKÃpuf*I?eeea vNkkk899GYYY֗1]aĈXtڡLjj*u5k(2]:??cƌyÜL-OҥW?QFN,V322н{w\~]ԫW5jԀpm\v W\cpQU1mЫW/jIFSN@J(3EocgqΝ;vo0`,oH7nTRD,X /_^k._ڗR]j0qD?B %]c^ƑlܸQ0l0j_ߞ>}*KtŊitB(ׯW;L2"Ok۶m\9$Riccc5wSWRtKaܹBٲe%]wʕվw}J*s#:]~GZy}-[4~-꽗z^po^GaϞ=o)VZZ1D1==!!!EEbʔ)oٳ(6a75lRA˗/ǥK$元lٲ2FexܹSOaaa8yc5 &OK~3W̑f8{^}L4 [lLQ)M6ضm%rJ_&QE-ڵkWDC~ҥ$%%(X*}<3"=='O|;vwwwROvvkO{O?$"E`ƍ}5j~;x ,X xkkk,^SN5׬uXjc (ŹsbƍvMhT sI'NDsppΝ;ѽ{w2~j?$hda͜9իW\wQ5Qx"EߐZ$egg'f'N!bpJ\RR͛7fС&?Eh! E!SN ibٲeXt$_:t耽{ё{lAꄄ;w'Oݻ;xxxH'''Qsss3҆Xx@/OsϦE} ž}JZ(J٣(dp/..ի닐Iر#\M6zWFDԩy ſ/fQH%vgjժq<(sXXvV(*G1WU*z)aFo=_tI6wq0a]*RfMlܸNBf͸Gi?k.נ)));1 b_3nnn21JQ ׻J2ңxmhkܸ1<==%ȸWݦO>dWK.޽;nݺ=F7|=(@ #66˖-CFʭ̓'O$?1-ɢڻs_*q=x{{˾0zll,>nNt1gL)xBL>oƾ}ЦMy **J4raN=<(GzW7%( E=RӾzޓ=H k֬U:uꄫW:UDdRЯ_?bh4}R( 1ۛcرc6l3gJr}"""0{lŌC|I E7oݻCqELӑ#GDܹ3L̋RzTTppp jncS_یL&77W6wx":tÇÇ\bsθr ֬YcR_J$/4i=zp{ 1-wվvfXJ)?F)G1;;[7}effF)//O6NNNC7=,"Ν;qIŎC| Eٳ8~8xtܸqCB]v唍yQʭg9R‚zjc&P5/P a׮] [stt۷ocȐ! ]t'yh"͛7os(GQȠus,KJJ7i_C&d\{dԭ[K,u,,,#** ,0/ )`ĉb]p;v(VƍEŰG޽9ed^LGoW ]Ѕύ^R2aC/GBPVSHa˖-@HHҥ ]իW8W1X8j(};!iHKKW^甑yZ(*a9Z(wˠ;!{KHkeP[5jZO8ƍKrceBQpU #w}':)1[φ., % ]Sz zPtttԻͫ޿CE۶m*& Cs0XSNq9n2;v 7nJ*0`̏Rz%2^H(@)ӻ1-S"00v? R-===1fnt:ƌ t0ưpBqFiE;;;ۘcR([( Qw;( )/td?ϝ)i&xxxHr%1hfѣG>}:xDY.ƍ!ETR(111ޛVVVd#lXt:[ ?'ФIIΡD/0p@1W۷kLb tܙ>DRJG1F)c֭Hq NBQ—.]Bf0n8A+}ܹKr%3x/~ g̙1oJQCJJ LRzE煒[<=֭[R:޽{9sYZÌ3D~\=|_}8 6E9Pʂ^^^ڙRzlVX(I[]tib%R(ܹsQV-1>|w},g)NqqYGsf/oJ\F AmlRzU*޽tۂIYۣțٮ(l=_?~}Zt&MX)ֳB1**s&M)=/^(Q&%??_aR(HU\\\ߛzBV(ϖ$i׮[liӨX4A.\ꫯV0JQTDDDHR:%ĸ\tI(e(W)x}̘1! oIJuVe?ɓX4!2duv>}pȊիW׻݃$x)3o^ 5 b b) EOOO~~~,ٻS|rIbZ | t:$`Ȑ! ?#H)Lf &<<ܬ>G6iF&11QYÂJY(@$OP>H֮]ǛՇ)..ưapY.N*X SJ"AkffffUbRRvrh޼^m/Q6!55h{{+|;B IDAT n߾M92BxV ]u֯_V -?K,)G1((HP;s.G_x8p@Lþ}y_v҅j8q""""0gޗY FS(VV 6llroÇ'Ҙ5k6n%Z7J^{*;&\bǎK.]vj/ubZкuk.v튫WbժUVEFS(@ݱtR߽{ZⲴ c òe˸Ŝ2e޿PH(G1 @ǏK 3^[npvv3ưi& 3Ott4OQ1.`ĈڗC<~8CQ>#⧥GXrY|Sb?_~%7_-yzmllfDDD 22R/grzs߸qI]_v΀aǡC z[XX`ٲe{.C4+য়~B׮]%_TT)So߾fケA~n:n1k.qI^EY䜉q–-[3Ǐw^ViիK.z+))AZ #y( E+++ڵ ח<BƍgIC*'11;vđ#G]f $/2Bqݜ31.fb׮]޳o(yYjQc jL[QꊃJ6TJJ q!++Ks9sZµk׸ ù$[ЪU+h4_&K?CT sķ~+AFC3Bx͟E83*99/r_`5kԨ!wej֬)y;r䈬yt:A=foo\QQ?~<ܗVFӱ֭[} nnnɓ'r_)d|Y[[ر#KOOESz.;uܩB-57} ru {AKѣG ~B/ٳgFsܹs]ߴiD=z$[R),,dÆ Ex"[ojk׎X%''Lff&۸q#֭oB!C7RzXJBz/uhii)˭td5ܗ)))ļ{XKx#G䟭lXPP5k&*OOOv%ٮt֫W/MPdJamm6n~x+..ff#F`vvv}lj5[hn EgggA-w1򘏏פFa/^2!""u *U}ھ}k߿?͕cX`w.\ ޻w9::{{{}vYK. l9s&LP,O?IYCRf͚vi:l-]QjU'@HA酢Rݢ^Fs=YYYlܹ`M777/BF|]o&-,Ѕ:^~=kY^^ܗl2Qø>.\c7od76Kɉٓ͛7߿hoSgddǏ֬Y3V.hӦjSz( vܩ@D)fCղիW3?===e5>Zjl߾}ٳ^z.^jk-τ \O@@;}ܗSiW\a-Z}璏I=[bɒ%[.6l[h۹s'qA%&&Çŋ!C0___ ‚͟?04BoݼyS_pmѽp*M4eff,x石ի~UZUrG^!n߾͜D=F2 nb3_ces&JƎk+d$''I&1 .k>|z-˛MV޽;=z4 f˗/g[la'OdW\a,**tVXXȲYzz:{!{r 믿ؑ#G؆ … G}z5j$ :1GzdXm(J/B{ܩ-/??۶ml|w^6l0fee%gooov]AթSGi_=g۷ogz#0Zh.//mۖcgf)))r_Zduɣs R۷omVVV,$$DcKPrX\\,ywN4ð^zlժU,55Utn=b7ofÇ;V_6CȬS___g?#Ɔ39riZADDDuֱAq}>j5[z5j߾}.Njj*kР׻?~}:̙ӑ{h79tbSNRaX`?VZ+^ƍ%1sL0NE4ggg߿۷/|71b 4wwwTZ...Fzz:>}Dܹs7n͛7,yسgz?83ŗ>_4ޞ͚5h2@?ce.5 t/ //W-dyĀ8тhd7*_ҥKlʔ)Zj(xT*֭[7{nn]h .5ki30<\pA=O ЫH0vIК5k٫[[JO<?&&4?߲RCGxB ޽{٠A(U aT(rzj6fV~}_d7776|pvŽS V[/իWgYYYr8VKOO;mne+[lɶlŠ\Ozz:sss[*/^d4 4`|qgΜtnSpXRRZh!O}vtP(T~uI?XYY&M & 6{)tW8d'N;r¢R?St%ϟ<==%`vIe߾}z>ŋ%Xݿ3///nϧ {7ܹs?|Ar91tȑ#lĈFz+|G[Dtvv[oۣO> ޽ǏGfff^Nl߾[<cõkp9ܹsO>ɈGRR\䠨(((@aa!rss_cmm XZZBh4򂇇PzuԩS5jԐIEbcctR>|h߾=Ν ʝk`ڵػw/8s Ξ=g͛())ٳ'z=zjժO,* !qF;V6-++ ϟǕ+WDFF"22iii988 007FPPڴi7xjsұ;B!𗟟wKK. 4 ~m/ӧOdӧC~~~ [[[8::󃏏+B1Qz suuE˖-NC.s !R.* T(B!& EB jz11䟨P$BLPNNmG EB!egg݆ E2* !$Gn=QH! Q$|ΝCxx !+..!kX^o3j5\]]agg777x{{yh۶-NUqbccsww 1\ 3,!kf0i$;GZ\\vnnn3!`&L#GRH!ׯchժĄB$1o5oW^ #t鉁b,###EEEyK -k~GNN#)) IIIBTT3NNN]6UwwwZjprrSYWt?Z{{{ؔ}KWLyTKVec (((G#\x.\ٻ/3\\\P~}4k C˖-NM1nݺ%v&7aioK*UL 1[+6//QQQq.]/ƍ\zLEڵѴiS4mAݺui֞xU/})$,_˖-3Jԩ:u UVF3qyy/U^o{闳R!<}T5T(H~𑟟c۶m8|0Z)%x t ;vD6mDs3IDAT_֭|qssCΝѪU+n͛7 wQKP+V`ԩ3")C"55}V\icѻwo 8ov{Qv/ĬY$?FヒÇ[n4\pAp[Q$EKHH)Soɝ * :uTxS///&yxx 88qG 1T(;v`;;;?'O"5k"66kLZ)S`…/@Lc ժUCzzmkkk 2#JGLaаaCqqqrSi4if̘!DV n݊]rKSxx"H$6OPP?* ݻ%KPHxnիD3rIm3y*MH@@viKMܹsغu+Nl.jժ'OחK< T(Phb:vhٓYpukNt1*NZj8z(j֬%Q>}Zp{*ɫPh>3TVM4ʸѣoi&3!8|8t8dDf"5 15T( F>H4m۶i!ÁDYnmug;&= S D=z)wʼn'%w*ݻw#33STSbȐ!2"J~m[l JR1zuԨQC'صkj&5~Q۶moS6Diq%i8y*MXVd9 ?kBAhh(Ν;'+v+++Y%9x t:T(סMTTXj&L`T_}˗/e̜jo&l)Ef?#Tq!1cp̈(Mvv, 6+nj)Bфr_I&|(W_}Ƙ9gDG~~olBф<bsb °g[L}vQ۶m)bʨP4aIIIO?ŗ_~)y1%/<y7n猈$%%۫jt֍cFTQh${aŊSsMl۶MP[J~ֽ#عs'oٲ%<==9fDL}ژwJcǎXnT*d ͞=[poi\lذATI<cleQɁ JJJ Ĺs=6!رcٳxj֬9+4.]B֭EŸ}69eDL(3gHR$zyyT$'N'jĉH$իWj_n]*IQh rFCPV- 1u7oupp9s8gD(33;vw 1T(?kS6\Phb߿ .pΎKM"Qh"x&N6 { !?Gzzƍ Q˗#""BT ZM QTȝѣGGqq͛7aoo13B˝;wЬY3Qf͚^(_tt46l(zÇc۶m"zMeDbRT_H$DiӦz/9eClڴiD ,XOBlQpXv&L@ǎ9eDyq q !dƞ=o5 1gtY>QlתU w܁13Z'((nQL4nѢ!<ו+Wо}{nE"|xw#UP4Rk֬.%!… \~|֭[#))[#Ft}PhΟ?)SpG;>D <ZVt:Ij:u*ƍeR͛7/B%1FÇ0` īQ"\aK,Oqq C1.]=׸(diiibl觰ŋ r>*MCQQ,Y E;;F,GH{&Ry%%%9r$=jsfdd;'Ga۶mX`"##ǯZ*O4hЀ{lB*zӧOѽ{wܺu{lZ),,İað{n='///k֬APP}IDggg9r7ʢEeffgϞ~$$K)q!YFrn[naÆ ظq#%;#:-ZHvB* Eo߾|dHII,6! 11Dhhl9\zC ܹ`ǎqsttѶm[Ec;sW[NM6qyȚGq=Ys Vӧ~ ___Ph`6l'|mh۶-N> +++c>;hARKOOGhh(o?/_ CVpɚ!/B@ ˗r`ӦMprrMbXx1N\ŋ˝tx!n޼۷oΝ;y&;2};hobP4H1W\5Zj!$$CEժUeͅCÆ wѣGrZs… VejCDDD 22u޽+-R?rCHPNO?> 999rSovڡiӦ;-Baؽ{7֭[u#Chݺ5ϟnݺBxzRRbcc(DFF"22QQQUܞժUÖ-[гgOS!䕨Pȝ;w0qD?^T^hѢ4h>>>4^=ܸqׯ_+wj9::sڵ+5jf͚rōVEjj*RSS$$$ 66Gtt4Ljömb&\E"gIII?>~Wȝ(VVV[.ԩ5kf͚]6j֬5jF4 +((@ll,]VD!>>111HLLDQQܩׯoooxxxzpssܠhh`ooHGvv6"dee!++ e}iggg)))HNN6ڱRQ՘1c/^L bPIbb"VXUVՇ5Ujժnnnpss+;/=hBJJ Ґx(T-cc+++T GZ gg~677UPRRRV 4h+|MS!D/T(t]7nV;Eprr*;4 wqqy h4XYYQ1{iii)))HMMߛS 1oVVVƼy`cc#w: pm8p7oFxx(Nm(^lll`oo;;;prr%VB󅦵uֶlOi˽5 YYYet,ό1ddd //yyyBNN˞| ++ Eff&󑗗 ZfcҼys믴Q4/pƍ%􄗗T{.peHLL4@椲Z-Z->}*w*VQQb@x{{cѢE=z4 !gbII ֮]+Wݻ&d8;;1t#-- yyyJƘ [B999aΜ9>}z] B Ŵ4|DDD +B!ƍ.w:peR"c Æ TH!_:u*<<~H[dR n^z;B! AaРAhݺ5T*)"Qî]ЫW/Z[BHj4k zѼysS"hTb`ʔ)8vܩB1B֭uΝ;jժrDQ2B͛7g9saaaHNN;%B!-ZM6hӦ Znի˝!`҅rss$$''#>>)))HMM-ۍ%==O>-B!`mmu"00 /,,,NE2BQ_1ddd 77ZGAA>}#33}K@aaaٿ械 NN̲sgB1w兺u^zEZ $3›J+\]]NEEE)V}a꼼 Slidge Trần H. Trung All right reserved Slidge slidge/dev/confs/000077500000000000000000000000001522535037600142235ustar00rootroot00000000000000slidge/dev/confs/movim.env000066400000000000000000000004061522535037600160640ustar00rootroot00000000000000# Database configuration DB_DRIVER=pgsql DB_HOST=postgresql DB_PORT=5432 DB_DATABASE=movim DB_USERNAME=movim DB_PASSWORD=BAD # Daemon configuration DAEMON_URL=http://localhost:8888 DAEMON_PORT=8080 DAEMON_INTERFACE=0.0.0.0 DAEMON_DEBUG=true DAEMON_VERBOSE=true slidge/dev/confs/nginx.conf000066400000000000000000000031731522535037600162210ustar00rootroot00000000000000upstream movim-http { server localhost:9000; } upstream movim-ws { server localhost:8080; } fastcgi_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginx_cache:100m inactive=60m; fastcgi_cache_key "$scheme$request_method$host$request_uri"; server { listen 80; server_name localhost; index index.php index.html; root /var/www/html/public; location / { try_files $uri /index.php?$args; } location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass movim-http; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } location /ws/ { proxy_pass http://movim-ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_redirect off; } location /picture { include fastcgi_params; add_header X-Cache $upstream_cache_status; fastcgi_ignore_headers "Cache-Control" "Expires" "Set-Cookie"; fastcgi_cache nginx_cache; fastcgi_cache_key $request_method$host$request_uri; fastcgi_cache_valid any 1h; } } server { listen 4444; server_name localhost; root /slidge-web; autoindex on; location / { try_files $uri =404; } } slidge/dev/confs/slidge-dev.ini000066400000000000000000000002341522535037600167460ustar00rootroot00000000000000legacy-module=superduper jid=slidge.localhost secret=secret server=localhost upload-service=upload.localhost admins=test@localhost debug=true dev-mode=true slidge/dev/confs/slidge-example.ini000066400000000000000000000030441522535037600176250ustar00rootroot00000000000000# More info at https://slidge.im/docs/slidge/main/admin/config # The slidge 'plugin' to use, ie, the name of the legacy service # legacy-module=slidge.plugins.discord # the JID of the gateway component, *NOT* your own JID # jid=discord.example.com # This is a regex to limit which JIDs can use the gateway # user-jid-validator=.*@example.com # the XMPP server hostname. Usually you connect via localhost, # (The jabber component protocol does not mention encryption) server=localhost # the secret passphrase used by slidge to connect to the XMPP server secret=secret # the slidge admins can list users, and remove them admins=test@localhost # turn on debug logs. they are real chatty. debug=true # For legacy plugins in which attachments are publicly downloadable URLs, let XMPP # clients directly download them from this URL. Note that this will probably leak # your client IP to the legacy network. use-attachment-original-urls=true # upload-service=upload.example.com # either use the no-upload-* options to serve files by HTTP from a static dir # (requires an HTTP server) # or set upload-service to use HTTP upload like XMPP clients do (this also requires # an HTTP server, but it's most likely already set up by your xmpp server). # no-upload-* is lighter on the resources but requires a little more energy setting up no-upload-path=/slidge-web no-upload-url-prefix=http://localhost:4444 no-upload-file-read-others=true # this fixes the attachment file name suffixes using libmagic if available fix-filename-suffix-mime-type=true ;log-file=/tmp/slidge.log slidge/dev/hot-reload.sh000077500000000000000000000002611522535037600155070ustar00rootroot00000000000000#!/bin/sh watchmedo auto-restart \ --pattern *.py \ --directory /io/superduper \ --directory /io/slidge \ --recursive \ python -- -m slidge -c /etc/slidge/slidge.ini slidge/dev/prettify_tests.py000066400000000000000000000042501522535037600165560ustar00rootroot00000000000000""" Prettify XML in slixmpp tests, using utidylib """ import re from argparse import ArgumentParser from pathlib import Path from textwrap import indent import tidy def get_parser() -> ArgumentParser: parser = ArgumentParser() parser.add_argument("INPUT", nargs="*", help="Python file or dir to process") return parser def main() -> None: args = get_parser().parse_args() for path in args.INPUT: path = Path(path) if path.is_file(): process(path) else: for file in path.glob("**/*.py"): process(file) def process(path: Path) -> None: content = path.read_text() pretty = re.sub(PATTERN, sub, content) new = [] # remove useless language injection tags and place them at the right place for line in pretty.split("\n"): stripped = line.strip() if stripped in ("self.recv(", "self.send("): new.append(line.replace("# language=XML", "").rstrip() + " # language=XML") else: new.append(line) new = "\n".join(new) if new != content: print(f"Prettyfying {path}") path.write_text(new) def sub(match: re.Match) -> str: whole = match.group(0) lines = whole.lstrip(" \n").split("\n") first = lines[0][0] if first == "#" or first != '"': return whole line = lines[1 if len(lines) > 1 else 0].lstrip(" \n") if not line: return whole first_non_quote = line[0] if first_non_quote != "<": return whole return '"""\n' + indent(prettify(match.group(1)) + '"""', " " * 12) def prettify(xml: str) -> str: r = str( tidy.parseString( xml, input_xml=True, indent_attributes=True, indent="auto", indent_spaces=2, # we don't want to wrap anything because whitespace # in tag text is meaningful wrap=10_000, ) ) lines = r.split("\n") return "\n".join(line.rstrip() for line in lines) def strip_first_line(s: str) -> str: return "\n".join(s.split("\n")[1:]) PATTERN = re.compile(r'"""(.*?)"""', re.MULTILINE | re.DOTALL) if __name__ == "__main__": main() slidge/doap.xml000066400000000000000000000602161522535037600140070ustar00rootroot00000000000000 Slidge 2022-11-16 XMPP gateway framework in python en Python Linux Nicoco complete 2.11.0 0.1.0 complete 2.5rc3 0.1.0 partial 1.34.5 No creation, admin or invites (yet) complete 1.3.0 0.1.0 complete 1.19.0 0.1.0 Basic PEP support for contact "puppet" JIDs, to provide nicknames, avatars and vcards complete 1.5 0.1.0 To display http uploads inline partial For stickers from XMPP to the legacy network complete 2.4 0.1.0 To comply with XEP-0100, but it's not sufficient for most gateways (2FA for instance) complete 1.1 0.1.1 complete 1.1.4 0.1.0 When available, legacy contacts use XEP-0084 for their avatars. Slidge also supports synchronizing the slidge user's legacy avatar with their XMPP avatar. complete 2.1 0.1.0 planned partial 1.0 0.1.0 Friend request flow not yet implemented, and slidge always acts as a bouncer complete 1.1.1 0.1.0 To map legacy contacts to JID local parts when necessary planned Will implement if it makes sense for one slidge's plugins planned Will implement if it makes sense for one slidge's plugins complete 1.6.0 0.1.0 So that legacy contacts announce their XMPP caps equivalent when appropriate planned Will implement if it makes sense for one slidge's plugins planned Might be useful to improve the registration process planned For MUC#admin forms 1.0.1 0.1.0 planned User list and delete are available via adhoc commands, but will be improved to follow this XEP partial Only for room avatars partial 1.2.2 0.1.0 Basic support so that legacy contacts have avatars, vcards and nicknames complete 1.1 0.1.0 complete 1.4.0 0.1.0 For services that have no equivalent event, receipts are sent when the legacy service accepts the message. planned Will implement if useful for a plugin planned Will implement if useful for a plugin complete 2.0.1 0.1.0 planned Will implement if useful for a plugin complete 2.0 0.1.0 For group MAM partial 0.1.0 To embed QR codes in data forms during registration via adhoc command planned Will implement if useful for a plugin. MSN nudges (or wizzes) anyone? partial For stickers from XMPP to the legacy network partial 0.1.0 Slidge can send MUC invitations. In the future, it might support incoming-from-xmpp invitations. Compatible with carbons if the server supports it. Even for messages sent from official clients if the component is a privileged entity. complete complete 1.0 0.1.0 Required for XEP-0356. complete 1.2.1 0.1.0 complete 1.0.1 0.1.0 For MUCs partial 0.2.0 0.1.0 complete 1.0.2 0.1.0 complete 0.4 0.1.0 complete 0.3.0 0.1.0 complete 0.4 0.1.0 Used to sync legacy/XMPP rosters and to reflect messages sent from official legacy clients by users. complete 0.6.1 0.1.0 In MUCs. complete 1.1.0 0.1.0 Slidge recognize messages sent from XMPP with HTTP Upload and send them as attachments on the legacy network. For legacy to XMPP attachments, HTTP upload is used by default, but serving attachments from an arbitrary dir is also possible. planned Will implement if useful for a plugin. complete partial Legacy service to XMPP attachments have SIMS metadata when possible. From XMPP to legacy service: basic support (only the source HTTP URI is used, no caption or metadata). 0.3.0alpha0 partial 0.1.0 1.1.3 Plugins can automatically add MUC bookmarks for the user if the component has proper IQ privileges (XEP-0356) complete 0.1.0 1.1.0 complete complete 0.4.1 0.1.0 0.3.0 complete complete 0.1.1 1.6.0 For message replies. planned complete 0.2.0 0.1.0 partial Legacy service to XMPP attachments have SFS metadata when possible. Incoming SFS is supported if it has a url-data source in the same message, notably for stickers. partial Nothing pack-related is supported, but slidge supports signaling that an attachment is meant to be a sticker. complete 0.2.0 partial 0.2.0 The implementation does not do "diffs", but can detect absence of change in affiliation list. complete 0.1.0 Relies on being a privileged entity (XEP-0356), to edit bookmark entries. complete 0.1.0 0.1.0 complete 0.1.0 0.1.0 Slidge supports listening to MDS events and triggering "legacy MDS" events, if added to the PEP MDS node, which is done automatically if XEP-0356 IQ privileges are set. Slidge also support updating MDS node in reaction to "legacy MDS" events. partial 0.1.0 Per client-type setting is not supported. partial 0.2.0 0.4.0 Users can list spaces and their rooms, but cannot administrate them. complete 0.1.0 slidge/docker-compose.yml000066400000000000000000000022361522535037600157750ustar00rootroot00000000000000services: superduper: build: context: . target: dev network_mode: service:prosody volumes: - ./slidge:/io/slidge - ./superduper:/io/superduper - ./dev/assets:/io/superduper/assets - ./persistent:/var/lib/slidge/slidge.localhost - ./dev/confs/slidge-dev.ini:/etc/slidge/slidge.ini depends_on: - prosody prosody: image: codeberg.org/slidge/prosody-slidge-dev:latest ports: - "127.0.0.1:5222:5222" # XMPP client - "127.0.0.1:5347:5347" # XMPP component - "127.0.0.1:5280:5280" # prosody's http - "127.0.0.1:5281:5281" # prosody's https - "127.0.0.1:8443:8443" # movim movim-db: image: "docker.io/postgres:18.3-trixie" environment: POSTGRES_USER: movim POSTGRES_PASSWORD: movim POSTGRES_DB: movim healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 2s movim: image: "ghcr.io/movim/movim:latest" network_mode: service:prosody depends_on: movim-db: condition: service_healthy environment: DB_HOST: movim-db DAEMON_URL: https://127.0.0.1:8443/ slidge/docs/000077500000000000000000000000001522535037600132655ustar00rootroot00000000000000slidge/docs/Makefile000066400000000000000000000012051522535037600147230ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= uv run sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) slidge/docs/source/000077500000000000000000000000001522535037600145655ustar00rootroot00000000000000slidge/docs/source/codeberg.svg000066400000000000000000000016261522535037600170650ustar00rootroot00000000000000 slidge/docs/source/conf.py000066400000000000000000000067201522535037600160710ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html from enum import Enum, IntEnum from pathlib import Path from slixmpp import ComponentXMPP # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('..')) # sys.path.insert(0, os.path.abspath('../..')) # -- Project information ----------------------------------------------------- project = "Slidge" copyright = "2025, the slidge contributors" author = "Nicolas Cedilnik" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.extlinks", # "sphinx.ext.viewcode", # crashes build unfortunately "autoapi.extension", "slidge_sphinx_extensions.config_obj", ] autoapi_type = "python" autoapi_dirs = ["../../slidge"] autoapi_keep_files = True autoapi_root = "api/auto" autoapi_ignore = ["*xep_*", "*alembic*"] autoapi_options = [ "inherited-members", "imported-members", # these on-by-default parameters are disabled # "undoc-members", # "private-members", "show-inheritance", # "special-members", ] def skip_stuff(app, what, name, obj, skip: bool, options) -> bool: # type:ignore # noqa:ANN001 # Hide some stuff from the docs if ( name.endswith(".Register") or name.endswith("user_store") or any(name.endswith("Gateway." + x) for x in dir(ComponentXMPP)) or any( name.endswith("MucType." + x) or name.endswith("CommandAccess." + x) or name.endswith("RegistrationType." + x) for x in dir(int) + dir(Enum) + dir(IntEnum) + ["name", "value"] ) ): skip = True return skip def setup(sphinx) -> None: # type:ignore # noqa:ANN001 sphinx.connect("autoapi-skip-member", skip_stuff) # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. # exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "slixmpp": ("https://slixmpp.readthedocs.io/en/latest/", None), } extlinks = {"xep": ("https://xmpp.org/extensions/xep-%s.html", "XEP-%s")} html_theme = "furo" html_theme_options = { "source_edit_link": "https://codeberg.org/slidge/slidge/_edit/main/docs/source/{filename}", "source_view_link": "https://codeberg.org/slidge/slidge/src/branch/main/docs/source/{filename}", "footer_icons": [ { "name": "Codeberg", "url": "https://codeberg.org/slidge/slidge", "html": Path("codeberg.svg").read_text(), }, ], } slidge/docs/source/contributing.rst000066400000000000000000000036511522535037600200330ustar00rootroot00000000000000Contributing ============ Development setup ----------------- With docker compose ******************* The easiest way to develop using slidge is by using docker-compose. Clone the repo, run `docker-compose up` and you should have: - an XMPP server (prosody) exposed on port 5222 with a registered user (password: password) - an XMPP component, the "super duper" gateway, a fake component that can be used to try stuff directly in an XMPP client, with code hot-reload. You can login with the JID ``test@localhost`` and ``password`` as the password. With uv ******* Alternatively, use `uv `_ to set up a virtual environment with ``uv sync``. Launch our preconfigured prosody instance for development with ``podman run --network host codeberg.org/slidge/prosody-slidge-dev``, then launch slidge with ``python -m superduper --jid slidge.localhost --secret secret --home-dir ./persistent --debug``. Guidelines ---------- Tests should be written with the `pytest `_ framework. For complex tests involving mocking data through the XMPP stream, the :class:`slidge.util.test.SlidgeTest` class can come in handy. The code should pass `mypy `_ and `ruff `_ with the settings defined in ``pyproject.toml``. About XMPP clients ------------------ `Gajim `_ is also a good choice to test stuff during development, since it implement a lot of XEPs, especially when it comes to components. You can launch it with ``--user-profile slidge`` to use a clean profile and not mess up your usual gajim config, db, cache, etc. Unlike gajim, some clients will not accept self signed certificates, a possible workaround using debian and docker is .. code-block:: docker cp slidge_prosody_1:/etc/prosody/certs/localhost.crt \ /tmp/localhost.crt sudo /tmp/localhost.crt /usr/local/share/ca-certificates sudo update-ca-certificates slidge/docs/source/design.rst000066400000000000000000000043721522535037600165760ustar00rootroot00000000000000General Design ============== The main slidge entrypoint will automatically detect which classes have been subclassed and use them automagically. Just subclass away, and launch your legacy module with ``slidge --legacy-module=your.importable.legacy_module``. At the very minimum, you will need to subclass :class:`~slidge.BaseGateway` and :class:`~slidge.BaseSession` for a legacy module to be functional. JID local parts to legacy IDs ----------------------------- You probably also want to subclass :class:`~slidge.contact.LegacyRoster` and :class:`~slidge.group.LegacyBookmarks` to define how :term:`JID local parts` map to legacy user or contact IDs. Defining which local parts map to proper valid user legacy IDs is crucial to discriminate between JIDs that map to a :class:`~slidge.contact.LegacyContact` and those that map to a :class:`~slidge.group.LegacyMUC`. You should override :meth:`~slidge.contact.LegacyRoster.jid_username_to_legacy_id`, :meth:`~slidge.contact.LegacyRoster.legacy_id_to_jid_username`, :meth:`~slidge.group.LegacyBookmarks.jid_username_to_legacy_id`, and :meth:`~slidge.group.LegacyBookmarks.legacy_id_to_jid_username` in your custom :class:`~slidge.contact.LegacyRoster` and :class:`~slidge.group.LegacyBookmarks` classes, and raise an appropriate :class:`~slixmpp.exceptions.XMPPError` when called with an invalid argument. Fetching info from the legacy service ------------------------------------- By subclassing :class:`~slidge.contact.LegacyContact` and :class:`~slidge.group.LegacyMUC`, you will be able to define how :term:`XMPP Entities` update information about themselves, such as their user-facing name and the :term:`Avatar` that represents them. This is done by overriding :meth:`slidge.contact.LegacyContact.update_info` and :meth:`slidge.group.LegacyMUC.update_info`, in which you should raise an :class:`~slixmpp.exceptions.XMPPError` in case their :attr:`~slidge.contact.LegacyContact.legacy_id` attribute is not valid. Pre-filling contacts and groups ------------------------------- The coroutines :meth:`slidge.contact.LegacyRoster.fill()` and :meth:`slidge.group.LegacyBookmarks.fill()` will be awaited just after :meth:`~slidge.BaseSession.login()` and should be used to pre-fill known "friends" and groups. slidge/docs/source/glossary.rst000066400000000000000000000052461522535037600171710ustar00rootroot00000000000000Glossary ======== .. glossary:: :sorted: User Someone using slidge, ie, someone who has an XMPP account and registered to a slidge-based XMPP component. Legacy Legacy is used to designate chat networks that are not XMPP. This follows the wording of :xep:`0100`, and nicoco thought it was funny, considering XMPP predates most chat systems still in use today. It can be confusing for a codebase though, so maybe we'll change that at some point. Legacy Network The messaging network slidge (and the :term:`User`) communicates with. Legacy Module An XMPP gateway based on slidge. Legacy Contact Someone using the legacy network to communicate with the :term:`User`. Official Client The reference client(s) for a legacy network. Examples: telegram-android and telegram-desktop for the telegram network. Carbons In the XMPP world, carbons (:xep:`0280`) are messages sent by the XMPP server to keep outgoing chat history in sync between different clients connected to the same XMPP account (eg, a desktop and mobile app). In slidge however, this refers to actions of the :term:`User` done from an :term:`Official client`. XMPP Entity Someone or something that has a JID, such as an XMPP user account (eg, ``someone@example.org``), an XMPP component (eg, ``slidge.example.org``), an XMPP Multi-User Chat (MUC in short, eg ``cool-group@groups.example.org``), an XMPP server (eg, ``example.org``), … basically anything that has a JID. JID Local Part The "username" part of a JID, eg ``username`` in ``username@example.org``. Avatar A picture representing an :term:`XMPP Entity`, such as a profile picture for a :class:`slidge.LegacyContact` Ad-hoc Command A way to interact with the gateway component (or any xmpp entities) via a series of forms, to trigger actions or request information. See :xep:`0050` for more details. Chatbot Command A way to interact with the gateway component via chat messages, a bit like a shell. Command Either an :term:`adhoc ` or a :term:`Chatbot Command`. Slidge provides the same commands via both interfaces, so they can be used on any client. Roster This is how the "contact list" is called in XMPP. Recipient An XMPP entity that the :term:`User` can send stanzas to. Either a :class:`LegacyMUC ` or a :class:`LegacyContact `. slidge/docs/source/howto.rst000066400000000000000000000000351522535037600164550ustar00rootroot00000000000000How to…? ======== Soon™ slidge/docs/source/index.rst000066400000000000000000000013571522535037600164340ustar00rootroot00000000000000Welcome to the slidge docs! =========================== .. sidebar:: External links - `Web site `_ - `Support room `_ - `Dev room `_ - `Issues `_ Slidge is a python library to easily make `Gateways `_ from `XMPP `_ to other chat networks. Gateways to `Matrix, Telegram, Discord, WhatsApp (and other silos) `_ are been built on top of it. .. toctree:: :maxdepth: 2 tutorial design howto glossary contributing - :ref:`Documentation index ` slidge/docs/source/tutorial.rst000066400000000000000000000122101522535037600171560ustar00rootroot00000000000000Tutorial ======== Wanna write a new :term:`legacy ` :term:`module ` for slidge to chat with prisoners of walled gardens through your favorite XMPP client? You've come to the right place. Minimal example --------------- Let's say we want to create a gateway to the famous *super duper chat network*. Put this in a file called ``superduper.py``: .. code-block:: python import super_duper.api # great python lib! from super_duper.client import SuperDuperClient from slidge import BaseGateway, BaseSession, GatewayUser from slidge.contact import LegacyContact from slidge.util.types import XMPPMessage class Gateway(BaseGateway): COMPONENT_NAME = "Gateway to the super duper chat network" class Session(BaseSession): async def login(self): self.super_client = SuperDuperClient( login=self.user.registration_form["username"], password=self.user.registration_form["password"], ) self.super_client.add_event_handler( callback=self.on_super_message, event=super_duper.api.IncomingMessageEvent ) await self.legacy.login() async def on_super_message(self, msg: super_duper.api.Message): contact = await self.contacts.by_legacy_id(msg.sender_id) contact.send_text(msg.text, msg.msg_id) class Contact(LegacyContact): async def on_message(self, message: XMPPMessage) -> str: sent = self.session.super_client.send_message( text=message.body, destination=self.legacy_id) return sent.msg_id This can now be launched using ``slidge --legacy-network=superduper --server=...`` The gateway component ********************* Let's dissect this a bit: .. code-block:: python class Gateway(BaseGateway): COMPONENT_NAME = "Gateway to the super duper chat network" By subclassing :class:`slidge.BaseGateway` we can customize our gateway component in various ways. Here we just changed its name (something we **have** to do), but we could also change the registration form fields by overriding :py:attr:`slidge.BaseGateway.REGISTRATION_FIELDS`, among other things. The user's session ****************** Setup ~~~~~ .. code-block:: python class Session(BaseSession): async def login(): self.super_client = SuperDuperClient( login=self.user.registration_form["username"], password=self.user.registration_form["password"], ) self.super_client.add_event_handler( callback=self.on_super_message, event=super_duper.api.IncomingMessageEvent ) The session represents the gateway user's session on the legacy network. The :py:attr:`slidge.BaseSession.user` attribute is a :class:`slidge.GatewayUser` instance and can be used to access the fields that the user filled when subscribing to the gateway, via the :py:attr:`slidge.GatewayUser.registration_form` dict. Here, we added a ``super_client`` attribute to the session instance, because our fake superduper lib is coded this way. YMMV depending on the library you use. Good python libs provide an event handler mechanism similar to what you see here. Login ~~~~~ .. code-block:: python async def login(self): await self.super_client.login() When the gateway user is logged, this method is called on its :py:attr:`slidge.Session.user` instance. With the superduper library, starting to receive incoming messages is very convenient, as you can see. From "super duper" to XMPP ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python async def on_super_message(self, msg: super_duper.api.Message): contact = await self.contacts.by_legacy_id(msg.sender) contact.send_text(msg.body, legacy_msg_id=msg.id) We are really lucky, superduper user IDs can directly be mapped to the user part of a JID. We can just use our session's virtual legacy roster to retrieve a :class:`slidge.contact.LegacyContact` instance. Just by calling :meth:`slidge.contact.LegacyContact.send_text`, we effectively transported the message's text to the gateway user. Ain't that great? From XMPP to "super duper" ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python class Contact(LegacyContact): async def on_message(self, message: XMPPMessage) -> str: sent = self.session.super_client.send_message( text=message.body, destination=self.legacy_id) # return an identifier for this message, for further references # (reactions, replies, etc.). return sent.msg_id When the :term:`user ` sends a message to ``something@superduper.example.org``, it triggers methods in concrete implementations of :term:`Recipients`, ie, subclasses of :class:`LegacyContact ` and :class:`LegacyMUC `. Going further ------------- Until we actually finish writing the docs, you can refer to :py:mod:`slidge` for the API reference and existing `legacy modules `_ for concrete example gateways to existing chat network. slidge/pyproject.toml000066400000000000000000000131421522535037600152520ustar00rootroot00000000000000[project] name = "slidge" description = "XMPP bridging framework" requires-python = ">= 3.11" dependencies = [ "aiohttp[speedups]~=3.11", "alembic~=1.14", "configargparse~=1.7", "defusedxml~=0.7.1", "pillow~=12.0", "python-magic~=0.4.27", "qrcode~=8.0", "slixmpp~=1.17", "sqlalchemy~=2.0", "thumbhash~=0.1.2", ] authors = [ {name = "Nicolas Cedilnik", email = "nicoco@nicoco.fr"}, ] license = "AGPL-3.0-or-later" classifiers = [ "Topic :: Internet :: XMPP", "Topic :: Software Development :: Libraries :: Python Modules", ] keywords = ["xmpp", "gateway", "bridge", "instant messaging"] dynamic = ["version"] readme = "README.md" [build-system] requires = ["setuptools>=64", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["slidge*"] [tool.setuptools_scm] [project.scripts] slidge = 'slidge.main:main' [project.urls] Homepage = "https://codeberg.org/slidge/" Issues = "https://codeberg.org/slidge/slidge/issues" Repository = "https://codeberg.org/slidge/slidge/" "Chat room" = "https://conference.nicoco.fr:5281/muc_log/slidge/" Documentation = "https://slidge.im/docs/slidge/main" changelog = "https://codeberg.org/slidge/slidge/releases" [project.optional-dependencies] lottie = [ "rlottie-python~=1.3.8", ] postgres = [ "psycopg2-binary>=2.9.11,<3", ] [dependency-groups] dev = [ "coverage>=7.6.10", "emoji>=2.14.0", "furo>=2024.8.6", "mypy>=1.14.1", "pre-commit>=4.0.1", "pygments>=2.18.0", "pytest>=8.3.4", "pytest-asyncio>=0.25.0", "ruff>=0.8.4", "sphinx>=8.1.3", "sphinx-autoapi>=3.4.0", "utidylib>=0.10", "slidge-sphinx-extensions>=0.2.1,<0.3", "sphinx-autobuild>=2025.8.25", ] [tool.mypy] check_untyped_defs = true disallow_untyped_calls = false # until slixmpp passes --strict, very hard to follow strict = true files = ["."] exclude = ["tests", "build"] [[tool.mypy.overrides]] module = [ "thumbhash", "configargparse", "qrcode", ] ignore_missing_imports = true [[tool.mypy.overrides]] module = "slidge.slixfix.*" ignore_errors = true [tool.ruff] line-length = 88 exclude = ["xep_*"] [tool.ruff.lint] extend-select = ["I", "UP", "ANN", "RUF", "SIM"] [tool.ruff.lint.extend-per-file-ignores] "tests/*" = ["ANN", "RUF"] "superduper/*" = ["ANN", "RUF"] [tool.coverage.run] source = ["slidge"] [tool.coverage.report] exclude_lines = [ "if .*TYPE_CHECKING:", "raise NotImplementedError" ] [tool.pytest.ini_options] asyncio_mode = "strict" filterwarnings = [ "ignore:The object should be created within an async function:DeprecationWarning:aiohttp", "ignore:.*pkg_resources.*:DeprecationWarning", "ignore::UserWarning:slidge", "ignore:coroutine 'XMLStream._end_stream_wait' was never awaited.*:RuntimeWarning" ] log_level = "DEBUG" [[tool.uv.index]] name = "codeberg" url = "https://codeberg.org/api/packages/slidge/pypi/simple" publish-url = "https://codeberg.org/api/packages/slidge/pypi" [[tool.uv.index]] name = "codeberg-poezio" url = "https://codeberg.org/api/packages/poezio/pypi/simple/" [tool.git-cliff.remote.gitea] owner = "slidge" repo = "slidge" [tool.git-cliff.changelog] header = """ # Changes """ body = """ {% for group, all_commits in commits | group_by(attribute="group") %} {%- if group == "cfix" %}{% continue %}{%- endif %} ## {{ group | striptags | trim | upper_first }} {% for commit in all_commits %} - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ {% if commit.breaking %}[**breaking**] {% endif %}\ {{ commit.message | split(pat="\n") | first | upper_first | trim }} \ ([`{{ commit.id | truncate(length=7, end="") }}`](./commit/{{ commit.id -}})\ {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} {%- for cfix_commit in commits | filter(attribute="group", value="cfix") -%} {%- if cfix_commit.scope and commit.id is starting_with(cfix_commit.scope) -%} , [`{{ cfix_commit.id | truncate(length=7, end="") }}`](./commit/{{ cfix_commit.id }}){% if cfix_commit.remote.username %} by @{{ cfix_commit.remote.username }}{%- endif -%} {%- endif -%} {%- endfor -%})\ {% endfor %} {% endfor %}\n """ footer = """ """ [tool.git-cliff.git] conventional_commits = true filter_unconventional = false commit_parsers = [ { message = "^feat", group = "🚀 Features" }, { message = "^imprv", group = "✨ Improvements" }, { message = "^fix", group = "🐛 Bug Fixes" }, { message = "^compat", group = "💑 Compatibility" }, { message = "^refactor", group = "🚜 Refactor" }, { message = "^doc", group = "📚 Documentation" }, { message = "^perf", group = "⚡ Performance" }, { message = "^style", group = "🎨 Styling" }, { message = "^test", group = "🧪 Testing" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(deps.*\\)", skip = true }, { message = "^chore\\(pr\\)", skip = true }, { message = "^chore\\(pull\\)", skip = true }, { message = "^build\\(lockfile\\)", skip = true }, { message = "^chore\\(lockfile\\)", skip = true }, { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, { body = ".*security", group = "🛡️ Security" }, { message = "^revert", group = "◀️ Revert" }, { message = "^cfix", group = "cfix" }, { message = ".*", group = "💼 Other" }, ] slidge/slidge/000077500000000000000000000000001522535037600136045ustar00rootroot00000000000000slidge/slidge/__init__.py000066400000000000000000000034231522535037600157170ustar00rootroot00000000000000""" The main slidge package. Contains importable classes for a minimal function :term:`Legacy Module`. """ import warnings from importlib.metadata import PackageNotFoundError, version from . import ( slixfix, # noqa: F401 util, ) from .command import FormField, SearchResult # noqa: F401 from .contact import LegacyContact, LegacyRoster # noqa: F401 from .core import config as global_config from .core.gateway import BaseGateway from .core.session import BaseSession from .db import GatewayUser from .group import LegacyBookmarks, LegacyMUC, LegacyParticipant # noqa: F401 from .main import main as main_func from .util.types import MucType # noqa: F401 from .util.util import addLoggingLevel def entrypoint(module_name: str) -> None: """ Entrypoint to be used in ``__main__.py`` of :term:`legacy modules `. :param module_name: An importable :term:`Legacy Module`. """ main_func(module_name) def formatwarning( message: Warning | str, category: type[Warning], filename: str, lineno: int, line: str = "", ) -> str: return f"{filename}:{lineno}:{category.__name__}:{message}\n" warnings.formatwarning = formatwarning # type:ignore try: __version__ = version("slidge") except PackageNotFoundError: # package is not installed __version__ = "dev" __all__ = [ "BaseGateway", "BaseSession", "GatewayUser", "__version__", # For backwards compatibility, these names are still importable from the # top-level slidge module, but this is deprecated. # "LegacyBookmarks", # "LegacyMUC", # "LegacyContact", # "LegacyParticipant", # "LegacyRoster", # "MucType", # "FormField", # "SearchResult", "entrypoint", "global_config", "util", ] addLoggingLevel() slidge/slidge/__main__.py000066400000000000000000000000451522535037600156750ustar00rootroot00000000000000from slidge.main import main main() slidge/slidge/command/000077500000000000000000000000001522535037600152225ustar00rootroot00000000000000slidge/slidge/command/__init__.py000066400000000000000000000012611522535037600173330ustar00rootroot00000000000000""" This module implements an unified API to define :term:`adhoc ` or :term:`chatbot ` commands. Just subclass a :class:`Command`, and make sures it is imported in your legacy module's ``__init__.py``. """ from . import admin, register, user # noqa: F401 from .base import ( Command, CommandAccess, CommandResponseType, Confirmation, ContactCommand, Form, FormField, MUCCommand, SearchResult, TableResult, ) __all__ = ( "Command", "CommandAccess", "CommandResponseType", "Confirmation", "ContactCommand", "Form", "FormField", "MUCCommand", "SearchResult", "TableResult", ) slidge/slidge/command/adhoc.py000066400000000000000000000354561522535037600166670ustar00rootroot00000000000000import asyncio import inspect import logging from collections.abc import Awaitable, Callable from functools import partial from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar, cast from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0004 import Form as SlixForm from slixmpp.plugins.xep_0030.stanza.items import DiscoItems from slixmpp.plugins.xep_0050.adhoc import CommandType from slidge.util.types import AnyMUC, AnyRecipient, AnySession from ..contact import LegacyContact from ..core import config from ..util.util import strip_leading_emoji from . import Command, CommandResponseType, Confirmation, Form, TableResult from .base import ( CommandResponseRecipientType, CommandResponseSessionType, ContactCommand, FormField, MUCCommand, ) from .categories import CommandCategory if TYPE_CHECKING: from ..core.gateway import BaseGateway GatewayType = TypeVar("GatewayType", bound="BaseGateway[Any]") AdhocSessionType = dict[str, Any] T = TypeVar("T") P = ParamSpec("P") class AdhocProvider(Generic[GatewayType]): """ A slixmpp-like plugin to handle adhoc commands, with less boilerplate and untyped dict values than slixmpp. """ FORM_TIMEOUT = 120 # seconds xmpp: GatewayType def __init__(self, xmpp: GatewayType) -> None: self.xmpp = xmpp self._commands = dict[str, Command[AnySession]]() self._categories = dict[str, list[Command[AnySession]]]() xmpp.plugin["xep_0030"].set_node_handler( "get_items", jid=xmpp.boundjid, node=self.xmpp.plugin["xep_0050"].stanza.Command.namespace, handler=self.get_items, ) self.xmpp.plugin["xep_0050"].api.register(self.__get_command, "get_command") self.__timeouts: dict[str, asyncio.TimerHandle] = {} async def __get_command( self, jid: JID | str | None = None, node: str | None = None, ifrom: JID | None = None, args: None = None, ) -> CommandType | None: if node is None: return None if jid is None: jid = self.xmpp.boundjid.bare if not isinstance(jid, JID): jid = JID(jid) if jid == self.xmpp.boundjid.bare: return self.xmpp.plugin["xep_0050"].commands.get((jid.full, node)) if ifrom is None: raise XMPPError("undefined-condition") session = self.xmpp.get_session_from_jid(ifrom) if session is None: raise XMPPError("subscription-required") recipient = await session.get_contact_or_group_or_participant(jid) if recipient is None: return None if recipient.is_participant: if recipient.contact is None: return None recipient = recipient.contact command = recipient.commands.get(node) if command is None: return None name = strip_leading_emoji_if_needed(command.NAME) handler = partial(self.__wrap_initial_handler, command, recipient=recipient) return name, handler, None, None # type:ignore async def __wrap_initial_handler( self, command: Command[AnySession] | type[ContactCommand[LegacyContact]] | type[MUCCommand[AnyMUC]], iq: Iq, adhoc_session: AdhocSessionType, recipient: AnyRecipient | None = None, ) -> AdhocSessionType: ifrom = iq.get_from() if recipient is None: cmd = cast(Command[AnySession], command) session = cmd.raise_if_not_authorized(ifrom) result1: CommandResponseSessionType[Any] = await self.__wrap_handler( cmd.run, session, ifrom ) return await self.__handle_result(session, result1, adhoc_session) else: cmd2 = cast( type[ContactCommand[LegacyContact]] | type[MUCCommand[AnyMUC]], command ) result2: CommandResponseRecipientType[Any] = await self.__wrap_handler( cmd2.run, # type:ignore[arg-type] recipient, ) return await self.__handle_result( recipient.session, result2, adhoc_session, recipient ) async def __handle_category_list( self, category: CommandCategory, iq: Iq, adhoc_session: AdhocSessionType ) -> AdhocSessionType: try: session = self.xmpp.get_session_from_stanza(iq) except XMPPError: session = None commands: dict[str, Command[AnySession]] = {} for command in self._categories[category.node]: try: command.raise_if_not_authorized(iq.get_from()) except XMPPError: continue commands[command.NODE] = command if len(commands) == 0: raise XMPPError( "not-authorized", "There is no command you can run in this category" ) return await self.__handle_result( session, Form( category.name, "", [ FormField( var="command", label="Command", type="list-single", options=[ { "label": strip_leading_emoji_if_needed(command.NAME), "value": command.NODE, } for command in commands.values() ], ) ], partial(self.__handle_category_choice, commands), ), adhoc_session, ) async def __handle_category_choice( self, commands: dict[str, Command[AnySession]], form_values: dict[str, str], session: "AnySession | None", jid: JID, ) -> CommandResponseSessionType[Any]: command = commands[form_values["command"]] result: CommandResponseSessionType[Any] = await self.__wrap_handler( command.run, session, jid ) return result async def __handle_result( self, session: "AnySession | None", result: CommandResponseType, adhoc_session: AdhocSessionType, recipient: AnyRecipient | None = None, ) -> AdhocSessionType: if isinstance(result, str) or result is None: adhoc_session["has_next"] = False adhoc_session["next"] = None adhoc_session["payload"] = None adhoc_session["notes"] = [("info", result or "Success!")] return adhoc_session if isinstance(result, Form): adhoc_session["next"] = partial( self.__wrap_form_handler, session, result, recipient ) adhoc_session["has_next"] = True adhoc_session["payload"] = result.get_xml() if result.timeout_handler is not None: self.__timeouts[adhoc_session["id"]] = self.xmpp.loop.call_later( self.FORM_TIMEOUT, partial( self.__wrap_timeout, result.timeout_handler, adhoc_session["id"] ), ) return adhoc_session if isinstance(result, Confirmation): adhoc_session["next"] = partial( self.__wrap_confirmation, session, result, recipient ) adhoc_session["has_next"] = True adhoc_session["payload"] = result.get_form() return adhoc_session if isinstance(result, TableResult): adhoc_session["next"] = None adhoc_session["has_next"] = False adhoc_session["payload"] = result.get_xml() return adhoc_session raise XMPPError("internal-server-error", text="OOPS!") def __wrap_timeout(self, handler: Callable[[], None], session_id: str) -> None: try: del self.xmpp.plugin["xep_0050"].sessions[session_id] except KeyError: log.error("Timeout but session could not be found: %s", session_id) handler() @staticmethod async def __wrap_handler( f: Callable[P, Awaitable[T] | T], *a: P.args, **k: P.kwargs, ) -> T: try: if inspect.iscoroutinefunction(f): return await f(*a, **k) # type:ignore[no-any-return] elif hasattr(f, "func") and inspect.iscoroutinefunction(f.func): return await f(*a, **k) # type:ignore[misc,no-any-return] else: return f(*a, **k) # type:ignore[return-value] except XMPPError: raise except Exception as e: log.debug("Exception in %s", f, exc_info=e) raise XMPPError("internal-server-error", text=str(e)) async def __wrap_form_handler( self, session: "AnySession | None", result: Form, recipient: AnyRecipient | None, form: SlixForm, adhoc_session: AdhocSessionType, ) -> AdhocSessionType: timer = self.__timeouts.pop(adhoc_session["id"], None) if timer is not None: print("canceled", adhoc_session["id"]) timer.cancel() form_values = result.get_values(form) if recipient is None: new_result = await self.__wrap_handler( result.handler, form_values, session, adhoc_session["from"], *result.handler_args, **result.handler_kwargs, ) else: new_result = await self.__wrap_handler( result.handler, recipient, form_values, *result.handler_args, **result.handler_kwargs, ) return await self.__handle_result(session, new_result, adhoc_session, recipient) async def __wrap_confirmation( self, session: "AnySession | None", confirmation: Confirmation, recipient: AnyRecipient | None, form: SlixForm, adhoc_session: AdhocSessionType, ) -> AdhocSessionType: if form.get_values().get("confirm"): if recipient is None: result = await self.__wrap_handler( confirmation.handler, session, adhoc_session["from"], *confirmation.handler_args, **confirmation.handler_kwargs, ) if confirmation.success: result = confirmation.success else: result = await self.__wrap_handler( confirmation.handler, recipient, *confirmation.handler_args, **confirmation.handler_kwargs, ) else: result = "You canceled the operation" return await self.__handle_result(session, result, adhoc_session, recipient) def register(self, command: Command[AnySession], jid: JID | None = None) -> None: """ Register a command as a adhoc command. this does not need to be called manually, ``BaseGateway`` takes care of that. :param command: :param jid: """ if jid is None: jid = self.xmpp.boundjid elif not isinstance(jid, JID): jid = JID(jid) if (category := command.CATEGORY) is None: if command.NODE in self._commands: raise RuntimeError( "There is already a command for the node '%s'", command.NODE ) self._commands[command.NODE] = command self.xmpp.plugin["xep_0050"].add_command( jid=jid, node=command.NODE, name=strip_leading_emoji_if_needed(command.NAME), handler=partial(self.__wrap_initial_handler, command), ) else: if isinstance(category, str): category = CommandCategory(category, category) node = category.node name = category.name if node not in self._categories: self._categories[node] = list[Command[AnySession]]() self.xmpp.plugin["xep_0050"].add_command( jid=jid, node=node, name=strip_leading_emoji_if_needed(name), handler=partial(self.__handle_category_list, category), ) self._categories[node].append(command) async def get_items(self, jid: JID, node: str, iq: Iq) -> DiscoItems: """ Get items for a disco query :param jid: the entity that should return its items :param node: which command node is requested :param iq: the disco query IQ :return: commands accessible to the given JID will be listed """ ifrom = iq.get_from() ifrom_str = str(ifrom) if ( not self.xmpp.jid_validator.match(ifrom_str) and ifrom_str not in config.ADMINS ): raise XMPPError( "forbidden", "You are not authorized to execute adhoc commands on this gateway. " "If this is unexpected, ask your administrator to verify that " "'user-jid-validator' is correctly set in slidge's configuration.", ) all_items = self.xmpp.plugin["xep_0030"].static.get_items(jid, node, None, None) log.debug("Static items: %r", all_items) if not all_items: return DiscoItems() session = self.xmpp.get_session_from_jid(ifrom) filtered_items = DiscoItems() filtered_items["node"] = self.xmpp.plugin["xep_0050"].stanza.Command.namespace for item in all_items: authorized = True if item["node"] in self._categories: for command in self._categories[item["node"]]: try: command.raise_if_not_authorized( ifrom, fetch_session=False, session=session ) except XMPPError: authorized = False else: authorized = True break else: try: self._commands[item["node"]].raise_if_not_authorized( ifrom, fetch_session=False, session=session ) except XMPPError: authorized = False if authorized: filtered_items.append(item) return filtered_items def strip_leading_emoji_if_needed(text: str) -> str: if config.STRIP_LEADING_EMOJI_ADHOC: return strip_leading_emoji(text) return text log = logging.getLogger(__name__) slidge/slidge/command/admin.py000066400000000000000000000153421522535037600166710ustar00rootroot00000000000000# Commands only accessible for slidge admins import functools import importlib import logging from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar from slixmpp import JID from slixmpp.exceptions import XMPPError from ..core import config from ..db.models import GatewayUser from ..util.types import AnySession from .base import ( NODE_PREFIX, Command, CommandAccess, Confirmation, FormField, FormSession, FormValues, TableResult, ) from .categories import ADMINISTRATION NODE_PREFIX = NODE_PREFIX + "admin/" if TYPE_CHECKING: from slidge.util.types import AnyGateway class AdminCommand(Command[AnySession]): ACCESS = CommandAccess.ADMIN_ONLY CATEGORY = ADMINISTRATION class ListUsers(AdminCommand): NAME = "👤 List registered users" HELP = "List the users registered to this gateway" CHAT_COMMAND = "list_users" NODE = NODE_PREFIX + CHAT_COMMAND async def run( self, _session: "AnySession | None", _ifrom: JID, *_: str ) -> TableResult: items = [] with self.xmpp.store.session() as orm: for u in orm.query(GatewayUser).all(): d = u.registration_date joined = "" if d is None else d.isoformat(timespec="seconds") items.append({"jid": u.jid.bare, "joined": joined}) return TableResult( description="List of registered users", fields=[FormField("jid", type="jid-single"), FormField("joined")], items=items, # type:ignore ) class SlidgeInfo(AdminCommand): NAME = "ℹ️ Server information" # noqa:RUF001 HELP = "List the users registered to this gateway" CHAT_COMMAND = "info" NODE = NODE_PREFIX + CHAT_COMMAND ACCESS = CommandAccess.ANY async def run(self, _session: "AnySession | None", _ifrom: JID, *_: str) -> str: start = self.xmpp.datetime_started uptime = datetime.now() - start if uptime.days: days_ago = f"{uptime.days} day{'s' if uptime.days != 1 else ''}" else: days_ago = None hours, seconds = divmod(uptime.seconds, 3600) if hours: hours_ago = f"{hours} hour" if hours != 1: hours_ago += "s" else: hours_ago = None minutes, seconds = divmod(seconds, 60) if minutes: minutes_ago = f"{minutes} minute" if minutes != 1: minutes_ago += "s" else: minutes_ago = None if any((days_ago, hours_ago, minutes_ago)): seconds_ago = None else: seconds_ago = f"{seconds} second" if seconds != 1: seconds_ago += "s" ago = ", ".join( [a for a in (days_ago, hours_ago, minutes_ago, seconds_ago) if a] ) legacy_module = importlib.import_module(config.LEGACY_MODULE) version = getattr(legacy_module, "__version__", "No version") import slidge return ( f"{self.xmpp.COMPONENT_NAME} (slidge core {slidge.__version__}," f" {config.LEGACY_MODULE} {version})\n" f"Up since {start:%Y-%m-%d %H:%M} ({ago} ago)" ) class DeleteUser(AdminCommand): NAME = "❌ Delete a user" HELP = "Unregister a user from the gateway" CHAT_COMMAND = "delete_user" NODE = NODE_PREFIX + CHAT_COMMAND async def run( self, _session: "AnySession | None", _ifrom: JID, *_: str ) -> FormSession[AnySession]: return FormSession( title="Remove a slidge user", instructions="Enter the bare JID of the user you want to delete", fields=[FormField("jid", type="jid-single", label="JID", required=True)], handler=self.delete, ) async def delete( self, form_values: FormValues, _session: "AnySession | None", _ifrom: JID, ) -> Confirmation: jid: JID = form_values.get("jid") # type:ignore with self.xmpp.store.session() as orm: user = orm.query(GatewayUser).where(GatewayUser.jid == jid).one_or_none() if user is None: raise XMPPError("item-not-found", text=f"There is no user '{jid}'") return Confirmation( prompt=f"Are you sure you want to unregister '{jid}' from slidge?", success=f"User {jid} has been deleted", handler=functools.partial(self.finish, jid=jid), ) async def finish( self, _session: "AnySession | None", _ifrom: JID, jid: JID ) -> None: with self.xmpp.store.session() as orm: user = orm.query(GatewayUser).where(GatewayUser.jid == jid).one_or_none() if user is None: raise XMPPError("bad-request", f"{jid} has no account here!") await self.xmpp.unregister_user(user, "You have been unregistered by an admin") class ChangeLoglevel(AdminCommand): NAME = "📋 Change the verbosity of the logs" HELP = "Set the logging level" CHAT_COMMAND = "loglevel" NODE = NODE_PREFIX + CHAT_COMMAND async def run( self, _session: "AnySession | None", _ifrom: JID, *_: str ) -> FormSession[AnySession]: return FormSession( title=self.NAME, instructions=self.HELP, fields=[ FormField( "level", label="Log level", required=True, type="list-single", options=[ {"label": "WARNING (quiet)", "value": str(logging.WARNING)}, {"label": "INFO (normal)", "value": str(logging.INFO)}, {"label": "DEBUG (verbose)", "value": str(logging.DEBUG)}, ], ) ], handler=self.finish, ) @staticmethod async def finish( form_values: FormValues, _session: "AnySession | None", _ifrom: JID, ) -> None: logging.getLogger().setLevel(int(form_values["level"])) # type:ignore class Exec(AdminCommand): NAME = HELP = "Exec arbitrary python code. SHOULD NEVER BE AVAILABLE IN PROD." CHAT_COMMAND = "!" NODE = "exec" ACCESS = CommandAccess.ADMIN_ONLY prev_snapshot = None context: ClassVar[dict[str, Any]] = {} def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) async def run(self, session: "AnySession | None", ifrom: JID, *args: str) -> str: from contextlib import redirect_stdout from io import StringIO f = StringIO() with redirect_stdout(f): exec(" ".join(args), self.context) out = f.getvalue() if out: return f"```\n{out}\n```" else: return "No output" slidge/slidge/command/base.py000066400000000000000000000370031522535037600165110ustar00rootroot00000000000000from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Iterable, Sequence from dataclasses import dataclass, field from enum import Enum from typing import ( TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, TypeVar, Union, ) from slixmpp import JID from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0004 import Form as SlixForm from slixmpp.plugins.xep_0004.stanza.field import FormField as SlixFormField from slixmpp.types import JidStr from slidge.contact import LegacyContact from slidge.group import LegacyMUC from ..core import config from ..util.types import ( AnyMUC, AnySession, FieldType, LegacyContactType, LegacyMUCType, SessionType, ) NODE_PREFIX = "https://slidge.im/command/core/" if TYPE_CHECKING: from ..util.types import AnyGateway from .categories import CommandCategory HandlerType = ( Callable[[AnySession, JID], "CommandResponseType"] | Callable[[AnySession, JID], Awaitable["CommandResponseType"]] ) FormValues = dict[str, str | JID | bool | list[str] | list[JID]] @dataclass class TableResult: """ Structured data as the result of a command """ fields: Sequence["FormField"] """ The 'columns names' of the table. """ items: Sequence[dict[str, str | JID]] """ The rows of the table. Each row is a dict where keys are the fields ``var`` attribute. """ description: str """ A description of the content of the table. """ jids_are_mucs: bool = False def get_xml(self) -> SlixForm: """ Get a slixmpp "form" (with header)to represent the data :return: some XML """ form = SlixForm() form["type"] = "result" form["title"] = self.description for f in self.fields: form.add_reported(f.var, label=f.label, type=f.type) for item in self.items: form.add_item({k: str(v) for k, v in item.items()}) return form @dataclass class SearchResult(TableResult): """ Results of the search command (search for contacts via Jabber Search) Return type of :meth:`BaseSession.search`. """ description: str = "Contact search results" @dataclass class Confirmation: """ A confirmation 'dialog' """ prompt: str """ The text presented to the command triggering user """ handler: Any """ An async function that should return a ResponseType """ success: str | None = None """ Text in case of success, used if handler does not return anything """ handler_args: Iterable[Any] = field(default_factory=list) """ arguments passed to the handler """ handler_kwargs: dict[str, Any] = field(default_factory=dict) """ keyword arguments passed to the handler """ def get_form(self) -> SlixForm: """ Get the slixmpp form :return: some xml """ form = SlixForm() form["type"] = "form" form["title"] = self.prompt form.append( FormField( "confirm", type="boolean", value="true", label="Confirm" ).get_xml() ) return form @dataclass class ConfirmationSession(Confirmation, Generic[SessionType]): handler: Callable[ [SessionType | None, JID], Awaitable["CommandResponseSessionType[SessionType]"], ] RecipientType = TypeVar("RecipientType", bound=LegacyContact | LegacyMUC[Any]) @dataclass class ConfirmationRecipient(Confirmation, Generic[RecipientType]): handler: Callable[ [RecipientType], Awaitable["CommandResponseRecipientType[RecipientType]"], ] @dataclass class Form: """ A form, to request user input """ title: str instructions: str fields: Sequence["FormField"] handler: Any handler_args: Iterable[Any] = field(default_factory=list) handler_kwargs: dict[str, Any] = field(default_factory=dict) timeout_handler: Callable[[], None] | None = None def get_values( self, slix_form: SlixForm ) -> dict[str, list[str] | list[JID] | str | JID | bool | None]: """ Parse form submission :param slix_form: the xml received as the submission of a form :return: A dict where keys=field.var and values are either strings or JIDs (if field.type=jid-single) """ str_values: dict[str, str] = slix_form.get_values() values = {} for f in self.fields: values[f.var] = f.validate(str_values.get(f.var)) return values def get_xml(self) -> SlixForm: """ Get the slixmpp "form" :return: some XML """ form = SlixForm() form["type"] = "form" form["title"] = self.title form["instructions"] = self.instructions for fi in self.fields: form.append(fi.get_xml()) return form class FormSession(Form, Generic[SessionType]): handler: Callable[ [FormValues, SessionType | None, JID], Awaitable["CommandResponseSessionType[SessionType]"], ] @dataclass class FormRecipient(Form, Generic[RecipientType]): handler: Callable[ [RecipientType, FormValues], Awaitable["CommandResponseRecipientType[RecipientType]"], ] class CommandAccess(int, Enum): """ Defines who can access a given Command """ ADMIN_ONLY = 0 USER = 1 USER_LOGGED = 2 USER_NON_LOGGED = 3 NON_USER = 4 ANY = 5 class Option(TypedDict): """ Options to be used for ``FormField``s of type ``list-*`` """ label: str value: str # TODO: support forms validation XEP-0122 @dataclass class FormField: """ Represents a field of the form that a user will see when registering to the gateway via their XMPP client. """ var: str = "" """ Internal name of the field, will be used to retrieve via :py:attr:`slidge.GatewayUser.registration_form` """ label: str | None = None """Description of the field that the user will see""" required: bool = False """Whether this field is mandatory or not""" private: bool = False """ For sensitive info that should not be displayed on screen while the user types. Forces field_type to "text-private" """ type: FieldType = "text-single" """Type of the field, see `XEP-0004 `_""" value: str = "" """Pre-filled value. Will be automatically pre-filled if a registered user modifies their subscription""" options: list[Option] | None = None image_url: str | None = None """An image associated to this field, eg, a QR code""" def __post_init__(self) -> None: if self.private: self.type = "text-private" def __acceptable_options(self) -> list[str]: if self.options is None: raise RuntimeError return [x["value"] for x in self.options] def validate( self, value: str | list[str] | None ) -> list[str] | list[JID] | str | JID | bool | None: """ Raise appropriate XMPPError if a given value is valid for this field :param value: The value to test :return: The same value OR a JID if ``self.type=jid-single`` """ if isinstance(value, list) and not self.type.endswith("multi"): raise XMPPError("not-acceptable", "A single value was expected") if self.type in ("list-multi", "jid-multi", "text-multi"): if not value: value = [] if isinstance(value, list): if self.type == "text-multi": return value return self.__validate_list_multi(value) else: raise XMPPError("not-acceptable", "Multiple values was expected") assert isinstance(value, (str, bool, JID)) or value is None if self.required and value is None: raise XMPPError("not-acceptable", f"Missing field: '{self.label}'") if value is None: return None if self.type == "jid-single": try: return JID(value) except ValueError: raise XMPPError("not-acceptable", f"Not a valid JID: '{value}'") elif self.type == "list-single": if value not in self.__acceptable_options(): raise XMPPError("not-acceptable", f"Not a valid option: '{value}'") elif self.type == "boolean": return value.lower() in ("1", "true") if isinstance(value, str) else value return value def __validate_list_multi(self, value: list[str]) -> list[str] | list[JID]: for v in value: if v not in self.__acceptable_options(): raise XMPPError("not-acceptable", f"Not a valid option: '{v}'") if self.type == "list-multi": return value return [JID(v) for v in value] def get_xml(self) -> SlixFormField: """ Get the field in slixmpp format :return: some XML """ f = SlixFormField() f["var"] = self.var f["label"] = self.label f["required"] = self.required f["type"] = self.type if self.options: for o in self.options: f.add_option(**o) f["value"] = self.value if self.image_url: f["media"].add_uri(self.image_url, itype="image/png") return f CommandResponseType = TableResult | Confirmation | Form | str | None CommandResponseSessionType = ( TableResult | ConfirmationSession[SessionType] | FormSession[SessionType] | str | None ) CommandResponseRecipientType = ( TableResult | ConfirmationRecipient[RecipientType] | FormRecipient[RecipientType] | str | None ) class _CommandMixin(ABC): NAME: str = NotImplemented """ Friendly name of the command, eg: "do something with stuff" """ HELP: str = NotImplemented """ Long description of what the command does """ NODE: str = NotImplemented """ Name of the node used for ad-hoc commands """ CHAT_COMMAND: str = NotImplemented """ Text to send to the gateway to trigger the command via a message """ class Command(_CommandMixin, Generic[SessionType]): """ Abstract base class to implement gateway commands (chatbot and ad-hoc) """ ACCESS: "CommandAccess" = NotImplemented """ Who can use this command """ CATEGORY: Union[str, "CommandCategory"] | None = None """ If used, the command will be under this top-level category. Use the same string for several commands to group them. This hierarchy only used for the adhoc interface, not the chat command interface. """ subclasses: ClassVar[list[type["Command[SessionType]"]]] = [] def __init__(self, xmpp: "AnyGateway") -> None: self.xmpp = xmpp def __init_subclass__( cls, **kwargs: Any, # noqa:ANN401 ) -> None: # store subclasses so subclassing is enough for the command to be # picked up by slidge cls.subclasses.append(cls) async def run( self, session: SessionType | None, ifrom: JID, *args: str, ) -> CommandResponseSessionType[SessionType]: """ Entry point of the command :param session: If triggered by a registered user, its slidge Session :param ifrom: JID of the command-triggering entity :param args: When triggered via chatbot type message, additional words after the CHAT_COMMAND string was passed :return: Either a TableResult, a Form, a Confirmation, a text, or None """ raise XMPPError("feature-not-implemented") def _get_session(self, jid: JID) -> SessionType | None: return self.xmpp.get_session_from_jid(jid) # type:ignore def __can_use_command(self, jid: JID) -> bool: j = jid.bare return bool(self.xmpp.jid_validator.match(j) or j in config.ADMINS) def raise_if_not_authorized( self, jid: JID, fetch_session: bool = True, session: SessionType | None = None, ) -> SessionType | None: """ Raise an appropriate error is jid is not authorized to use the command :param jid: jid of the entity trying to access the command :param fetch_session: :param session: :return:session of JID if it exists """ if not self.__can_use_command(jid): raise XMPPError( "bad-request", "Your JID is not allowed to use this gateway." ) if fetch_session: session = self._get_session(jid) if self.ACCESS == CommandAccess.ADMIN_ONLY and not is_admin(jid): raise XMPPError("not-authorized") elif self.ACCESS == CommandAccess.NON_USER and session is not None: raise XMPPError( "bad-request", "This is only available for non-users. Unregister first." ) elif self.ACCESS == CommandAccess.USER and session is None: raise XMPPError( "forbidden", "This is only available for users that are registered to this gateway", ) elif self.ACCESS == CommandAccess.USER_NON_LOGGED: if session is None or session.logged: raise XMPPError( "forbidden", ( "This is only available for users that are not logged to the" " legacy service" ), ) elif self.ACCESS == CommandAccess.USER_LOGGED and ( session is None or not session.logged ): raise XMPPError( "forbidden", ("This is only available when you are logged in to the legacy service"), ) return session T = TypeVar("T", bound="LegacyContact | AnyMUC") class _RecipientCommand(_CommandMixin, Generic[T]): @staticmethod @abstractmethod async def run(recipient: T, *args: str) -> CommandResponseRecipientType[T]: """ Entrypoint for a recipient-specific command. The first argument is a :class:`LegacyContact` or :class:`LegacyMUC` instance. ``*args`` are extra args passed when using the chatbot. """ raise NotImplementedError class ContactCommand(_RecipientCommand[LegacyContactType], Generic[LegacyContactType]): """ A command that will be avaible on a contact. It implicitly requires the user to be registered and logged. It is never instantiated, so all methods must be static methods. Its entrypoint is the ``run()`` static method. """ recipient_cls = LegacyContact def __init_subclass__( cls, **kwargs: Any, # noqa:ANN401 ) -> None: cls.recipient_cls.commands[cls.NODE] = cls # type:ignore[assignment] cls.recipient_cls.commands_chat[cls.CHAT_COMMAND] = cls # type:ignore[assignment] class MUCCommand(_RecipientCommand[LegacyMUCType], Generic[LegacyMUCType]): """ A command that will be avaible on a MUC. It implicitly requires the user to be registered and logged. It is never instantiated, so all methods must be static methods. Its entrypoint is the ``run()`` static method. """ recipient_cls = LegacyMUC def __init_subclass__( cls, **kwargs: Any, # noqa:ANN401 ) -> None: cls.recipient_cls.commands[cls.NODE] = cls cls.recipient_cls.commands_chat[cls.CHAT_COMMAND] = cls def is_admin(jid: JidStr) -> bool: return JID(jid).bare in config.ADMINS slidge/slidge/command/categories.py000066400000000000000000000006371522535037600177270ustar00rootroot00000000000000from typing import NamedTuple from .base import NODE_PREFIX class CommandCategory(NamedTuple): name: str node: str ADMINISTRATION = CommandCategory("🛷️ Slidge administration", NODE_PREFIX + "admin") CONTACTS = CommandCategory("👤 Contacts", NODE_PREFIX + "contacts") GROUPS = CommandCategory("👥 Groups", NODE_PREFIX + "groups") SPACES = CommandCategory("🌌 Spaces", NODE_PREFIX + "spaces") slidge/slidge/command/chat_command.py000066400000000000000000000377101522535037600202210ustar00rootroot00000000000000# Handle slidge commands by exchanging chat messages with the gateway components. # Ad-hoc methods should provide a better UX, but some clients do not support them, # so this is mostly a fallback. import asyncio import inspect import logging from collections.abc import Awaitable, Callable from typing import ( TYPE_CHECKING, Any, Generic, Literal, Never, ParamSpec, TypeVar, cast, overload, ) from urllib.parse import quote as url_quote from slixmpp import JID, CoroutineCallback, Message, StanzaPath from slixmpp.exceptions import XMPPError from slixmpp.types import JidStr, MessageTypes from slidge.command.base import ( CommandResponseRecipientType, CommandResponseSessionType, ConfirmationRecipient, ConfirmationSession, FormRecipient, FormSession, ) from slidge.contact import LegacyContact from slidge.group import LegacyMUC from slidge.util.types import AnyMUC, AnyRecipient, AnySession from . import Command, CommandResponseType, Confirmation, Form, TableResult from .categories import CommandCategory if TYPE_CHECKING: from ..core.gateway import BaseGateway GatewayType = TypeVar("GatewayType", bound="BaseGateway[Any]") T = TypeVar("T") P = ParamSpec("P") class ChatCommandProvider(Generic[GatewayType]): UNKNOWN = "Wut? I don't know that command: {}" xmpp: GatewayType def __init__(self, xmpp: GatewayType) -> None: self.xmpp = xmpp self._keywords = list[str]() self._commands: dict[str, Command[AnySession]] = {} self._input_futures = dict[str, asyncio.Future[str]]() self.xmpp.register_handler( CoroutineCallback( "chat_command_handler", StanzaPath(f"message@to={self.xmpp.boundjid.bare}"), self._handle_message, # type: ignore ) ) def register(self, command: Command[AnySession]) -> None: """ Register a command to be used via chat messages with the gateway Plugins should not call this, any class subclassing Command should be automatically added by slidge core. :param command: the new command """ t = command.CHAT_COMMAND if t in self._commands: raise RuntimeError("There is already a command triggered by '%s'", t) self._commands[t] = command @overload async def input(self, jid: JidStr, text: str | None = None) -> str: ... @overload async def input( self, jid: JidStr, text: str | None = None, *, blocking: Literal[False] = ... ) -> asyncio.Future[str]: ... @overload async def input( self, jid: JidStr, text: str | None = None, *, mtype: MessageTypes = "chat", timeout: int = 60, blocking: Literal[True] = True, **msg_kwargs: Any, # noqa:ANN401 ) -> str: ... async def input( self, jid: JidStr, text: str | None = None, *, mtype: MessageTypes = "chat", timeout: int = 60, blocking: bool = True, **msg_kwargs: Any, ) -> str | asyncio.Future[str]: """ Request arbitrary user input using a simple chat message, and await the result. You shouldn't need to call directly bust instead use :meth:`.BaseSession.input` to directly target a user. NB: When using this, the next message that the user sent to the component will not be transmitted to :meth:`.BaseGateway.on_gateway_message`, but rather intercepted. Await the coroutine to get its content. :param jid: The JID we want input from :param text: A prompt to display for the user :param mtype: Message type :param timeout: :param blocking: If set to False, timeout has no effect and an :class:`asyncio.Future` is returned instead of a str :return: The user's reply """ jid = JID(jid) if text is not None: self.xmpp.send_message( mto=jid, mbody=text, mtype=mtype, mfrom=self.xmpp.boundjid.bare, **msg_kwargs, ) f: asyncio.Future[str] = asyncio.get_event_loop().create_future() self._input_futures[jid.bare] = f if not blocking: return f try: await asyncio.wait_for(f, timeout) except TimeoutError: self.xmpp.send_message( mto=jid, mbody="You took too much time to reply", mtype=mtype, mfrom=self.xmpp.boundjid.bare, ) del self._input_futures[jid.bare] raise XMPPError("remote-server-timeout", "You took too much time to reply") return f.result() async def _handle_message(self, msg: Message) -> None: if not msg["body"]: return if not msg.get_from().node: return # ignore component and server messages f = self._input_futures.pop(msg.get_from().bare, None) if f is not None: f.set_result(msg["body"]) return c = msg["body"] first_word, *rest = c.split(" ") first_word = first_word.lower() if first_word == "help": return self._handle_help(msg, *rest) if first_word in ("contact", "room"): return await self._handle_recipient(first_word, msg, *rest) mfrom = msg.get_from() command = self._commands.get(first_word) if command is None: self._not_found(msg, first_word) return try: session = command.raise_if_not_authorized(mfrom) except XMPPError as e: reply = msg.reply() reply["body"] = e.text reply.send() raise result: CommandResponseSessionType[Any] = await self.__wrap_handler( msg, command.run, session, mfrom, *rest ) self.xmpp.delivery_receipt.ack(msg) await self._handle_result(result, msg, session) def __make_uri(self, body: str) -> str: return f"xmpp:{self.xmpp.boundjid.bare}?message;body={body}" async def _handle_result( self, result: CommandResponseSessionType[Any] | CommandResponseRecipientType[Any], msg: Message, session: "AnySession | None", recipient: AnyRecipient | None = None, ) -> CommandResponseSessionType[Any] | CommandResponseRecipientType[Any]: if isinstance(result, str) or result is None: reply = msg.reply() reply["body"] = result or "End of command." reply.send() return None if isinstance(result, Form): if recipient is None: result = cast(FormSession[AnySession], result) else: result = cast(FormRecipient[AnyRecipient], result) try: return await self.__handle_form( # type:ignore[return-value] result, msg, session, recipient=recipient ) except XMPPError as e: if ( result.timeout_handler is None or e.condition != "remote-server-timeout" ): raise e return result.timeout_handler() if isinstance(result, Confirmation): yes_or_no = await self.input(msg.get_from(), result.prompt) if not yes_or_no.lower().startswith("y"): reply = msg.reply() reply["body"] = "Canceled" reply.send() return None if recipient is None: result = cast(ConfirmationSession[AnySession], result) result = await self.__wrap_handler( msg, result.handler, session, msg.get_from(), *result.handler_args, **result.handler_kwargs, ) else: result = cast(ConfirmationRecipient[AnyRecipient], result) result = await self.__wrap_handler( msg, result.handler, recipient, *result.handler_args, **result.handler_kwargs, ) return await self._handle_result(result, msg, session, recipient=recipient) if isinstance(result, TableResult): if len(result.items) == 0: msg.reply("Empty results").send() return None body = result.description + "\n" for item in result.items: for f in result.fields: if f.type == "jid-single": j = JID(item[f.var]) value = f"xmpp:{percent_encode(j)}" if result.jids_are_mucs: value += "?join" else: value = item[f.var] # type:ignore body += f"\n{f.label or f.var}: {value}" msg.reply(body).send() return None raise RuntimeError async def __handle_form( self, result: Form, msg: Message, session: "AnySession | None", recipient: AnyRecipient | None = None, ) -> CommandResponseType: form_values = {} for t in result.title, result.instructions: if t: msg.reply(t).send() for f in result.fields: if f.type == "fixed": msg.reply(f"{f.label or f.var}: {f.value}").send() else: if f.type == "list-multi": msg.reply( "Multiple selection allowed, use new lines as a separator, ie, " "one selected item per line. To select no item, reply with a space " "(the punctuation)." ).send() if f.options: for o in f.options: msg.reply(f"{o['label']}: {self.__make_uri(o['value'])}").send() if f.value: msg.reply(f"Default: {f.value}").send() if f.type == "boolean": msg.reply("yes: " + self.__make_uri("yes")).send() msg.reply("no: " + self.__make_uri("no")).send() ans = await self.xmpp.input( msg.get_from(), (f.label or f.var) + "? (or 'abort')", mtype="chat", ) if ans.lower() == "abort": return await self._handle_result("Command aborted", msg, session) if f.type == "boolean": ans = "true" if ans.lower() == "yes" else "false" if f.type.endswith("multi"): choices = [] if ans == " " else ans.split("\n") form_values[f.var] = f.validate(choices) else: form_values[f.var] = f.validate(ans) if recipient is None: new_result = await self.__wrap_handler( msg, result.handler, form_values, session, msg.get_from(), *result.handler_args, **result.handler_kwargs, ) new_result = cast(CommandResponseSessionType[Any], new_result) else: new_result = await self.__wrap_handler( msg, result.handler, recipient, form_values, *result.handler_args, **result.handler_kwargs, ) new_result = cast(CommandResponseRecipientType[Any], new_result) return await self._handle_result(new_result, msg, session, recipient=recipient) @staticmethod async def __wrap_handler( msg: Message, f: Callable[P, Awaitable[T] | T], *a: P.args, **k: P.kwargs, ) -> T | None: try: if inspect.iscoroutinefunction(f): return await f(*a, **k) # type:ignore[no-any-return] elif hasattr(f, "func") and inspect.iscoroutinefunction(f.func): return await f(*a, **k) # type:ignore[misc,no-any-return] else: return f(*a, **k) # type:ignore[return-value] except Exception as e: log.debug("Error in %s", f, exc_info=e) reply = msg.reply() reply["body"] = f"Error: {e}" reply.send() return None def _handle_help(self, msg: Message, *rest: str) -> None: if len(rest) == 0: reply = msg.reply() reply["body"] = self._help(msg.get_from()) reply.send() elif len(rest) == 1 and (command := self._commands.get(rest[0])): reply = msg.reply() reply["body"] = f"{command.CHAT_COMMAND}: {command.NAME}\n{command.HELP}" reply.send() else: self._not_found(msg, str(rest)) def _help(self, mfrom: JID) -> str: session = self.xmpp.get_session_from_jid(mfrom) msg = "Available commands:" for c in sorted( self._commands.values(), key=lambda co: ( ( co.CATEGORY if isinstance(co.CATEGORY, str) else ( co.CATEGORY.name if isinstance(co.CATEGORY, CommandCategory) else "" ) ), co.CHAT_COMMAND, ), ): try: c.raise_if_not_authorized(mfrom, fetch_session=False, session=session) except XMPPError: continue msg += f"\n{c.CHAT_COMMAND} -- {c.NAME}" return msg def _not_found(self, msg: Message, word: str) -> Never: e = self.UNKNOWN.format(word) msg.reply(e).send() raise XMPPError("item-not-found", e) async def _handle_recipient( self, recipient_str: Literal["contact", "room"], msg: Message, *args: str ) -> None: session = self.xmpp.get_session_from_jid(msg.get_from()) recipient_cls = LegacyContact if recipient_str == "contact" else LegacyMUC if session is None: raise XMPPError("subscription-required") if len(args) == 0 or args[0] == "help": self.xmpp.delivery_receipt.ack(msg) self._help_recipient(msg, recipient_cls) return if len(args) == 1: self._help_recipient(msg, recipient_cls) raise XMPPError( "bad-request", f"Contact commands require at least two parameters: {recipient_str}_jid_username and command_name", ) jid_username, command_name, *rest = args command = recipient_cls.commands_chat.get(command_name) if command is None: raise XMPPError("item-not-found") if recipient_cls is LegacyContact: legacy_id = await session.contacts.jid_username_to_legacy_id(jid_username) recipient = await session.contacts.by_legacy_id(legacy_id) else: legacy_id = await session.bookmarks.jid_username_to_legacy_id(jid_username) recipient = await session.bookmarks.by_legacy_id(legacy_id) result = await self.__wrap_handler(msg, command.run, recipient, *rest) # type:ignore[arg-type,func-returns-value] self.xmpp.delivery_receipt.ack(msg) await self._handle_result(result, msg, session, recipient) def _help_recipient( self, msg: Message, recipient_cls: "type[LegacyContact | AnyMUC]" ) -> None: msg.reply( "Available commands:\n" + "\n".join( f"{co.CHAT_COMMAND} ({co.NAME}): {co.HELP}" for co in recipient_cls.commands_chat.values() ) ).send() def percent_encode(jid: JID) -> str: return f"{url_quote(jid.user)}@{jid.server}" log = logging.getLogger(__name__) slidge/slidge/command/register.py000066400000000000000000000167441522535037600174340ustar00rootroot00000000000000""" This module handles the registration :term:`Command`, which is a necessary step for a JID to become a slidge :term:`User`. """ import asyncio import functools import tempfile from enum import IntEnum from typing import Any import qrcode from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from ..core import config from ..db import GatewayUser from ..util.types import AnySession, UserPreferences from .base import Command, CommandAccess, Form, FormField, FormSession, FormValues from .user import Preferences class RegistrationType(IntEnum): """ An :class:`Enum` to define the registration flow. """ SINGLE_STEP_FORM = 0 """ 1 step, 1 form, the only flow compatible with :xep:`0077`. Using this, the whole flow is defined by :attr:`slidge.BaseGateway.REGISTRATION_FIELDS` and :attr:`.REGISTRATION_INSTRUCTIONS`. """ QRCODE = 10 """ The registration requires flashing a QR code in an official client. See :meth:`slidge.BaseGateway.send_qr`, :meth:`.get_qr_text` and :meth:`.confirm_qr`. """ TWO_FACTOR_CODE = 20 """ The registration requires confirming login with a 2FA code, eg something received by email or SMS to finalize the authentication. See :meth:`.validate_two_factor_code`. """ class TwoFactorNotRequired(Exception): """ Should be raised in :meth:`slidge.BaseGateway.validate` if the code is not required after all. This can happen for a :term:`Legacy Network` where 2FA is optional. """ pass class Register(Command[AnySession]): NAME = "📝 Register to the gateway" HELP = "Link your JID to this gateway" NODE = "jabber:iq:register" CHAT_COMMAND = "register" ACCESS = CommandAccess.NON_USER SUCCESS_MESSAGE = "Success, welcome!" def _finalize( self, form_values: UserPreferences, _session: None, ifrom: JID, user: GatewayUser, *_: Any, # noqa:ANN401 ) -> str: user.preferences = form_values # type: ignore self.xmpp.store.users.update(user) self.xmpp.event("user_register", Iq(sfrom=ifrom.bare)) return self.SUCCESS_MESSAGE async def run( self, _session: "AnySession | None", ifrom: JID, *_: str, ) -> FormSession[AnySession]: self.xmpp.raise_if_not_allowed_jid(ifrom) return FormSession( title=f"Registration to '{self.xmpp.COMPONENT_NAME}'", instructions=self.xmpp.REGISTRATION_INSTRUCTIONS, fields=self.xmpp.REGISTRATION_FIELDS, handler=self.register, ) async def register( self, form_values: dict[str, Any], _session: None, ifrom: JID, ) -> Form | None: two_fa_needed = True try: data = await self.xmpp.user_prevalidate(ifrom, form_values) except ValueError as e: raise XMPPError("bad-request", str(e)) except TwoFactorNotRequired: data = None if self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE: two_fa_needed = False else: raise except Exception as e: raise XMPPError("internal-server-error", str(e)) user = GatewayUser( jid=JID(ifrom.bare), legacy_module_data=form_values if data is None else data, ) if self.xmpp.REGISTRATION_TYPE == RegistrationType.SINGLE_STEP_FORM or ( self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE and not two_fa_needed ): return await self.preferences(user) if self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE: return Form( title=self.xmpp.REGISTRATION_2FA_TITLE, instructions=self.xmpp.REGISTRATION_2FA_INSTRUCTIONS, fields=[FormField("code", label="Code", required=True)], handler=functools.partial(self.two_fa, user=user), ) elif self.xmpp.REGISTRATION_TYPE == RegistrationType.QRCODE: self.xmpp.qr_pending_registrations[user.jid.bare] = ( self.xmpp.loop.create_future() ) qr_text = await self.xmpp.get_qr_text(user) qr = qrcode.make(qr_text) with tempfile.NamedTemporaryFile(suffix=".png") as f: qr.save(f.name) img_url, _ = await self.xmpp.send_file(f.name, mto=ifrom) if img_url is None: raise XMPPError( "internal-server-error", "Slidge cannot send attachments" ) self.xmpp.send_text(qr_text, mto=ifrom) return Form( title="Flash this", instructions="Flash this QR in the appropriate place", fields=[ FormField( "qr_img", type="fixed", value=qr_text, image_url=img_url, ), FormField( "qr_text", type="fixed", value=qr_text, label="Text encoded in the QR code", ), FormField( "qr_img_url", type="fixed", value=img_url, label="URL of the QR code image", ), ], handler=functools.partial(self.qr, user=user), ) async def two_fa( self, form_values: FormValues, _session: None, _ifrom: JID, user: GatewayUser, ) -> Form: assert isinstance(form_values["code"], str) data = await self.xmpp.validate_two_factor_code(user, form_values["code"]) if data is not None: user.legacy_module_data.update(data) return await self.preferences(user) async def qr( self, _form_values: FormValues, _session: None, _ifrom: JID, user: GatewayUser, ) -> Form: try: data = await asyncio.wait_for( self.xmpp.qr_pending_registrations[user.jid.bare], config.QR_TIMEOUT, ) except TimeoutError: raise XMPPError( "remote-server-timeout", ( "It does not seem that the QR code was correctly used, " "or you took too much time" ), ) if data is not None: user.legacy_module_data.update(data) return await self.preferences(user) async def preferences(self, user: GatewayUser) -> Form: return Form( title="Preferences", instructions=Preferences.HELP, fields=self.xmpp.PREFERENCES, handler=functools.partial(self._finalize, user=user), timeout_handler=functools.partial(self._preferences_timeout, user=user), ) def _preferences_timeout(self, user: GatewayUser) -> None: self.xmpp.event("user_register", Iq(sfrom=user.jid.bare)) self.xmpp.store.users.update(user) self.xmpp.send_message( mfrom=self.xmpp.boundjid.bare, mto=user.jid, mbody="You did not choose your preferences in time, falling back to defaults. " "You can change preferences later with the 'preferences' command.\n" + self.SUCCESS_MESSAGE, ) slidge/slidge/command/user.py000066400000000000000000000440721522535037600165610ustar00rootroot00000000000000# Commands available to users import contextlib from copy import deepcopy from typing import Any, cast from slixmpp import JID from slixmpp.exceptions import XMPPError from slidge.db.meta import JSONSerializable from slidge.db.models import Space from ..util.types import ( AnyMUC, AnySession, MucType, UserPreferences, ) from .base import ( Command, CommandAccess, Confirmation, ConfirmationSession, Form, FormField, FormSession, FormValues, SearchResult, TableResult, ) from .categories import CONTACTS, GROUPS, SPACES class Search(Command[AnySession]): NAME = "🔎 Search for contacts" HELP = "Search for contacts via this gateway" CHAT_COMMAND = "find" NODE = CONTACTS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = CONTACTS async def run( self, session: "AnySession | None", _ifrom: JID, *args: str ) -> FormSession[AnySession] | SearchResult | None: assert session is not None await session.ready if args: return await session.on_search( {self.xmpp.SEARCH_FIELDS[0].var: " ".join(args)} ) return FormSession[AnySession]( title=self.xmpp.SEARCH_TITLE, instructions=self.xmpp.SEARCH_INSTRUCTIONS, fields=self.xmpp.SEARCH_FIELDS, handler=self.search, ) @staticmethod async def search( form_values: FormValues, session: "AnySession | None", _ifrom: JID ) -> SearchResult: assert session is not None results = await session.on_search(form_values) # type: ignore if results is None: raise XMPPError("item-not-found", "No contact was found") return results class SyncContacts(Command[AnySession]): NAME = "🔄 Sync XMPP roster" HELP = ( "Synchronize your XMPP roster with your legacy contacts. " "Slidge will only add/remove/modify contacts in its dedicated roster group" ) CHAT_COMMAND = "sync-contacts" NODE = CONTACTS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = CONTACTS async def run( self, session: "AnySession | None", _ifrom: JID, *args: str, ) -> ConfirmationSession[AnySession]: assert session is not None await session.ready return ConfirmationSession[AnySession]( prompt="Are you sure you want to sync your roster?", success=None, handler=self.sync, ) async def sync(self, session: "AnySession | None", _ifrom: JID) -> str: if session is None: raise RuntimeError roster_iq = await self.xmpp["xep_0356"].get_roster(session.user_jid.bare) contacts = session.contacts.known_contacts() added = 0 removed = 0 updated = 0 for item in roster_iq["roster"]: groups = set(item["groups"]) if self.xmpp.ROSTER_GROUP in groups: contact = contacts.pop(item["jid"], None) if contact is None: if len(groups) == 1: await self.xmpp["xep_0356"].set_roster( session.user_jid, {item["jid"]: {"subscription": "remove"}} ) removed += 1 else: groups.remove(self.xmpp.ROSTER_GROUP) await self.xmpp["xep_0356"].set_roster( session.user_jid, { item["jid"]: { "subscription": item["subscription"], "name": item["name"], "groups": groups, } }, ) updated += 1 else: if contact.name != item["name"]: await contact.add_to_roster(force=True) updated += 1 # we popped before so this only acts on slidge contacts not in the xmpp roster for contact in contacts.values(): added += 1 await contact.add_to_roster() return f"{added} added, {removed} removed, {updated} updated" class ListContacts(Command[AnySession]): NAME = HELP = "👤 List your legacy contacts" CHAT_COMMAND = "contacts" NODE = CONTACTS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = CONTACTS async def run( self, session: "AnySession | None", _ifrom: JID, *_: str, ) -> TableResult: assert session is not None await session.ready contacts = sorted( session.contacts, key=lambda c: c.name.casefold() if c.name else "" ) return TableResult( description="Your buddies", fields=[FormField("name"), FormField("jid", type="jid-single")], items=[{"name": c.name, "jid": c.jid.bare} for c in contacts], ) class ListGroups(Command[AnySession]): NAME = HELP = "👥 List your legacy groups" CHAT_COMMAND = "groups" NODE = GROUPS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = GROUPS async def run( self, session: "AnySession | None", _ifrom: JID, *_: str ) -> TableResult: assert session is not None await session.ready groups: list[AnyMUC] = sorted( session.bookmarks, key=lambda g: (g.name or g.jid.node).casefold() ) return TableResult( description="Your groups", fields=[FormField("name"), FormField("jid", type="jid-single")], items=[ {"name": g.name or str(g.legacy_id), "jid": g.jid.bare} for g in groups ], jids_are_mucs=True, ) class ListSpaces(Command[AnySession]): NAME = "🌐 List my spaces" HELP = "List the spaces you are part of. Spaces are collections of rooms." CHAT_COMMAND = "spaces" NODE = GROUPS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = SPACES related_to_spaces = True async def run( self, session: "AnySession | None", _ifrom: JID, *_: str ) -> TableResult: assert session is not None spaces = await _get_updated_spaces(session) return TableResult( description="Your spaces. If your client does not support spaces, use the 'space-rooms' command.", fields=[FormField("name"), FormField("iri")], items=[ { "name": s.name or str(s.legacy_id), "iri": f"xmpp:{self.xmpp.boundjid.bare}?;node={await session.bookmarks.space_legacy_id_to_node(s.legacy_id)}", } for s in spaces ], jids_are_mucs=True, ) class ListRoomsInSpace(Command[AnySession]): NAME = "🌐 List the rooms in a space" HELP = "List the rooms of a space you are part of. Spaces are collections of rooms." CHAT_COMMAND = "space-rooms" NODE = GROUPS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = SPACES async def run( self, session: "AnySession | None", _ifrom: JID, *_: str ) -> "FormSession[AnySession]": assert session is not None spaces = await _get_updated_spaces(session) return FormSession( title="Your spaces", instructions="Select a space to view its rooms", fields=[ FormField( var="space_legacy_id", label="Space", required=True, type="list-single", options=[ { "label": s.name or str(s.legacy_id), "value": str(s.legacy_id), } for s in spaces ], ) ], handler=self.list_rooms, handler_args=({str(s.legacy_id): s.name for s in spaces},), ) async def list_rooms( self, form_values: FormValues, session: "AnySession | None", _ifrom: JID, space_names: dict[str, str], ) -> TableResult: assert session is not None space_legacy_id = form_values.get("space_legacy_id") if space_legacy_id is None: raise XMPPError("bad-request", "You need to specify a space") assert isinstance(space_legacy_id, str) await session.ready with self.xmpp.store.session() as orm: rooms = sorted( list( self.xmpp.store.spaces.get_rooms( orm, session.user_pk, await session.bookmarks.space_legacy_id_to_node( space_legacy_id ), ) ), key=lambda r: (r.name or str(r.jid.node)).casefold(), ) name = space_names.get(space_legacy_id) return TableResult( fields=[ FormField("name", "Name"), FormField("jid", "JID", type="jid-single"), ], description=f"Rooms of '{name or space_legacy_id}'", items=[{"name": r.name or str(r.legacy_id), "jid": r.jid} for r in rooms], jids_are_mucs=True, ) class Login(Command[AnySession]): NAME = "🔐 Re-login to the legacy network" HELP = "Login to the legacy service" CHAT_COMMAND = "re-login" NODE = "https://slidge.im/command/core/" + CHAT_COMMAND ACCESS = CommandAccess.USER_NON_LOGGED async def run( self, session: "AnySession | None", _ifrom: JID, *_: str, ) -> str: assert session is not None if session.is_logging_in: raise XMPPError("bad-request", "You are already logging in.") session.is_logging_in = True try: msg = await self.xmpp.login_wrap(session) except Exception as e: session.send_gateway_status(f"Re-login failed: {e}", show="dnd") raise XMPPError( "internal-server-error", etype="wait", text=f"Could not login: {e}" ) finally: session.is_logging_in = False session.logged = True session.send_gateway_status(msg or "Re-connected", show="chat") session.send_gateway_message(msg or "Re-connected") return msg class CreateGroup(Command[AnySession]): NAME = "🆕 New legacy group" HELP = "Create a group on the legacy service" CHAT_COMMAND = "create-group" NODE = GROUPS.node + "/" + CHAT_COMMAND CATEGORY = GROUPS ACCESS = CommandAccess.USER_LOGGED async def run( self, session: "AnySession | None", _ifrom: JID, *_: str, ) -> FormSession[AnySession]: assert session is not None await session.ready contacts = session.contacts.known_contacts(only_friends=True) return FormSession( title="Create a new group", instructions="Pick contacts that should be part of this new group", fields=[ FormField(var="group_name", label="Name of the group", required=True), FormField( var="contacts", label="Contacts to add to the new group", type="list-multi", options=[ {"value": str(contact.jid), "label": contact.name} for contact in sorted(contacts.values(), key=lambda c: c.name) ], required=False, ), ], handler=self.finish, ) @staticmethod async def finish( form_values: FormValues, session: "AnySession | None", *_: Any, # noqa:ANN401 ) -> TableResult: assert session is not None legacy_id: str = await session.on_create_group( cast(str, form_values["group_name"]), [ await session.contacts.by_jid(JID(j)) for j in form_values.get("contacts", []) # type:ignore ], ) muc = await session.bookmarks.by_legacy_id(legacy_id) return TableResult( description=f"Your new group: xmpp:{muc.jid}?join", fields=[FormField("name"), FormField("jid", type="jid-single")], items=[{"name": muc.name, "jid": muc.jid}], jids_are_mucs=True, ) class Preferences(Command[AnySession]): NAME = "⚙️ Preferences" HELP = "Customize the gateway behaviour to your liking" CHAT_COMMAND = "preferences" NODE = "https://slidge.im/command/core/preferences" ACCESS = CommandAccess.USER async def run( self, session: "AnySession | None", _ifrom: JID, *_: str, ) -> FormSession[AnySession]: fields = deepcopy(self.xmpp.PREFERENCES) assert session is not None current = session.user.preferences for field in fields: field.value = current.get(field.var, field.value) # type:ignore return Form( title="Preferences", instructions=self.HELP, fields=fields, handler=self.finish, # type:ignore handler_kwargs={"previous": current}, ) async def finish( self, form_values: UserPreferences, session: "AnySession | None", *_: Any, # noqa:ANN401 previous: JSONSerializable, ) -> str: assert session is not None if previous == form_values: return "No preference was changed" previous = previous.copy() user = session.user user.preferences.update(form_values) # type:ignore self.xmpp.store.users.update(user) with contextlib.suppress(NotImplementedError): await session.on_preferences(previous, form_values) # type:ignore[arg-type] if not previous["sync_avatar"] and form_values["sync_avatar"]: await self.xmpp.fetch_user_avatar(session) else: user.avatar_hash = None return "Your preferences have been updated." class Unregister(Command[AnySession]): NAME = "❌ Unregister from the gateway" HELP = "Unregister from the gateway" CHAT_COMMAND = "unregister" NODE = "https://slidge.im/command/core/unregister" ACCESS = CommandAccess.USER async def run( self, session: "AnySession | None", _ifrom: JID, *_: str ) -> ConfirmationSession[AnySession]: return ConfirmationSession[AnySession]( prompt=f"Are you sure you want to unregister from '{self.xmpp.boundjid}'?", success=f"You are not registered to '{self.xmpp.boundjid}' anymore.", handler=self.unregister, ) async def unregister(self, session: "AnySession | None", _ifrom: JID) -> str: assert session is not None await self.xmpp.unregister_user(session.user) return "You are not registered anymore. Bye!" class LeaveGroup(Command[AnySession]): NAME = HELP = "❌ Leave a legacy group" CHAT_COMMAND = "leave-group" NODE = GROUPS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = GROUPS async def run( self, session: "AnySession | None", ifrom: JID, *_: str, ) -> FormSession[AnySession]: assert session is not None await session.ready groups = sorted(session.bookmarks, key=lambda g: g.DISCO_NAME.casefold()) return FormSession( title="Leave a group", instructions="Select the group you want to leave", fields=[ FormField( "group", "Group name", type="list-single", options=[ {"label": g.name or str(g.legacy_id), "value": str(i)} for i, g in enumerate(groups) ], ) ], handler=self.confirm, handler_args=(groups,), ) async def confirm( self, form_values: FormValues, _session: "AnySession | None", _ifrom: JID, groups: list[AnyMUC], ) -> Confirmation: group = groups[int(form_values["group"])] # type:ignore return Confirmation( prompt=f"Are you sure you want to leave the group '{group.name}'?", handler=self.finish, handler_args=(group,), ) @staticmethod async def finish(session: AnySession, _ifrom: JID, group: AnyMUC) -> None: await group.on_leave() await session.bookmarks.remove(group, reason="You left this group via slidge.") class InviteInGroups(Command[AnySession]): NAME = "💌 Re-invite me in my groups" HELP = "Ask the gateway to send invitations for all your private groups" CHAT_COMMAND = "re-invite" NODE = GROUPS.node + "/" + CHAT_COMMAND ACCESS = CommandAccess.USER_LOGGED CATEGORY = GROUPS async def run(self, session: "AnySession | None", _ifrom: JID, *_: str) -> None: assert session is not None await session.ready for muc in session.bookmarks: if muc.type == MucType.GROUP: session.send_gateway_invite( muc, reason="You asked to be re-invited in all groups." ) async def _get_updated_spaces(session: AnySession) -> list[Space]: await session.ready with session.xmpp.store.session() as orm: spaces = list(session.xmpp.store.spaces.get_all(orm, session.user_pk)) updated_spaces: list[Space] = [] for space in spaces: try: updated_spaces.append(await session.bookmarks.update_space_if_needed(space)) except Exception: session.log.exception( "Something went wrong trying to update space '%r'", space ) return sorted(updated_spaces, key=lambda s: s.name or str(s.legacy_id)) slidge/slidge/contact/000077500000000000000000000000001522535037600152375ustar00rootroot00000000000000slidge/slidge/contact/__init__.py000066400000000000000000000002771522535037600173560ustar00rootroot00000000000000""" Everything related to 1 on 1 chats, and other legacy users' details. """ from .contact import LegacyContact from .roster import LegacyRoster __all__ = ("LegacyContact", "LegacyRoster") slidge/slidge/contact/contact.py000066400000000000000000000475001522535037600172520ustar00rootroot00000000000000import datetime import logging import warnings from collections.abc import Iterable, Iterator, Sequence from datetime import date from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self from xml.etree import ElementTree as ET from slixmpp import JID, Message, Presence from slixmpp.exceptions import IqError, IqTimeout from slixmpp.plugins.xep_0292.stanza import VCard4 from slixmpp.types import MessageTypes from slidge.db.avatar import CachedAvatar from ..core.mixins import AvatarMixin, FullCarbonMixin from ..core.mixins.disco import ContactAccountDiscoMixin from ..core.mixins.recipient import RecipientMixin from ..db.models import Contact, ContactSent from ..util import SubclassableOnce from ..util.types import ( AnySession, ClientType, MessageOrPresenceTypeVar, ) if TYPE_CHECKING: from ..command.base import ContactCommand from ..group.participant import LegacyParticipant class LegacyContact( AvatarMixin, ContactAccountDiscoMixin, FullCarbonMixin, RecipientMixin, SubclassableOnce, ): """ This class centralizes actions in relation to a specific legacy contact. You shouldn't create instances of contacts manually, but rather rely on :meth:`.LegacyRoster.by_legacy_id` to ensure that contact instances are singletons. The :class:`.LegacyRoster` instance of a session is accessible through the :attr:`.BaseSession.contacts` attribute. Typically, your plugin should have methods hook to the legacy events and call appropriate methods here to transmit the "legacy action" to the xmpp user. This should look like this: .. code-block:python class Session(BaseSession): ... async def on_cool_chat_network_new_text_message(self, legacy_msg_event): contact = self.contacts.by_legacy_id(legacy_msg_event.from) contact.send_text(legacy_msg_event.text) async def on_cool_chat_network_new_typing_event(self, legacy_typing_event): contact = self.contacts.by_legacy_id(legacy_msg_event.from) contact.composing() ... Use ``carbon=True`` as a keyword arg for methods to represent an action FROM the user TO the contact, typically when the user uses an official client to do an action such as sending a message or marking as message as read. This will use :xep:`0363` to impersonate the XMPP user in order. """ session: AnySession RESOURCE: str = "slidge" """ A full JID, including a resource part is required for chat states (and maybe other stuff) to work properly. This is the name of the resource the contacts will use. """ PROPAGATE_PRESENCE_TO_GROUPS = True mtype: MessageTypes = "chat" _can_send_carbon = True is_participant: Literal[False] = False is_group: Literal[False] = False _ONLY_SEND_PRESENCE_CHANGES = True STRIP_SHORT_DELAY = True _NON_FRIEND_PRESENCES_FILTER: ClassVar[set[str]] = {"subscribe", "unsubscribed"} INVITATION_RECIPIENT = True commands: ClassVar[dict[str, "type[ContactCommand[LegacyContact]]"]] = {} commands_chat: ClassVar[dict[str, "type[ContactCommand[LegacyContact]]"]] = {} stored: Contact model: Contact def __init__(self, session: AnySession, stored: Contact) -> None: self.session = session self.xmpp = session.xmpp self.stored = stored self._set_logger() super().__init__() @property def jid(self) -> JID: jid = JID(self.stored.jid) jid.resource = self.RESOURCE return jid @jid.setter def jid(self, _jid: JID) -> None: raise RuntimeError @property def legacy_id(self) -> str: return self.stored.legacy_id async def get_vcard(self, fetch: bool = True) -> VCard4 | None: if fetch and not self.stored.vcard_fetched: await self.fetch_vcard() if self.stored.vcard is None: return None return VCard4(xml=ET.fromstring(self.stored.vcard)) @property def is_friend(self) -> bool: return self.stored.is_friend @is_friend.setter def is_friend(self, value: bool) -> None: if value == self.is_friend: return self.update_stored_attribute(is_friend=value) @property def added_to_roster(self) -> bool: return self.stored.added_to_roster @added_to_roster.setter def added_to_roster(self, value: bool) -> None: if value == self.added_to_roster: return self.update_stored_attribute(added_to_roster=value) @property def participants(self) -> Iterator["LegacyParticipant[Self]"]: with self.xmpp.store.session() as orm: self.stored = orm.merge(self.stored) participants = self.stored.participants for p in participants: with self.xmpp.store.session() as orm: p = orm.merge(p) muc = self.session.bookmarks.from_store(p.room) part = muc.participant_from_store(p, contact=self) yield part @property def user_jid(self) -> JID: return self.session.user_jid @property # type:ignore def DISCO_TYPE(self) -> ClientType: return self.client_type @DISCO_TYPE.setter def DISCO_TYPE(self, value: ClientType) -> None: self.client_type = value @property def client_type(self) -> ClientType: """ The client type of this contact, cf https://xmpp.org/registrar/disco-categories.html#client Default is "pc". """ return self.stored.client_type @client_type.setter def client_type(self, value: ClientType) -> None: if self.stored.client_type == value: return self.update_stored_attribute(client_type=value) def _set_logger(self) -> None: self.log = logging.getLogger(f"{self.user_jid.bare}:contact:{self}") def __repr__(self) -> str: return f"" def __get_subscription_string(self) -> str: if self.is_friend: return "both" return "none" def __propagate_to_participants(self, stanza: Presence) -> None: if not self.PROPAGATE_PRESENCE_TO_GROUPS: return ptype = stanza["type"] if ptype in ("available", "chat"): func_name = "online" elif ptype in ("xa", "unavailable"): # we map unavailable to extended_away, because offline is # "participant leaves the MUC" # TODO: improve this with a clear distinction between participant # and member list func_name = "extended_away" elif ptype == "busy": func_name = "busy" elif ptype == "away": func_name = "away" else: return last_seen: datetime.datetime | None = ( stanza["idle"]["since"] if "idle" in stanza else None ) kw = dict(status=stanza["status"], last_seen=last_seen) for part in self.participants: func = getattr(part, func_name) func(**kw) def _send( self, stanza: MessageOrPresenceTypeVar, carbon: bool = False, nick: bool = False, **send_kwargs: Any, # noqa:ANN401 ) -> MessageOrPresenceTypeVar: if carbon and isinstance(stanza, Message): stanza["to"] = self.jid.bare stanza["from"] = self.user_jid self._privileged_send(stanza) return stanza if isinstance(stanza, Presence): if not self._updating_info: self.__propagate_to_participants(stanza) if ( not self.is_friend and stanza["type"] not in self._NON_FRIEND_PRESENCES_FILTER ): return stanza if self.name and (nick or not self.is_friend): n = self.xmpp.plugin["xep_0172"].stanza.UserNick() n["nick"] = self.name stanza.append(n) if ( not self._updating_info and self.xmpp.MARK_ALL_MESSAGES and is_markable(stanza) ): with self.xmpp.store.session(expire_on_commit=False) as orm: self.stored = orm.merge(self.stored) exists = ( orm.query(ContactSent) .filter_by(contact_id=self.stored.id, msg_id=stanza["id"]) .first() ) if exists: self.log.warning( "Contact has already sent message %s", stanza["id"] ) else: new = ContactSent(contact=self.stored, msg_id=stanza["id"]) orm.add(new) self.stored.sent_order.append(new) orm.commit() stanza["to"] = self.user_jid stanza.send() return stanza def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]: """ Return XMPP msg ids sent by this contact up to a given XMPP msg id. Legacy modules have no reason to use this, but it is used by slidge core for legacy networks that need to mark all messages as read (most XMPP clients only send a read marker for the latest message). This has side effects, if the horizon XMPP id is found, messages up to this horizon are cleared, to avoid sending the same read mark twice. :param horizon_xmpp_id: The latest message :return: A list of XMPP ids up to horizon_xmpp_id, included """ with self.xmpp.store.session() as orm: assert self.stored.id is not None ids = self.xmpp.store.contacts.pop_sent_up_to( orm, self.stored.id, horizon_xmpp_id ) orm.commit() return ids @property def name(self) -> str: """ Friendly name of the contact, as it should appear in the user's roster """ return self.stored.nick or "" @name.setter def name(self, n: str | None) -> None: if self.stored.nick == n: return self.update_stored_attribute(nick=n) self._set_logger() if self.is_friend and self.added_to_roster: self.xmpp.pubsub.broadcast_nick( user_jid=self.user_jid, jid=self.jid.bare, nick=n ) for p in self.participants: p.nickname = n or str(self.legacy_id) def _post_avatar_update(self, cached_avatar: CachedAvatar | None) -> None: if self.is_friend and self.added_to_roster: self.session.create_task( self.session.xmpp.pubsub.broadcast_avatar( self.jid.bare, self.session.user_jid, cached_avatar ) ) for p in self.participants: self.log.debug("Propagating new avatar to %s", p.muc) p.send_last_presence(force=True, no_cache_online=True) def set_vcard( self, /, full_name: str | None = None, given: str | None = None, surname: str | None = None, birthday: date | None = None, phone: str | None = None, phones: Iterable[str] = (), note: str | None = None, url: str | None = None, email: str | None = None, country: str | None = None, locality: str | None = None, pronouns: str | None = None, ) -> None: """ Update xep:`0292` data for this contact. Use this for additional metadata about this contact to be available to XMPP clients. The "note" argument is a text of arbitrary size and can be useful when no other field is a good fit. """ vcard = VCard4() vcard.add_impp(f"xmpp:{self.jid.bare}") if n := self.name: vcard.add_nickname(n) if full_name: vcard["full_name"] = full_name elif n: vcard["full_name"] = n if given: vcard["given"] = given if surname: vcard["surname"] = surname if birthday: vcard["birthday"] = birthday if note: vcard.add_note(note) if url: vcard.add_url(url) if email: vcard.add_email(email) if phone: vcard.add_tel(phone) for p in phones: vcard.add_tel(p) if (country and locality) or country: vcard.add_address(country, locality) if pronouns: vcard["pronouns"]["text"] = pronouns self.update_stored_attribute(vcard=str(vcard), vcard_fetched=True) self.session.create_task( self.xmpp.pubsub.broadcast_vcard_event(self.jid, self.user_jid, vcard) ) def get_roster_item(self) -> dict[str, dict[str, str | Sequence[str]]]: item = { "subscription": self.__get_subscription_string(), "groups": [self.xmpp.ROSTER_GROUP], } if (n := self.name) is not None: item["name"] = n return {self.jid.bare: item} async def add_to_roster(self, force: bool = False) -> None: """ Add this contact to the user roster using :xep:`0356` :param force: add even if the contact was already added successfully """ if self.added_to_roster and not force: return if not self.session.user.preferences.get("roster_push", True): log.debug("Roster push request by plugin ignored (--no-roster-push)") return try: await self.xmpp.plugin["xep_0356"].set_roster( jid=self.user_jid, roster_items=self.get_roster_item() ) except PermissionError: warnings.warn( f"Slidge does not have the privilege (XEP-0356) to manage the roster of {self.user_jid}. " "If this is a local user, consider configuring your XMPP server for that." ) self.send_friend_request( f"I'm already your friend on {self.xmpp.COMPONENT_TYPE}, but " "slidge is not allowed to manage your roster." ) return except (IqError, IqTimeout) as e: self.log.warning("Could not add to roster", exc_info=e) else: # we only broadcast pubsub events for contacts added to the roster # so if something was set before, we need to push it now self.added_to_roster = True self.send_last_presence(force=True) async def __broadcast_pubsub_items(self) -> None: if not self.is_friend: return if not self.added_to_roster: return cached_avatar = self.get_cached_avatar() if cached_avatar is not None: await self.xmpp.pubsub.broadcast_avatar( self.jid.bare, self.session.user_jid, cached_avatar ) nick = self.name if nick is not None: self.xmpp.pubsub.broadcast_nick( self.session.user_jid, self.jid.bare, nick, ) def send_friend_request(self, text: str | None = None) -> None: presence = self._make_presence(ptype="subscribe", pstatus=text, bare=True) self._send(presence, nick=True) async def accept_friend_request(self, text: str | None = None) -> None: """ Call this to signify that this Contact has accepted to be a friend of the user. :param text: Optional message from the friend to the user """ self.is_friend = True self.added_to_roster = True self.log.debug("Accepting friend request") presence = self._make_presence(ptype="subscribed", pstatus=text, bare=True) self._send(presence, nick=True) self.send_last_presence() await self.__broadcast_pubsub_items() self.log.debug("Accepted friend request") def reject_friend_request(self, text: str | None = None) -> None: """ Call this to signify that this Contact has refused to be a contact of the user (or that they don't want to be friends anymore) :param text: Optional message from the non-friend to the user """ presence = self._make_presence(ptype="unsubscribed", pstatus=text, bare=True) self.offline() self._send(presence, nick=True) self.is_friend = False async def on_friend_request(self, text: str = "") -> None: """ Called when receiving a "subscribe" presence, ie, "I would like to add you to my contacts/friends", from the user to this contact. In XMPP terms: "I would like to receive your presence updates" This is only called if self.is_friend = False. If self.is_friend = True, slidge will automatically "accept the friend request", ie, reply with a "subscribed" presence. When called, a 'friend request event' should be sent to the legacy service, and when the contact responds, you should either call self.accept_subscription() or self.reject_subscription() """ pass async def on_friend_delete(self, text: str = "") -> None: """ Called when receiving an "unsubscribed" presence, ie, "I would like to remove you to my contacts/friends" or "I refuse your friend request" from the user to this contact. In XMPP terms: "You won't receive my presence updates anymore (or you never have)". """ pass async def on_friend_accept(self) -> None: """ Called when receiving a "subscribed" presence, ie, "I accept to be your/confirm that you are my friend" from the user to this contact. In XMPP terms: "You will receive my presence updates". """ pass def unsubscribe(self) -> None: """ (internal use by slidge) Send an "unsubscribe", "unsubscribed", "unavailable" presence sequence from this contact to the user, ie, "this contact has removed you from their 'friends'". """ for ptype in "unsubscribe", "unsubscribed", "unavailable": self.xmpp.send_presence(pfrom=self.jid, pto=self.user_jid.bare, ptype=ptype) async def update_info(self) -> None: """ Fetch information about this contact from the legacy network This is awaited on Contact instantiation, and should be overridden to update the nickname, avatar, vcard [...] of this contact, by making "legacy API calls". To take advantage of the slidge avatar cache, you can check the .avatar property to retrieve the "legacy file ID" of the cached avatar. If there is no change, you should not call :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar` or attempt to modify the ``.avatar`` property. """ pass async def fetch_vcard(self) -> None: """ It the legacy network doesn't like that you fetch too many profiles on startup, it's also possible to fetch it here, which will be called when XMPP clients of the user request the vcard, if it hasn't been fetched before :return: """ pass def _make_presence( self, *, last_seen: datetime.datetime | None = None, status_codes: set[int] | None = None, user_full_jid: JID | None = None, **presence_kwargs: Any, # noqa:ANN401 ) -> Presence: p = super()._make_presence(last_seen=last_seen, **presence_kwargs) caps = self.xmpp.plugin["xep_0115"] if p.get_from().resource and self.stored.caps_ver: p["caps"]["node"] = caps.caps_node p["caps"]["hash"] = caps.hash p["caps"]["ver"] = self.stored.caps_ver return p def is_markable(stanza: Message | Presence) -> bool: if isinstance(stanza, Presence): return False return bool(stanza["body"]) log = logging.getLogger(__name__) slidge/slidge/contact/roster.py000066400000000000000000000214331522535037600171320ustar00rootroot00000000000000import asyncio import logging import warnings from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Generic from slixmpp import JID from slixmpp.exceptions import IqError, IqTimeout, XMPPError from sqlalchemy.orm import Session from sqlalchemy.orm import Session as OrmSession from ..db.models import Contact, GatewayUser from ..util import SubclassableOnce from ..util.jid_escaping import EscapeMixin from ..util.lock import NamedLockMixin from ..util.types import AnySession, LegacyContactType from ..util.util import timeit if TYPE_CHECKING: pass class ContactIsUser(Exception): pass class LegacyRoster( Generic[LegacyContactType], NamedLockMixin, EscapeMixin, SubclassableOnce, ): """ Virtual roster of a gateway user that allows to represent all of their contacts as singleton instances (if used properly and not too bugged). Every :class:`.BaseSession` instance will have its own :class:`.LegacyRoster` instance accessible via the :attr:`.BaseSession.contacts` attribute. Typically, you will mostly use the :meth:`.LegacyRoster.by_legacy_id` function to retrieve a contact instance. You might need to override :meth:`.LegacyRoster.legacy_id_to_jid_username` and/or :meth:`.LegacyRoster.jid_username_to_legacy_id` to incorporate some custom logic if you need some characters when translation JID user parts and legacy IDs. """ _contact_cls: type[LegacyContactType] def __init__(self, session: AnySession) -> None: super().__init__() self.log = logging.getLogger(f"{session.user_jid.bare}:roster") self.user_legacy_id: str | None = None self.ready: asyncio.Future[bool] = session.xmpp.loop.create_future() self.session = session self.__filling = False @property def user(self) -> GatewayUser: return self.session.user def orm(self) -> Session: return self.session.xmpp.store.session() def from_store(self, stored: Contact) -> LegacyContactType: return self._contact_cls(self.session, stored=stored) def __repr__(self) -> str: return f"" def __iter__(self) -> Iterator[LegacyContactType]: with self.orm() as orm: contacts = orm.query(Contact).filter_by(user=self.user, updated=True).all() for stored in contacts: yield self.from_store(stored) def known_contacts(self, only_friends: bool = True) -> dict[str, LegacyContactType]: if only_friends: return {c.jid.bare: c for c in self if c.is_friend} return {c.jid.bare: c for c in self} async def by_jid(self, contact_jid: JID) -> LegacyContactType: # """ # Retrieve a contact by their JID # # If the contact was not instantiated before, it will be created # using :meth:`slidge.LegacyRoster.jid_username_to_legacy_id` to infer their # legacy user ID. # # :param contact_jid: # :return: # """ username = contact_jid.node if not username: raise XMPPError( "bad-request", "Contacts must have a local part in their JID" ) contact_jid = JID(contact_jid.bare) async with self.lock(("username", username)): legacy_id = await self.jid_username_to_legacy_id(username) if legacy_id == self.user_legacy_id: raise ContactIsUser if self.get_lock(("legacy_id", legacy_id)): self.log.debug("Already updating %s via by_legacy_id()", contact_jid) return await self.by_legacy_id(legacy_id) with self.orm() as orm: stored = ( orm.query(Contact) .filter_by(user=self.user, jid=contact_jid) .one_or_none() ) if stored is None: stored = Contact( user_account_id=self.session.user_pk, legacy_id=legacy_id, jid=contact_jid, ) return await self.__update_if_needed(stored) async def __update_if_needed(self, stored: Contact) -> LegacyContactType: contact = self.from_store(stored) if contact.stored.updated: return contact with contact.updating_info(): await contact.update_info() if contact.is_friend and not self.__filling: await contact.add_to_roster() if contact.cached_presence is not None: contact._store_last_presence(contact.cached_presence) return contact def by_jid_only_if_exists(self, contact_jid: JID) -> LegacyContactType | None: with self.orm() as orm: stored = ( orm.query(Contact) .filter_by(user=self.user, jid=contact_jid) .one_or_none() ) if stored is not None and stored.updated: return self.from_store(stored) return None @timeit async def by_legacy_id(self, /, legacy_id: str) -> LegacyContactType: """ Retrieve a contact by their legacy_id If the contact was not instantiated before, it will be created using :meth:`slidge.LegacyRoster.legacy_id_to_jid_username` to infer their legacy user ID. :param legacy_id: :return: """ if legacy_id == self.user_legacy_id: raise ContactIsUser async with self.lock(("legacy_id", legacy_id)): username = await self.legacy_id_to_jid_username(legacy_id) if self.get_lock(("username", username)): self.log.debug("Already updating %s via by_jid()", username) return await self.by_jid( JID(username + "@" + self.session.xmpp.boundjid.bare) ) with self.orm() as orm: stored = ( orm.query(Contact) .filter_by(user=self.user, legacy_id=str(legacy_id)) .one_or_none() ) if stored is None: stored = Contact( user_account_id=self.session.user_pk, legacy_id=str(legacy_id), jid=JID(f"{username}@{self.session.xmpp.boundjid.bare}"), ) return await self.__update_if_needed(stored) @timeit async def _fill(self, orm: OrmSession) -> None: try: if hasattr(self.session.xmpp, "TEST_MODE"): # dirty hack to avoid mocking xmpp server replies to this # during tests raise PermissionError iq = await self.session.xmpp["xep_0356"].get_roster( self.session.user_jid.bare ) user_roster = iq["roster"]["items"] except (PermissionError, IqError, IqTimeout): user_roster = None self.__filling = True async for contact in self.fill(): if user_roster is None: continue item = contact.get_roster_item() old = user_roster.get(contact.jid.bare) if old is not None and all( old[k] == item[contact.jid.bare].get(k) for k in ("subscription", "groups", "name") ): self.log.debug("No need to update roster") continue self.log.debug("Updating roster") if not contact.is_friend: continue if not self.session.user.preferences.get("roster_push", True): continue try: await self.session.xmpp["xep_0356"].set_roster( self.session.user_jid.bare, item, ) except (PermissionError, IqError, IqTimeout) as e: warnings.warn(f"Could not add to roster: {e}") else: contact.added_to_roster = True contact.send_last_presence(force=True) orm.commit() self.__filling = False async def fill(self) -> AsyncIterator[LegacyContactType]: """ Populate slidge's "virtual roster". This should yield contacts that are meant to be added to the user's roster, typically by using ``await self.by_legacy_id(contact_id)``. Setting the contact nicknames, avatar, etc. should be in :meth:`LegacyContact.update_info()` It's not mandatory to override this method, but it is recommended way to populate "friends" of the user. Calling ``await (await self.by_legacy_id(contact_id)).add_to_roster()`` accomplishes the same thing, but doing it in here allows to batch DB queries and is better performance-wise. """ return yield log = logging.getLogger(__name__) slidge/slidge/core/000077500000000000000000000000001522535037600145345ustar00rootroot00000000000000slidge/slidge/core/__init__.py000066400000000000000000000001041522535037600166400ustar00rootroot00000000000000from .pubsub import PubSubComponent __all__ = ("PubSubComponent",) slidge/slidge/core/config.py000066400000000000000000000201661522535037600163600ustar00rootroot00000000000000from pathlib import Path from slixmpp import JID as JIDType # REQUIRED, so not default value class _Categories: MANDATORY = (0, "Mandatory settings") BASE = (10, "Basic configuration") ATTACHMENTS = (20, "Attachments") LOG = (30, "Logging") ADVANCED = (40, "Advanced settings") LEGACY_MODULE: str LEGACY_MODULE__DOC = ( "Importable python module containing (at least) a BaseGateway and a LegacySession subclass. " "NB: this is not needed if you use a gateway-specific entrypoint, e.g., `slidgram` or " "`python -m slidgram`." ) LEGACY_MODULE__CATEGORY = _Categories.BASE SERVER: str = "localhost" SERVER__DOC = ( "The XMPP server's host name. Defaults to localhost, which is the " "standard way of running slidge, on the same host as the XMPP server. " "The 'Jabber Component Protocol' (XEP-0114) does not mention encryption, " "so you *should* provide encryption another way, eg via port forwarding, if " "you change this." ) SERVER__SHORT = "s" SERVER__CATEGORY = _Categories.BASE SECRET: str SECRET__DOC = "The gateway component's secret (required to connect to the XMPP server)" SECRET__CATEGORY = _Categories.MANDATORY JID: JIDType JID__DOC = "The gateway component's JID" JID__SHORT = "j" JID__CATEGORY = _Categories.MANDATORY PORT: str = "5347" PORT__DOC = "The XMPP server's port for incoming component connections" PORT__SHORT = "p" PORT__CATEGORY = _Categories.BASE # Dynamic default (depends on other values) HOME_DIR: Path HOME_DIR__DOC = ( "Directory where slidge will writes it persistent data and cache. " "Defaults to /var/lib/slidge/${SLIDGE_JID}. " ) HOME_DIR__DYNAMIC_DEFAULT = True HOME_DIR__CATEGORY = _Categories.BASE DB_URL: str DB_URL__DOC = ( "Database URL, see . " "Defaults to sqlite:///${HOME_DIR}/slidge.sqlite" ) DB_URL__DYNAMIC_DEFAULT = True DB_URL__CATEGORY = _Categories.ADVANCED USER_JID_VALIDATOR: str USER_JID_VALIDATOR__DOC = ( "Regular expression to restrict users that can register to the gateway, by JID. " "Defaults to .*@${INFERRED_SERVER}. INFERRED_SERVER is derived for the gateway JID, " "by removing whatever is before the first encountered dot in it. Example: if " "slidge's JID=slidge.example.org, INFERRED_SERVER=example.org." ) USER_JID_VALIDATOR__DYNAMIC_DEFAULT = True USER_JID_VALIDATOR__CATEGORY = _Categories.BASE # Optional, so default value + type hint if default is None ADMINS: tuple[JIDType, ...] = () ADMINS__DOC = "JIDs of the gateway admins" ADMINS__CATEGORY = _Categories.BASE UPLOAD_SERVICE: str | None = None UPLOAD_SERVICE__DOC = ( "JID of an HTTP upload service the gateway can use. " "This is optional, as it should be automatically determined via service" "discovery." ) UPLOAD_SERVICE__CATEGORY = _Categories.ATTACHMENTS AVATAR_SIZE = 200 AVATAR_SIZE__DOC = ( "Maximum image size (width and height), image ratio will be preserved" ) AVATAR_SIZE__CATEGORY = _Categories.ADVANCED USE_ATTACHMENT_ORIGINAL_URLS: bool = False USE_ATTACHMENT_ORIGINAL_URLS__DOC = ( "For legacy plugins in which attachments are publicly downloadable URLs, " "let XMPP clients directly download them from this URL. Note that this will " "probably leak your client IP to the legacy network." ) USE_ATTACHMENT_ORIGINAL_URLS__CATEGORY = _Categories.ATTACHMENTS UPLOAD_REQUESTER: str | None = None UPLOAD_REQUESTER__DOC = ( "Set which JID should request the upload slots. Defaults to the user's JID if " "IQ/get privileges granted for the 'urn:xmpp:http:upload:0' namespace; the component " "JID otherwise." ) UPLOAD_REQUESTER__CATEGORY = _Categories.ATTACHMENTS UPLOAD_URL_PREFIX: str | None = None UPLOAD_URL_PREFIX__DOC = ( "This is an optional setting to make sure the URL of your upload service is never leaked " "to the legacy network in bodies of messages. This can happen under rare circumstances and/or bugs," "when replying to an attachment. Set this to the common prefix of the public URL your attachments get, " "eg https://upload.example.org:5281/" ) UPLOAD_URL_PREFIX__CATEGORY = _Categories.ATTACHMENTS NO_UPLOAD_PATH: str | None = None NO_UPLOAD_PATH__DOC = ( "Instead of using the XMPP server's HTTP upload component, copy files to this dir. " "You need to set NO_UPLOAD_URL_PREFIX too if you use this option, and configure " "an web server to serve files in this dir." ) NO_UPLOAD_PATH__CATEGORY = _Categories.ATTACHMENTS NO_UPLOAD_URL_PREFIX: str | None = None NO_UPLOAD_URL_PREFIX__DOC = ( "Base URL that servers files in the dir set in the no-upload-path option, " "eg https://example.com:666/slidge-attachments/" ) NO_UPLOAD_URL_PREFIX__CATEGORY = _Categories.ATTACHMENTS NO_UPLOAD_FILE_READ_OTHERS: bool = False NO_UPLOAD_FILE_READ_OTHERS__DOC = ( "After writing a file in NO_UPLOAD_PATH, change its permission so that 'others' can" " read it." ) NO_UPLOAD_FILE_READ_OTHERS__CATEGORY = _Categories.ATTACHMENTS IGNORE_DELAY_THRESHOLD = 300 IGNORE_DELAY_THRESHOLD__DOC = ( "Threshold, in seconds, below which the information is stripped " "out of emitted stanzas." ) IGNORE_DELAY_THRESHOLD__CATEGORY = _Categories.ADVANCED PARTIAL_REGISTRATION_TIMEOUT = 3600 PARTIAL_REGISTRATION_TIMEOUT__DOC = ( "Timeout before registration and login. Only useful for legacy networks where " "a single step registration process is not enough." ) PARTIAL_REGISTRATION_TIMEOUT__CATEGORY = _Categories.ADVANCED QR_TIMEOUT = 60 QR_TIMEOUT__DOC = "Timeout for QR code flashing confirmation." QR_TIMEOUT__CATEGORY = _Categories.ADVANCED FIX_FILENAME_SUFFIX_MIME_TYPE: bool = False FIX_FILENAME_SUFFIX_MIME_TYPE__DOC = ( "Fix the Filename suffix based on the Mime Type of the file. Some clients (eg" " Conversations) may not inline files that have a wrong suffix for the MIME Type." " Therefore the MIME Type of the file is checked, if the suffix is not valid for" " that MIME Type, a valid one will be picked." ) FIX_FILENAME_SUFFIX_MIME_TYPE__CATEGORY = _Categories.ATTACHMENTS LOG_FILE: Path | None = None LOG_FILE__DOC = "Log to a file instead of stdout/err" LOG_FILE__CATEGORY = _Categories.LOG LOG_FORMAT: str = "%(levelname)s:%(name)s:%(message)s" LOG_FORMAT__DOC = ( "Optionally, a format string for logging messages. Refer to " "https://docs.python.org/3/library/logging.html#logrecord-attributes " "for available options." ) LOG_FORMAT__CATEGORY = _Categories.LOG MAM_MAX_DAYS = 7 MAM_MAX_DAYS__DOC = "Maximum number of days for group archive retention." MAM_MAX_DAYS__CATEGORY = _Categories.BASE ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH = 200 ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH__DOC = ( "Some legacy network provide ridiculously long filenames, strip above this limit, " "preserving suffix." ) ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH__CATEGORY = _Categories.ATTACHMENTS CONVERT_STICKERS: bool = False CONVERT_STICKERS__DOC = ( "Convert lottie vector stickers (from the legacy side) to webp animations." ) CONVERT_STICKERS__CATEGORY = _Categories.ATTACHMENTS AVATAR_RESAMPLING_THREADS = 2 AVATAR_RESAMPLING_THREADS__DOC = ( "Number of additional threads to use for avatar resampling. Even in a single-core " "context, this makes avatar resampling non-blocking." ) AVATAR_RESAMPLING_THREADS__CATEGORY = _Categories.ADVANCED DEV_MODE = False DEV_MODE__DOC = ( "Enables an interactive python shell via chat commands, for admins." "Not safe to use in prod, but great during dev." ) DEV_MODE__CATEGORY = _Categories.ADVANCED STRIP_LEADING_EMOJI_ADHOC = False STRIP_LEADING_EMOJI_ADHOC__DOC = ( "Strip the leading emoji in ad-hoc command names, if present, in case you " "are a emoji-hater." ) STRIP_LEADING_EMOJI_ADHOC__CATEGORY = _Categories.ADVANCED COMPONENT_NAME: str | None = None COMPONENT_NAME__DOC = ( "Overrides the default component name with a custom one. This is seen in service discovery and as the nickname " "of the component in chat windows." ) COMPONENT_NAME__CATEGORY = _Categories.ADVANCED WELCOME_MESSAGE: str | None = None WELCOME_MESSAGE__DOC = ( "Overrides the default welcome message received by newly registered users." ) WELCOME_MESSAGE__CATEGORY = _Categories.ADVANCED slidge/slidge/core/dispatcher/000077500000000000000000000000001522535037600166625ustar00rootroot00000000000000slidge/slidge/core/dispatcher/__init__.py000066400000000000000000000001241522535037600207700ustar00rootroot00000000000000from .session_dispatcher import SessionDispatcher __all__ = ("SessionDispatcher",) slidge/slidge/core/dispatcher/caps.py000066400000000000000000000042251522535037600201650ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING from slixmpp import Presence from slixmpp.exceptions import XMPPError from slixmpp.xmlstream import StanzaBase from .util import DispatcherMixin if TYPE_CHECKING: from slidge.util.types import AnyGateway class CapsMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.del_filter("out", xmpp.plugin["xep_0115"]._filter_add_caps) xmpp.add_filter("out", self._filter_add_caps) async def _filter_add_caps(self, stanza: StanzaBase) -> StanzaBase: # we rolled our own "add caps on presences" filter because # there is too much magic happening in slixmpp # anyway, we probably want to roll our own "dynamic disco"/caps # module in the long run, so it's a step in this direction if not isinstance(stanza, Presence): return stanza if "caps" in stanza: return stanza if stanza["type"] not in ("available", "chat", "away", "dnd", "xa"): return stanza pfrom = stanza.get_from() caps = self.xmpp.plugin["xep_0115"] if pfrom != self.xmpp.boundjid.bare: try: session = self.xmpp.get_session_from_jid(stanza.get_to()) except XMPPError: log.debug("not adding caps 1") return stanza if session is None: return stanza await session.ready try: contact = await session.contacts.by_jid(pfrom) except XMPPError: return stanza if contact.stored.caps_ver: ver = contact.stored.caps_ver else: ver = await contact.get_caps_ver(pfrom) contact.update_stored_attribute(caps_ver=ver) else: ver = await caps.get_verstring(pfrom) log.debug("Ver: %s", ver) if ver: stanza["caps"]["node"] = caps.caps_node stanza["caps"]["hash"] = caps.hash stanza["caps"]["ver"] = ver return stanza log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/disco.py000066400000000000000000000153531522535037600203440ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING, Any import sqlalchemy as sa import sqlalchemy.orm from slixmpp import JID from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0004.stanza import Form from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo from slixmpp.plugins.xep_0030.stanza.items import DiscoItems from slixmpp.types import OptJid from ...db.models import Room, Space from ...util.types import AnySession from .util import DispatcherMixin if TYPE_CHECKING: from slidge.util.types import AnyGateway, AnySession class DiscoMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.plugin["xep_0030"].set_node_handler( "get_info", jid=None, node=None, handler=self.get_info, ) xmpp.plugin["xep_0030"].set_node_handler( "get_items", jid=None, node=None, handler=self.get_items, ) async def get_info( self, jid: OptJid, node: str | None, ifrom: OptJid, data: Any, # noqa:ANN401 ) -> DiscoInfo | None: # TODO: OMG the control flow in this method 🤮 if ifrom == self.xmpp.boundjid.bare or ( jid in (self.xmpp.boundjid.bare, None) and (not node or node.startswith("http://slixmpp.com/ver/")) ): return self.xmpp.plugin["xep_0030"].static.get_info(jid, node, ifrom, data) if ifrom is None: raise XMPPError("subscription-required") assert jid is not None session = await self._get_session_from_jid(jid=ifrom) if not jid.username: assert node is not None # 🤮 return await self.__spaces_info(session, node) log.debug("Looking for entity: %s", jid) entity = await session.get_contact_or_group_or_participant(jid) if entity is None: raise XMPPError("item-not-found") return await entity.get_disco_info(jid, node) async def __spaces_info(self, session: AnySession, node: str) -> DiscoInfo: legacy_id = await session.bookmarks.space_node_to_legacy_id(node) with self.xmpp.store.session(expire_on_commit=False) as orm: space = self.xmpp.store.spaces.get_by_legacy_id( orm, session.user_pk, legacy_id, full=True ) if space is None: raise XMPPError("item-not-found", f"No space for node '{node}'") space = await session.bookmarks.update_space_if_needed(space) form = Form() form["type"] = "result" form.add_field( var="FORM_TYPE", ftype="hidden", value="http://jabber.org/protocol/pubsub#meta-data", ) form.add_field( var="pubsub#type", value="urn:xmpp:spaces:0", ) form.add_field( var="pubsub#creator", type="jid-single", value=self.xmpp.boundjid.bare if space.creator is None else str(space.creator.jid), ) form.add_field(var="pubsub#title", value=space.name) form.add_field( var="pubsub#owner", type="jid-multi", value=[str(owner.jid) for owner in space.owners], ) if space.member_count is not None: form.add_field(var="pubsub#num_subscribers", value=str(space.member_count)) if space.description is not None: form.add_field(var="pubsub#description", value=space.description) info = DiscoInfo() info.add_identity(category="pubsub", itype="leaf") info.add_feature("http://jabber.org/protocol/pubsub") for feat in ( "meta-data", "item-ids", "manage-subscriptions", "modify-affiliations", "outcast-affiliation", "retract-items", "retrieve-affiliations", "retrieve-items", "retrieve-subscriptions", "subscribe", "subscription-notifications", ): info.add_feature(f"http://jabber.org/protocol/pubsub#{feat}") info.append(form) return info async def get_items( self, jid: OptJid, node: str | None, ifrom: OptJid, data: Any, # noqa:ANN401 ) -> DiscoItems: if ifrom is None: raise XMPPError("bad-request") assert ifrom is not None session = await self._get_session_from_jid(ifrom) if jid == self.xmpp.boundjid.bare or not jid: if node: return DiscoItems() else: return await self.__list_rooms(session, data) entity = await session.get_contact_or_group_or_participant(jid) if entity is None: raise XMPPError("item-not-found") return await entity.get_disco_items(node) async def __list_rooms( self, session: "AnySession", data: dict, # type:ignore[type-arg] ) -> DiscoItems: try: included_types = data["disco_items"]["filter"]["included_types"] except KeyError: included_types = [] d = DiscoItems() with self.xmpp.store.session() as orm: if included_types == ["urn:xmpp:spaces:0"]: if not self.xmpp.SPACES: raise XMPPError( "feature-not-implemented", "This gateway does not support XEP-0503", ) await self.__spaces(orm, session, d) elif not included_types: self.__rooms(orm, session, d) else: raise XMPPError("item-not-found", f"No items for {included_types}") return d async def __spaces( self, orm: sqlalchemy.orm.Session, session: AnySession, d: DiscoItems ) -> None: await session.bookmarks.update_spaces_info() for space in orm.execute( sa.select(Space) .options(sa.orm.load_only(Space.legacy_id, Space.name)) .filter_by(user=session.user) ).scalars(): d.add_item( JID(self.xmpp.boundjid.bare), name=space.name, node=await session.bookmarks.space_legacy_id_to_node(space.legacy_id), ) @staticmethod def __rooms( orm: sqlalchemy.orm.Session, session: AnySession, d: DiscoItems ) -> None: for room in orm.execute( sa.select(Room) .options(sa.orm.load_only(Room.jid, Room.name)) .filter_by(user=session.user) .order_by(Room.name) ).scalars(): d.add_item(room.jid, name=room.name) log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/message/000077500000000000000000000000001522535037600203065ustar00rootroot00000000000000slidge/slidge/core/dispatcher/message/__init__.py000066400000000000000000000003641522535037600224220ustar00rootroot00000000000000from .chat_state import ChatStateMixin from .marker import MarkerMixin from .message import MessageContentMixin class MessageMixin(ChatStateMixin, MarkerMixin, MessageContentMixin): __slots__: list[str] = [] __all__ = ("MessageMixin",) slidge/slidge/core/dispatcher/message/chat_state.py000066400000000000000000000014731522535037600230040ustar00rootroot00000000000000from typing import TYPE_CHECKING from slixmpp import Message from slixmpp.xmlstream import StanzaBase from ..util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from slidge.util.types import AnyGateway class ChatStateMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.add_event_handler("chatstate", self.on_chatstate) @exceptions_to_xmpp_errors async def on_chatstate(self, msg: StanzaBase) -> None: assert isinstance(msg, Message) if msg["body"]: # if there is a body, it's handled in on_legacy_message() return recipient, thread = await self._get_recipient_and_thread(msg) await recipient.on_chat_state(msg["chat_state"], thread) slidge/slidge/core/dispatcher/message/marker.py000066400000000000000000000050071522535037600221430ustar00rootroot00000000000000from typing import TYPE_CHECKING from slixmpp import JID, Message from slixmpp.xmlstream import StanzaBase from ....group.room import LegacyMUC from ....util.types import AnyRecipient from ..util import DispatcherMixin, exceptions_to_xmpp_errors, get_recipient if TYPE_CHECKING: from slidge.util.types import AnyGateway, AnyRecipient class MarkerMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.add_event_handler("marker_displayed", self.on_marker_displayed) xmpp.add_event_handler( "message_displayed_synchronization_publish", self.on_message_displayed_synchronization_publish, ) @exceptions_to_xmpp_errors async def on_marker_displayed(self, msg: StanzaBase) -> None: assert isinstance(msg, Message) session = await self._get_session(msg) recipient = await get_recipient(session, msg) legacy_thread = await self._xmpp_to_legacy_thread(session, msg, recipient) to_mark = self.__to_mark(recipient, msg["displayed"]["id"]) for xmpp_id in to_mark: await recipient.on_displayed( self._xmpp_msg_id_to_legacy(xmpp_id, recipient), legacy_thread ) if isinstance(recipient, LegacyMUC): await recipient.echo(msg, None) def __to_mark(self, recipient: AnyRecipient, msg_id: str) -> list[str]: if self.xmpp.MARK_ALL_MESSAGES: return recipient.pop_unread_xmpp_ids_up_to(msg_id) else: return [msg_id] @exceptions_to_xmpp_errors async def on_message_displayed_synchronization_publish( self, msg: StanzaBase ) -> None: assert isinstance(msg, Message) chat_jid = JID(msg["pubsub_event"]["items"]["item"]["id"]) if chat_jid.server != self.xmpp.boundjid.bare: return session = await self._get_session(msg, timeout=None) if chat_jid == self.xmpp.boundjid.bare: return recipient = await session.get_contact_or_group_or_participant(chat_jid) if not isinstance(recipient, LegacyMUC): session.log.debug("Ignoring non-groupchat MDS event") return stanza_id = msg["pubsub_event"]["items"]["item"]["displayed"]["stanza_id"]["id"] to_mark = self.__to_mark(recipient, stanza_id) for xmpp_id in to_mark: await recipient.on_displayed( self._xmpp_msg_id_to_legacy(xmpp_id, recipient), None ) slidge/slidge/core/dispatcher/message/message.py000066400000000000000000000453771522535037600223240ustar00rootroot00000000000000import base64 import hashlib import logging from copy import copy from dataclasses import dataclass from typing import TYPE_CHECKING from xml.etree import ElementTree from slixmpp import JID, Message from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0511.stanza import LinkMetadata from ....contact.contact import LegacyContact from ....group.room import LegacyMUC from ....util.types import ( AnyRecipient, LinkPreview, RecipientType, Reply, XMPPAttachment, XMPPAttachmentMessage, XMPPMessage, XMPPTextMessage, ) from ....util.util import dict_to_named_tuple, remove_emoji_variation_selector_16 from ... import config from ..util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from slidge.util.types import AnyGateway @dataclass class _IncomingAttachment: attachment: XMPPAttachment is_sticker: bool cid: str | None = None class MessageContentMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.add_event_handler("legacy_message", self.on_legacy_message) xmpp.add_event_handler("message_retract", self.on_message_retract) xmpp.add_event_handler("groupchat_message", self.on_groupchat_message) xmpp.add_event_handler("reactions", self.on_reactions) async def on_groupchat_message(self, msg: Message) -> None: await self.on_legacy_message(msg) @exceptions_to_xmpp_errors async def on_legacy_message(self, msg: Message) -> None: """ Meant to be called from :class:`BaseGateway` only. :param msg: :return: """ # we MUST not use `if m["replace"]["id"]` because it adds the tag if not # present. this is a problem for MUC echoed messages if "apply_to" in msg: # ignore message retraction (handled by a specific method) # this is an old version of the protocol that depends on the # message fastening deprecated xep. return if "reactions" in msg: # ignore message reaction fallback. # the reaction itself is handled by self.react_from_msg(). return if "retract" in msg: # ignore message retraction fallback. # the retraction itself is handled by self.on_retract return recipient, thread = await self._get_recipient_and_thread(msg) replace = await self.__get_replace(msg, recipient) if msg.xml.find(".//{*}encrypted") is not None: raise XMPPError( "bad-request", "You cannot send encrypted messages through this gateway" ) body, reply = await self.__get_reply(msg, recipient) cid = self.__get_xhtml_sticker_cid(msg) if cid: legacy_msg_id = await self.__dispatch_bob( msg.get_from(), cid, recipient, reply=reply, thread=thread, ) else: attachments = self.__get_attachments(msg) if len(attachments) == 1 and attachments[0].is_sticker: legacy_msg_id = await self.__dispatch_nonbob_sticker( attachments[0], recipient, msg["body"], reply=reply, thread=thread, ) else: legacy_msg_id = await self.__dispatch_msg( replace=replace, body=body, attachments=tuple(a.attachment for a in attachments), recipient=recipient, thread=thread, reply=reply, msg=msg, ) if isinstance(recipient, LegacyMUC): stanza_id = await recipient.echo(msg, legacy_msg_id) else: stanza_id = None self.__ack(msg) if not legacy_msg_id: return with self.xmpp.store.session() as orm: if recipient.is_group: self.xmpp.store.id_map.set_origin( orm, recipient.stored.id, legacy_msg_id, msg.get_id() ) assert stanza_id is not None self.xmpp.store.id_map.set_msg( orm, recipient.stored.id, legacy_msg_id, [stanza_id], True, ) else: self.xmpp.store.id_map.set_msg( orm, recipient.stored.id, legacy_msg_id, [msg.get_id()], False, ) if recipient.session.MESSAGE_IDS_ARE_THREAD_IDS and thread: self.xmpp.store.id_map.set_thread( orm, recipient.stored.id, thread, legacy_msg_id, recipient.is_group ) orm.commit() def __get_xhtml_sticker_cid(self, msg: Message) -> str | None: if "html" not in msg: return None body = ElementTree.fromstring("" + msg["html"].get_body() + "") p = body.findall("p") if p is None: return None if len(p) != 1: return None if p[0].text is not None and p[0].text.strip(): return None images = p[0].findall("img") if len(images) != 1: return None # no text, single img ⇒ this is a sticker # other cases should be interpreted as "custom emojis" in text src = images[0].get("src") if src is None: return None if src.startswith("cid:"): return src.removeprefix("cid:") return None async def __get_replace( self, msg: Message, recipient: RecipientType, ) -> str | None: if "replace" not in msg or "id" not in msg["replace"]: return None return ( self._xmpp_msg_id_to_legacy(msg["replace"]["id"], recipient, True) or None ) def __get_attachments(self, msg: Message) -> list[_IncomingAttachment]: is_sticker = "sticker" in msg if ( "sfs" in msg and "sources" in msg["sfs"] and "url-data" in msg["sfs"]["sources"] and "target" in msg["sfs"]["sources"]["url-data"] ): # TODO: support "attach source in later message", cf https://xmpp.org/extensions/xep-0447.html#example-5 # TODO: support for other sources than URL # TODO: support for multiattachments in single message. # What do we do if is_sticker and multiple files? attachment = _IncomingAttachment( XMPPAttachment(url=msg["sfs"]["sources"]["url-data"]["target"]), is_sticker=is_sticker, ) if "file" in msg["sfs"]: attachment.attachment.content_type = ( msg["sfs"]["file"]["media-type"] or None ) if "hash" in msg["sfs"]["file"]: algo = msg["sfs"]["file"]["hash"]["algo"] h = msg["sfs"]["file"]["hash"]["value"] attachment.cid = f"{algo}+{h}" if algo and h else None return [attachment] if "oob" in msg: return [ _IncomingAttachment( XMPPAttachment(url=msg["oob"]["url"]), is_sticker=is_sticker ) ] if ( "reference" in msg and "sims" in msg["reference"] and "sources" in msg["reference"]["sims"] ): for source in msg["reference"]["sims"]["sources"]["substanzas"]: if source["uri"].startswith("http"): attachment = _IncomingAttachment( XMPPAttachment(url=source["uri"]), is_sticker=is_sticker ) break else: return [] if "file" in msg["reference"]["sims"]: attachment.attachment.content_type = msg["media-type"] or None return [attachment] return [] async def __dispatch_msg( self, *, replace: str | None, reply: Reply | None, body: str | None, attachments: tuple[XMPPAttachment, ...], recipient: AnyRecipient, thread: str | None, msg: Message, ) -> str | None: if replace: if body is not None: body = body.strip() if not body and not attachments and recipient.RETRACTION: await recipient.on_retract(replace, thread=thread) return None if not recipient.CORRECTION: if recipient.RETRACTION: await recipient.on_retract(replace, thread=thread) replace = None elif body: body = "Correction:\n" + body if not any([attachments, body]): log.debug( "Ignoring msg with id '%s' because no body or attachments found", msg.get_id(), ) return None link_previews = ( parse_link_previews(msg["link_metadatas"]) if "link_metadata" in msg else () ) for attachment in attachments: if not body: body = None break body = body.replace(attachment.url, "").strip() if recipient.is_group: mentions = tuple(await recipient.parse_mentions(body)) else: mentions = () if attachments: xmpp_msg: XMPPMessage = XMPPAttachmentMessage( body=body, attachments=attachments, reply=reply, thread=thread, link_previews=link_previews, mentions=mentions, replace=replace, ) else: xmpp_msg = XMPPTextMessage( body=body, attachments=attachments, reply=reply, thread=thread, link_previews=link_previews, mentions=mentions, replace=replace, ) return await recipient.on_message(xmpp_msg) @exceptions_to_xmpp_errors async def on_message_retract(self, msg: Message) -> None: recipient, thread = await self._get_recipient_and_thread(msg) if not recipient.RETRACTION: raise XMPPError( "bad-request", "This legacy service does not support message retraction.", ) xmpp_id: str = msg["retract"]["id"] legacy_id = self._xmpp_msg_id_to_legacy(xmpp_id, recipient, origin=True) await recipient.on_retract(legacy_id, thread=thread) if isinstance(recipient, LegacyMUC): await recipient.echo(msg, None) self.__ack(msg) @exceptions_to_xmpp_errors async def on_reactions(self, msg: Message) -> None: recipient, thread = await self._get_recipient_and_thread(msg) react_to: str = msg["reactions"]["id"] special_msg = recipient.session.SPECIAL_MSG_ID_PREFIX and react_to.startswith( recipient.session.SPECIAL_MSG_ID_PREFIX ) if special_msg: legacy_id = react_to else: legacy_id = self._xmpp_msg_id_to_legacy(react_to, recipient) if not legacy_id: log.debug("Ignored reaction from user") raise XMPPError( "internal-server-error", "Could not convert the XMPP msg ID to a legacy ID", ) emojis = [ remove_emoji_variation_selector_16(r["value"]) for r in msg["reactions"] ] error_msg = None recipient = recipient if not special_msg: if recipient.REACTIONS_SINGLE_EMOJI and len(emojis) > 1: error_msg = "Maximum 1 emoji/message" if ( not error_msg and (subset := await recipient.available_emojis(legacy_id)) and not set(emojis).issubset(subset) ): error_msg = ( f"You can only react with the following emojis: {''.join(subset)}" ) if error_msg: recipient.session.send_gateway_message(error_msg) if not isinstance(recipient, LegacyMUC): # no need to carbon for groups, we just don't echo the stanza recipient.react(legacy_id, carbon=True) await recipient.on_react(legacy_id, [], thread=thread) raise XMPPError( "policy-violation", text=error_msg, clear=False, ) await recipient.on_react(legacy_id, emojis, thread=thread) if isinstance(recipient, LegacyMUC): await recipient.echo(msg, None) else: self.__ack(msg) with self.xmpp.store.session() as orm: multi = self.xmpp.store.id_map.get_xmpp( orm, recipient.stored.id, legacy_id, recipient.is_group ) if not multi: return multi = [m for m in multi if react_to != m] if isinstance(recipient, LegacyMUC): for xmpp_id in multi: mc = copy(msg) mc["reactions"]["id"] = xmpp_id await recipient.echo(mc) elif isinstance(recipient, LegacyContact): for xmpp_id in multi: recipient.react(legacy_id, emojis, xmpp_id=xmpp_id, carbon=True) def __ack(self, msg: Message) -> None: if not self.xmpp.PROPER_RECEIPTS: self.xmpp.delivery_receipt.ack(msg) async def __get_reply( self, msg: Message, recipient: AnyRecipient ) -> tuple[str, Reply | None]: if "reply" not in msg: return msg["body"], None session = recipient.session try: reply_to_msg_id = self._xmpp_msg_id_to_legacy(msg["reply"]["id"], recipient) except XMPPError: session.log.debug( "Could not determine reply-to legacy msg ID, sending quote instead." ) return redact_url(msg["body"]), None reply_to_jid = JID(msg["reply"]["to"]) reply_to = None if msg["type"] == "chat": if reply_to_jid.bare != session.user_jid.bare: try: reply_to = await session.contacts.by_jid(reply_to_jid) except XMPPError: session.log.exception("Could not instantiate replied-to contact") elif msg["type"] == "groupchat": nick = reply_to_jid.resource try: muc = await session.bookmarks.by_jid(reply_to_jid) except XMPPError: session.log.exception("Could not instantiate replied-to participant") else: if nick == muc.user_nick: reply_to = await muc.get_user_participant() elif not nick: reply_to = muc.get_system_participant() else: reply_to = await muc.get_participant(nick, store=False) if "fallback" in msg and ( isinstance(recipient, LegacyMUC) or recipient.REPLIES ): text = msg["fallback"].get_stripped_body( self.xmpp.plugin["xep_0461"].namespace ) try: reply_fallback = redact_url(msg["reply"].get_fallback_body()) except AttributeError: reply_fallback = None else: text = msg["body"] reply_fallback = None return text, Reply(reply_to_msg_id, reply_to, reply_fallback) async def __dispatch_nonbob_sticker( self, attachment: _IncomingAttachment, recipient: AnyRecipient, fallback: str, reply: Reply | None = None, thread: str | None = None, ) -> str | None: if attachment.cid: with self.xmpp.store.session() as orm: sticker = self.xmpp.store.bob.get_sticker(orm, attachment.cid) else: sticker = None if sticker is None: async with attachment.attachment.get() as response: response.raise_for_status() data = await response.read() cid = "sha256+" + hashlib.sha256(data).hexdigest() with self.xmpp.store.session() as orm: sticker = self.xmpp.store.bob.set_sticker( orm, cid, data, attachment.attachment.content_type ) orm.commit() if sticker is None: return await recipient.on_message( XMPPTextMessage( body="\n".join([attachment.attachment.url, fallback]), thread=thread, reply=reply, ) ) sticker.reply = reply sticker.thread = thread return await recipient.on_sticker(sticker) async def __dispatch_bob( self, from_: JID, cid: str, recipient: AnyRecipient, reply: Reply | None = None, thread: str | None = None, ) -> str | None: with self.xmpp.store.session() as orm: sticker = self.xmpp.store.bob.get_sticker(orm, cid) if sticker is None: await self.xmpp.plugin["xep_0231"].get_bob( from_, cid, ifrom=self.xmpp.boundjid ) with self.xmpp.store.session() as orm: sticker = self.xmpp.store.bob.get_sticker(orm, cid) assert sticker is not None sticker.reply = reply sticker.thread = thread return await recipient.on_sticker(sticker) def redact_url(text: str) -> str: needle = config.NO_UPLOAD_URL_PREFIX or config.UPLOAD_URL_PREFIX if not needle: return text return text.replace(needle, "") def parse_link_previews(link_metadatas: list[LinkMetadata]) -> tuple[LinkPreview, ...]: result = [] for link_metadata in link_metadatas: preview: LinkPreview = dict_to_named_tuple(link_metadata, LinkPreview) # type:ignore[arg-type] if ( preview.image and isinstance(preview.image, str) and preview.image.startswith("data:image/jpeg;base64,") ): try: image = base64.b64decode( preview.image.removeprefix("data:image/jpeg;base64,") ) except Exception as e: log.warning( "Could not decode base64-encoded image: %s '%s'", e, preview.image ) else: preview = preview._replace(image=image) result.append(preview) return tuple(result) log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/muc/000077500000000000000000000000001522535037600174465ustar00rootroot00000000000000slidge/slidge/core/dispatcher/muc/__init__.py000066400000000000000000000004421522535037600215570ustar00rootroot00000000000000from .admin import MucAdminMixin from .mam import MamMixin from .misc import MucMiscMixin from .owner import MucOwnerMixin from .ping import PingMixin class MucMixin(PingMixin, MamMixin, MucAdminMixin, MucOwnerMixin, MucMiscMixin): __slots__: list[str] = [] __all__ = ("MucMixin",) slidge/slidge/core/dispatcher/muc/admin.py000066400000000000000000000067041522535037600211170ustar00rootroot00000000000000import typing from slixmpp import JID, CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from slixmpp.xmlstream import StanzaBase from ..util import DispatcherMixin, exceptions_to_xmpp_errors if typing.TYPE_CHECKING: from slidge.util.types import AnyGateway class MucAdminMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) self.xmpp.register_handler( CoroutineCallback( "MUCModerate", StanzaPath("iq/moderate"), self.on_user_moderation, ) ) self.xmpp.register_handler( CoroutineCallback( "MUCSetAffiliation", StanzaPath("iq@type=set/mucadmin_query"), self.on_user_set_affiliation, ) ) self.xmpp.register_handler( CoroutineCallback( "MUCGetAffiliation", StanzaPath("iq@type=get/mucadmin_query"), self.on_muc_admin_query_get, ) ) @exceptions_to_xmpp_errors async def on_user_moderation(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) muc = await self.get_muc_from_stanza(iq) moderate = iq["moderate"] xmpp_id = iq["moderate"]["id"] if not xmpp_id: raise XMPPError("bad-request", "Missing moderated message ID") if not moderate["retract"]: raise XMPPError( "feature-not-implemented", "Slidge only implements moderation/retraction", ) legacy_id = self._xmpp_msg_id_to_legacy(xmpp_id, muc) await muc.on_moderate(legacy_id, moderate["reason"] or None) iq.reply(clear=True).send() @exceptions_to_xmpp_errors async def on_user_set_affiliation(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) muc = await self.get_muc_from_stanza(iq) item = iq["mucadmin_query"]["item"] if item["jid"]: contact = await muc.session.contacts.by_jid(JID(item["jid"])) else: part = await muc.get_participant( item["nick"], fill_first=True, create=False ) if part is None: raise XMPPError("item-not-found") assert part.contact is not None contact = part.contact if item["affiliation"]: part = await muc.get_participant_by_contact(contact) await part.on_set_affiliation( item["affiliation"], item["reason"] or None, item["nick"] or None, ) elif item["role"] == "none": part = await muc.get_participant_by_contact(contact) await part.on_kick(item["reason"] or None) iq.reply(clear=True).send() @exceptions_to_xmpp_errors async def on_muc_admin_query_get(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) affiliation = iq["mucadmin_query"]["item"]["affiliation"] if not affiliation: raise XMPPError("bad-request") session = await self._get_session(iq, 1, logged=True) muc = await session.bookmarks.by_jid(iq.get_to()) reply = iq.reply() reply.enable("mucadmin_query") async for participant in muc.get_participants(affiliation): reply["mucadmin_query"].append(participant.mucadmin_item()) reply.send() slidge/slidge/core/dispatcher/muc/mam.py000066400000000000000000000057161522535037600206030ustar00rootroot00000000000000import asyncio from typing import TYPE_CHECKING from slixmpp import CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from slixmpp.xmlstream import StanzaBase from ... import config from ..util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from typing import Any from slidge import BaseGateway class MamMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "BaseGateway[Any]") -> None: super().__init__(xmpp) self.__mam_cleanup_task = xmpp.loop.create_task(self.__mam_cleanup()) # type:ignore[misc] xmpp.register_handler( CoroutineCallback( "MAM_query", StanzaPath("iq@type=set/mam"), self.__handle_mam, ) ) xmpp.register_handler( CoroutineCallback( "MAM_get_from", StanzaPath("iq@type=get/mam"), self.__handle_mam_get_form, ) ) xmpp.register_handler( CoroutineCallback( "MAM_get_meta", StanzaPath("iq@type=get/mam_metadata"), self.__handle_mam_metadata, ) ) async def __mam_cleanup(self) -> None: if not config.MAM_MAX_DAYS: return while True: await asyncio.sleep(3600 * 6) with self.xmpp.store.session() as orm: self.xmpp.store.mam.nuke_older_than(orm, config.MAM_MAX_DAYS) orm.commit() @exceptions_to_xmpp_errors async def __handle_mam(self, iq: Iq) -> None: muc = await self.get_muc_from_stanza(iq) await muc.send_mam(iq) async def __handle_mam_get_form(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) ito = iq.get_to() if ito == self.xmpp.boundjid.bare: raise XMPPError( text="No MAM on the component itself, use a JID with a resource" ) session = await self._get_session(iq, 0, logged=True) await session.bookmarks.by_jid(ito) reply = iq.reply() form = self.xmpp.plugin["xep_0004"].make_form() form.add_field(ftype="hidden", var="FORM_TYPE", value="urn:xmpp:mam:2") form.add_field(ftype="jid-single", var="with") form.add_field(ftype="text-single", var="start") form.add_field(ftype="text-single", var="end") form.add_field(ftype="text-single", var="before-id") form.add_field(ftype="text-single", var="after-id") form.add_field(ftype="boolean", var="include-groupchat") field = form.add_field(ftype="list-multi", var="ids") field["validate"]["datatype"] = "xs:string" field["validate"]["open"] = True reply["mam"].append(form) reply.send() @exceptions_to_xmpp_errors async def __handle_mam_metadata(self, iq: Iq) -> None: muc = await self.get_muc_from_stanza(iq) await muc.send_mam_metadata(iq) slidge/slidge/core/dispatcher/muc/misc.py000066400000000000000000000115361522535037600207610ustar00rootroot00000000000000import logging import typing from slixmpp import ( JID, CoroutineCallback, Iq, MatchXMLMask, Message, Presence, StanzaPath, ) from slixmpp.exceptions import XMPPError from ..util import DispatcherMixin, exceptions_to_xmpp_errors if typing.TYPE_CHECKING: from slidge.util.types import AnyGateway class MucMiscMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.register_handler( CoroutineCallback( "ibr_remove", StanzaPath("/iq/register"), self.on_ibr_remove ) ) xmpp.add_event_handler("groupchat_join", self.on_groupchat_join) xmpp.add_event_handler( "groupchat_direct_invite", self.on_groupchat_direct_invite ) xmpp.add_event_handler("groupchat_subject", self.on_groupchat_subject) xmpp.add_event_handler("groupchat_message_error", self.__on_group_chat_error) xmpp.register_handler( CoroutineCallback( "muc_thread_subject", MatchXMLMask( "" ), self.on_thread_subject, ) ) async def __on_group_chat_error(self, msg: Message) -> None: condition = msg["error"].get_condition() if condition not in KICKABLE_ERRORS: return try: muc = await self.get_muc_from_stanza(msg) except XMPPError as e: log.debug("Not removing resource", exc_info=e) return mfrom = msg.get_from() resource = mfrom.resource try: muc.remove_user_resource(resource) except KeyError: # this actually happens quite frequently on for both beagle and monal # (not sure why?), but is of no consequence log.debug("%s was not in the resources of %s", resource, muc) else: log.debug( "Removed %s from the resources of %s because of error", resource, muc ) @exceptions_to_xmpp_errors async def on_ibr_remove(self, iq: Iq) -> None: if iq.get_to() == self.xmpp.boundjid.bare: return if iq["type"] == "set" and iq["register"]["remove"]: muc = await self.get_muc_from_stanza(iq) await muc.on_leave() iq.reply().send() await muc.session.bookmarks.remove( muc, "You left this chat from an XMPP client." ) return raise XMPPError("feature-not-implemented") @exceptions_to_xmpp_errors async def on_groupchat_join(self, p: Presence) -> None: if not self.xmpp.GROUPS: raise XMPPError( "feature-not-implemented", "This gateway does not implement multi-user chats.", ) muc = await self.get_muc_from_stanza(p) await muc.join(p) @exceptions_to_xmpp_errors async def on_groupchat_direct_invite(self, msg: Message) -> None: invite = msg["groupchat_invite"] jid = JID(invite["jid"]) if jid.domain != self.xmpp.boundjid.bare: raise XMPPError( "bad-request", "Legacy contacts can only be invited to legacy groups, not standard XMPP MUCs.", ) if invite["password"]: raise XMPPError( "bad-request", "Password-protected groups are not supported" ) session = await self._get_session(msg, logged=True) contact = await session.contacts.by_jid(msg.get_to()) muc = await session.bookmarks.by_jid(jid) part = await muc.get_participant_by_contact(contact) await part.on_invitation(invite["reason"] or None) @exceptions_to_xmpp_errors async def on_groupchat_subject(self, msg: Message) -> None: muc = await self.get_muc_from_stanza(msg) if not muc.HAS_SUBJECT: raise XMPPError( "bad-request", "There are no room subject in here. " "Use the room configuration to update its name or description", ) await muc.on_set_subject(msg["subject"]) @exceptions_to_xmpp_errors async def on_thread_subject(self, msg: Message) -> None: if msg["body"]: return muc, thread = await self._get_recipient_and_thread(msg) assert thread is not None await muc.on_set_thread_subject(thread, msg["subject"]) # type:ignore[union-attr] KICKABLE_ERRORS = { "gone", "internal-server-error", "item-not-found", "jid-malformed", "recipient-unavailable", "redirect", "remote-server-not-found", "remote-server-timeout", "service-unavailable", "malformed error", } log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/muc/owner.py000066400000000000000000000066171522535037600211640ustar00rootroot00000000000000import typing from slixmpp import CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0004.stanza.form import Form from slixmpp.xmlstream import StanzaBase from ..util import DispatcherMixin, exceptions_to_xmpp_errors if typing.TYPE_CHECKING: from slidge.util.types import AnyGateway class MucOwnerMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.register_handler( CoroutineCallback( "MUCOwnerGet", StanzaPath("iq@type=get/mucowner_query"), self.on_muc_owner_query, ) ) xmpp.register_handler( CoroutineCallback( "MUCOwnerSet", StanzaPath("iq@type=set/mucowner_query"), self.on_muc_owner_set, ) ) @exceptions_to_xmpp_errors async def on_muc_owner_query(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) muc = await self.get_muc_from_stanza(iq) reply = iq.reply() form = Form(title="Slidge room configuration") form["instructions"] = ( "Complete this form to modify the configuration of your room." ) form.add_field( var="FORM_TYPE", type="hidden", value="http://jabber.org/protocol/muc#roomconfig", ) form.add_field( var="muc#roomconfig_roomname", label="Natural-Language Room Name", type="text-single", value=muc.name, ) if muc.HAS_DESCRIPTION: form.add_field( var="muc#roomconfig_roomdesc", label="Short Description of Room", type="text-single", value=muc.description, ) muc_owner = iq["mucowner_query"] muc_owner.append(form) reply.append(muc_owner) reply.send() @exceptions_to_xmpp_errors async def on_muc_owner_set(self, iq: StanzaBase) -> None: assert isinstance(iq, Iq) muc = await self.get_muc_from_stanza(iq) query = iq["mucowner_query"] if form := query.get_plugin("form", check=True): values = form.get_values() await muc.on_set_config( name=values.get("muc#roomconfig_roomname"), description=( values.get("muc#roomconfig_roomdesc") if muc.HAS_DESCRIPTION else None ), ) form["type"] = "result" clear = False elif destroy := query.get_plugin("destroy", check=True): reason = destroy["reason"] or None await muc.on_destroy_request(reason) user_participant = await muc.get_user_participant() user_participant.stored.affiliation = "none" user_participant.stored.role = "none" presence = user_participant._make_presence(ptype="unavailable", force=True) presence["muc"].enable("destroy") if reason is not None: presence["muc"]["destroy"]["reason"] = reason user_participant._send(presence) await muc.session.bookmarks.remove(muc, kick=False) clear = True else: raise XMPPError("bad-request") iq.reply(clear=clear).send() slidge/slidge/core/dispatcher/muc/ping.py000066400000000000000000000032421522535037600207560ustar00rootroot00000000000000from typing import TYPE_CHECKING from slixmpp import CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from slidge.util.types import AnyMUC from ..util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from slidge.util.types import AnyGateway class PingMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.remove_handler("Ping") xmpp.register_handler( CoroutineCallback( "Ping", StanzaPath("iq@type=get/ping"), self.__handle_ping, ) ) xmpp.plugin["xep_0030"].add_feature("urn:xmpp:ping") @exceptions_to_xmpp_errors async def __handle_ping(self, iq: Iq) -> None: ito = iq.get_to() if ito == self.xmpp.boundjid.bare: iq.reply().send() session = await self._get_session(iq) try: muc = await session.bookmarks.by_jid(ito) except XMPPError: pass else: self.__handle_muc_ping(muc, iq) return try: await session.contacts.by_jid(ito) except XMPPError: pass else: iq.reply().send() return raise XMPPError( "item-not-found", f"This JID does not match anything slidge knows: {ito}" ) @staticmethod def __handle_muc_ping(muc: AnyMUC, iq: Iq) -> None: if iq.get_from().resource in muc.get_user_resources(): iq.reply().send() else: raise XMPPError("not-acceptable", etype="cancel", by=muc.jid) slidge/slidge/core/dispatcher/presence.py000066400000000000000000000163371522535037600210520ustar00rootroot00000000000000import contextlib import logging import typing from slixmpp import JID, Presence from slixmpp.exceptions import XMPPError from ...contact import LegacyContact from ...contact.roster import ContactIsUser from ...util.types import AnySession from ...util.util import merge_resources from .util import DispatcherMixin, exceptions_to_xmpp_errors if typing.TYPE_CHECKING: from slidge.util.types import AnyGateway class _IsDirectedAtComponent(Exception): def __init__(self, session: AnySession) -> None: self.session = session class PresenceHandlerMixin(DispatcherMixin): __slots__: list[str] = [] xmpp: "AnyGateway" def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.add_event_handler("presence_subscribe", self._handle_subscribe) xmpp.add_event_handler("presence_subscribed", self._handle_subscribed) xmpp.add_event_handler("presence_unsubscribe", self._handle_unsubscribe) xmpp.add_event_handler("presence_unsubscribed", self._handle_unsubscribed) xmpp.add_event_handler("presence_probe", self._handle_probe) xmpp.add_event_handler("presence", self.on_presence) async def __get_contact(self, pres: Presence) -> LegacyContact: sess = await self._get_session(pres) pto = pres.get_to() if pto == self.xmpp.boundjid.bare: raise _IsDirectedAtComponent(sess) await sess.contacts.ready return await sess.contacts.by_jid(pto) # type:ignore[no-any-return] @exceptions_to_xmpp_errors async def _handle_subscribe(self, pres: Presence) -> None: try: contact = await self.__get_contact(pres) except _IsDirectedAtComponent: pres.reply().send() return if contact.is_friend: pres.reply().send() else: await contact.on_friend_request(pres["status"]) @exceptions_to_xmpp_errors async def _handle_unsubscribe(self, pres: Presence) -> None: pres.reply().send() try: contact = await self.__get_contact(pres) except _IsDirectedAtComponent as e: e.session.send_gateway_message("Bye bye!") await e.session.kill_by_jid(e.session.user_jid) return contact.is_friend = False await contact.on_friend_delete(pres["status"]) @exceptions_to_xmpp_errors async def _handle_subscribed(self, pres: Presence) -> None: try: contact = await self.__get_contact(pres) except _IsDirectedAtComponent: return await contact.on_friend_accept() contact.send_last_presence(force=True) @exceptions_to_xmpp_errors async def _handle_unsubscribed(self, pres: Presence) -> None: try: contact = await self.__get_contact(pres) except _IsDirectedAtComponent: return if contact.is_friend: contact.is_friend = False await contact.on_friend_delete(pres["status"]) @exceptions_to_xmpp_errors async def _handle_probe(self, pres: Presence) -> None: try: contact = await self.__get_contact(pres) except _IsDirectedAtComponent: session = await self._get_session(pres) session.send_cached_presence(pres.get_from()) return if contact.is_friend: contact.send_last_presence(force=True) else: reply = pres.reply() reply["type"] = "unsubscribed" reply.send() @exceptions_to_xmpp_errors async def on_presence(self, p: Presence) -> None: if "muc_join" in p: # handled in on_groupchat_join # without this early return, since we switch from and to in this # presence stanza, on_groupchat_join ends up trying to instantiate # a MUC with the user's JID, which in turn leads to slidge sending # a (error) presence from=the user's JID, which terminates the # XML stream. return session = await self._get_session(p) pto = p.get_to() if pto == self.xmpp.boundjid.bare: await self._on_presence_to_component(session, p) return if p.get_type() == "available": try: contact = await session.contacts.by_jid(pto) except XMPPError: contact = None except ContactIsUser: raise XMPPError( "bad-request", "Actions with yourself are not supported." ) if contact is not None: await self.xmpp.pubsub.on_presence_available(p, contact) return try: await session.bookmarks.jid_local_part_to_legacy_id(pto.user) except Exception: muc = None else: muc = session.bookmarks.by_jid_only_if_exists(JID(pto.bare)) if muc is not None and p.get_type() == "unavailable": muc.on_presence_unavailable(p) return if muc is None or p.get_from().resource not in muc.get_user_resources(): return if pto.resource == muc.user_nick: # Ignore presence stanzas with the valid nick. # even if joined to the group, we might receive those from clients, # when setting a status message, or going away, etc. return # We can't use XMPPError here because XMPPError does not have a way to # add the element error_stanza = p.error() error_stanza.set_to(p.get_from()) error_stanza.set_from(pto) error_stanza.enable("muc_join") # error_stanza.enable("error") error_stanza["error"]["type"] = "cancel" error_stanza["error"]["by"] = muc.jid error_stanza["error"]["condition"] = "not-acceptable" error_stanza["error"]["text"] = ( "Slidge does not let you change your nickname in groups." ) error_stanza.send() async def _on_presence_to_component(self, session: AnySession, p: Presence) -> None: session.log.debug("Received a presence from %s", p.get_from()) if (ptype := p.get_type()) not in _USEFUL_PRESENCES: return if not session.user.preferences.get("sync_presence", False): session.log.debug("User does not want to sync their presence") return # NB: get_type() returns either a proper presence type or # a presence show if available. Weird, weird, weird slix. resources = self.xmpp.roster[self.xmpp.boundjid.bare][p.get_from()].resources with contextlib.suppress(NotImplementedError): await session.on_presence( p.get_from().resource, ptype, # type: ignore p["status"], resources, merge_resources(resources), ) if p.get_type() == "available": await self.xmpp.pubsub.on_presence_available(p, None) for contact in session.contacts: await self.xmpp.pubsub.on_presence_available(p, contact) _USEFUL_PRESENCES = {"available", "unavailable", "away", "chat", "dnd", "xa"} log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/pubsub.py000066400000000000000000000200701522535037600205330ustar00rootroot00000000000000from slixmpp import CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0060.stanza import Affiliation, Item, Subscription from slixmpp.plugins.xep_0084.stanza import Data as AvatarData from slixmpp.plugins.xep_0084.stanza import MetaData as AvatarMetadata from slidge.db.models import Space from ...util.types import AnyGateway, AnySession from .util import DispatcherMixin, exceptions_to_xmpp_errors class PubSubMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: AnyGateway) -> None: super().__init__(xmpp) for path, func in [ ("get/pubsub/items", self.__get_items), ("set/pubsub/subscribe", self.__set_subscribe), ("set/pubsub/unsubscribe", self.__set_unsubscribe), ("get/pubsub/affiliations", self.__get_affiliations), ("get/pubsub_owner/affiliations", self.__owner_get_affiliations), ("get/pubsub/subscriptions", self.__get_subscriptions), ("get/pubsub_owner/subscriptions", self.__owner_get_subscriptions), ]: self.xmpp.register_handler( CoroutineCallback( func.__name__, StanzaPath(f"iq@to={self.xmpp.boundjid.bare}@type={path}"), func, # type:ignore[arg-type] # ty:ignore[invalid-argument-type] ) ) async def __get_items(self, iq: Iq) -> None: node = iq["pubsub"]["items"]["node"] if node in (AvatarData.namespace, AvatarMetadata.namespace, "urn:xmpp:vcard4"): # handled in slidge/core/pubsub.py # TODO: have a single entrypoint for all get/pubsub/items return session = await self._get_session(iq, logged=True) legacy_id = await self.__get_legacy_id(session, node) item_ids = [item["id"] for item in iq["pubsub"]["items"]["items"]] # TODO: support for avatar and banner items here if not all( item_id.endswith(f"@{self.xmpp.boundjid.bare}") for item_id in item_ids ): raise XMPPError( "item-not-found", "Slidge only supports items that are MUCs.", ) if item_ids: room_legacy_ids = [ str( await session.bookmarks.jid_local_part_to_legacy_id(x.split("@")[0]) ) for x in item_ids ] else: room_legacy_ids = [] with self.xmpp.store.session() as orm: rooms = self.xmpp.store.spaces.get_rooms( orm, session.user_pk, str(legacy_id), room_legacy_ids ) if item_ids and len(rooms) != len(item_ids): raise XMPPError( "item-not-found", f"Could not find items: {set(item_ids) - set(str(r.jid) for r in rooms)}", ) reply = iq.reply() reply["pubsub"]["items"]["node"] = node for room in rooms: item = Item() item["id"] = room.jid item["conference"]["name"] = room.name reply["pubsub"]["items"].append(item) reply.send() async def __get_legacy_id(self, session: AnySession, node: str) -> object: try: legacy_id = await session.bookmarks.space_node_to_legacy_id(node) except Exception as e: raise XMPPError("item-not-found", str(e)) with self.xmpp.store.session() as orm: if not self.xmpp.store.spaces.exists(orm, session.user_pk, str(legacy_id)): raise XMPPError( "item-not-found", f"This is not a known space: '{legacy_id}'" ) return legacy_id async def __set_subscribe(self, iq: Iq) -> None: node = iq["pubsub"]["subscribe"]["node"] session = await self._get_session(iq, logged=True) await self.__get_legacy_id(session, node) reply = iq.reply(clear=True) sub = reply["pubsub"]["subscription"] sub["node"] = node sub["jid"] = session.user_jid sub["subscription"] = "subscribed" reply.send() @exceptions_to_xmpp_errors async def __set_unsubscribe(self, iq: Iq) -> None: session = await self._get_session(iq, logged=True) jid = iq["pubsub"]["unsubscribe"]["jid"] if jid != session.user_jid.bare: raise XMPPError("bad-request", f"Cannot unsubscribe JID {jid}") node = iq["pubsub"]["unsubscribe"]["node"] legacy_id = await session.bookmarks.space_node_to_legacy_id(node) await session.on_leave_space(legacy_id) reply = iq.reply(clear=True) sub = reply["pubsub"]["subscription"] sub["node"] = node sub["jid"] = session.user_jid sub["subscription"] = "none" reply.send() async def __get_affiliations(self, iq: Iq) -> None: session = await self._get_session(iq, logged=True) reply = iq.reply() node = iq["pubsub"]["affiliations"]["node"] if node: legacy_id = await self.__get_legacy_id(session, node) with self.xmpp.store.session() as orm: if not self.xmpp.store.spaces.exists( orm, session.user_pk, str(legacy_id) ): raise XMPPError("item-not-found", f"Space '{legacy_id}' not found") affiliation = reply["pubsub"]["affiliations"]["affiliation"] affiliation["node"] = node affiliation["affiliation"] = "subscriber" else: with self.xmpp.store.session() as orm: spaces = list(self.xmpp.store.spaces.get_all(orm, session.user_pk)) for space in spaces: reply["pubsub"]["affiliations"].append( await self.__make_affiliation(session, space) ) reply.send() @staticmethod async def __make_affiliation(session: AnySession, space: Space) -> Affiliation: affiliation = Affiliation() node = await session.bookmarks.space_legacy_id_to_node(space.legacy_id) affiliation["node"] = node affiliation["affiliation"] = "subscriber" return affiliation async def __owner_get_affiliations(self, iq: Iq) -> None: raise XMPPError( "forbidden", "Slidge does not implement managing space affiliations." ) async def __owner_get_subscriptions(self, iq: Iq) -> None: node = iq["pubsub_owner"]["subscriptions"]["node"] if not node: raise XMPPError("bad-request", "No node was specified") session = await self._get_session(iq, logged=True) await self.__get_legacy_id(session, node) reply = iq.reply(clear=False) sub = reply["pubsub_owner"]["subscriptions"]["subscription"] sub["jid"] = session.user_jid sub["subscription"] = "subscribed" reply.send() async def __get_subscriptions(self, iq: Iq) -> None: _node = iq["pubsub"]["subscriptions"]["node"] session = await self._get_session(iq, logged=True) reply = iq.reply(clear=False) subscriptions = reply["pubsub"]["subscriptions"] if node := iq["pubsub"]["subscriptions"]["node"]: await self.__get_legacy_id(session, node) subscription = subscriptions["subscription"] subscription["node"] = node subscription["jid"] = session.user_jid subscription["subscription"] = "subscribed" else: with self.xmpp.store.session() as orm: spaces = list(self.xmpp.store.spaces.get_all(orm, session.user_pk)) for space in spaces: subscriptions.append(await self.__make_subscription(session, space)) reply.send() @staticmethod async def __make_subscription(session: AnySession, space: Space) -> Subscription: subscription = Subscription() node = await session.bookmarks.space_legacy_id_to_node(space.legacy_id) subscription["node"] = node subscription["jid"] = session.user_jid subscription["subscription"] = "subscribed" return subscription slidge/slidge/core/dispatcher/registration.py000066400000000000000000000101131522535037600217420ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from ...db import GatewayUser from .. import config from .util import DispatcherMixin if TYPE_CHECKING: from slidge.util.types import AnyGateway class RegistrationMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: AnyGateway) -> None: super().__init__(xmpp) xmpp.plugin["xep_0077"].api.register( self.xmpp.make_registration_form, # type:ignore[arg-type] "make_registration_form", ) xmpp.plugin["xep_0077"].api.register(self._user_get, "user_get") # type:ignore[arg-type] xmpp.plugin["xep_0077"].api.register(self._user_validate, "user_validate") # type:ignore[arg-type] xmpp.plugin["xep_0077"].api.register(self._user_modify, "user_modify") # type:ignore[arg-type] # kept for slixmpp internal API compat # TODO: either fully use slixmpp internal API or rewrite registration without it at all xmpp.plugin["xep_0077"].api.register(lambda *a: None, "user_remove") xmpp.add_event_handler("user_register", self._on_user_register) xmpp.add_event_handler("user_unregister", self._on_user_unregister) def get_user(self, jid: JID) -> GatewayUser | None: session = self.xmpp.get_session_from_jid(jid) if session is None: return None return session.user async def _user_get( self, _gateway_jid: JID, _node: str, ifrom: JID, iq: Iq, ) -> GatewayUser | None: if ifrom is None: ifrom = iq.get_from() return self.get_user(ifrom) async def _user_validate( self, _gateway_jid: JID, _node: str, ifrom: JID, iq: Iq, ) -> None: xmpp = self.xmpp log.debug("User validate: %s", ifrom.bare) form_dict = {f.var: iq.get(f.var) for f in xmpp.REGISTRATION_FIELDS} xmpp.raise_if_not_allowed_jid(ifrom) try: legacy_module_data = await xmpp.user_prevalidate(ifrom, form_dict) except ValueError as e: raise XMPPError("bad-request", str(e)) except XMPPError: raise except Exception as e: raise XMPPError("internal-server-error", str(e)) if legacy_module_data is None: legacy_module_data = form_dict with self.xmpp.store.session() as orm: user = GatewayUser( jid=ifrom.bare, legacy_module_data=legacy_module_data, ) orm.add(user) orm.commit() log.info("New user: %s", user) async def _user_modify( self, _gateway_jid: JID, _node: str, ifrom: JID, form_dict: dict[str, str | None], ) -> None: try: await self.xmpp.user_prevalidate(ifrom, form_dict) except ValueError as e: raise XMPPError("bad-request", str(e)) except XMPPError: raise except Exception as e: raise XMPPError("internal-server-error", str(e)) log.debug("Modify user: %s", ifrom) with self.xmpp.store.session() as orm: user = orm.query(GatewayUser).one_or_none() if user is None: raise XMPPError("internal-server-error", "User not found") user.legacy_module_data.update(form_dict) self.xmpp.store.users.update(user) async def _on_user_register(self, iq: Iq) -> None: session = await self._get_session(iq, wait_for_ready=False) for jid in config.ADMINS: self.xmpp.send_message( mto=jid, mbody=f"{iq.get_from()} has registered", mtype="chat", mfrom=self.xmpp.boundjid.bare, ) session.send_gateway_message(self.xmpp.WELCOME_MESSAGE) await self.xmpp.login_wrap(session) async def _on_user_unregister(self, iq: Iq) -> None: await self.xmpp.kill_session(iq.get_from()) log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/search.py000066400000000000000000000066001522535037600205030ustar00rootroot00000000000000from typing import TYPE_CHECKING from slixmpp import JID, CoroutineCallback, Iq, StanzaPath from slixmpp.exceptions import XMPPError from .util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from slidge.util.types import AnyGateway class SearchMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.plugin["xep_0055"].api.register(self.search_get_form, "search_get_form") # type:ignore[arg-type] xmpp.plugin["xep_0055"].api.register(self._search_query, "search_query") # type:ignore[arg-type] xmpp.plugin["xep_0030"].add_feature("jabber:iq:gateway") xmpp.register_handler( CoroutineCallback( "iq:gateway", StanzaPath("iq/gateway"), self._handle_gateway_iq, ) ) async def search_get_form( self, _gateway_jid: JID, _node: str, ifrom: JID, iq: Iq, ) -> Iq: """ Prepare the search form using :attr:`.BaseSession.SEARCH_FIELDS` """ await self._get_session(iq) xmpp = self.xmpp reply = iq.reply() form = reply["search"]["form"] form["title"] = xmpp.SEARCH_TITLE form["instructions"] = xmpp.SEARCH_INSTRUCTIONS for field in xmpp.SEARCH_FIELDS: form.append(field.get_xml()) return reply # type:ignore[no-any-return] async def _search_query( self, _gateway_jid: JID, _node: str, ifrom: JID, iq: Iq, ) -> Iq: """ Handles a search request """ session = await self._get_session(iq) result = await session.on_search(iq["search"]["form"].get_values()) if not result: raise XMPPError("item-not-found", text="Nothing was found") reply = iq.reply() form = reply["search"]["form"] for field in result.fields: form.add_reported(field.var, label=field.label, type=field.type) for item in result.items: form.add_item(item) return reply # type:ignore[no-any-return] @exceptions_to_xmpp_errors async def _handle_gateway_iq(self, iq: Iq) -> None: if iq.get_to() != self.xmpp.boundjid.bare: raise XMPPError("bad-request", "This can only be used on the component JID") if len(self.xmpp.SEARCH_FIELDS) > 1: raise XMPPError( "feature-not-implemented", "Use jabber search for this gateway" ) session = await self._get_session(iq) field = self.xmpp.SEARCH_FIELDS[0] reply = iq.reply() if iq["type"] == "get": reply["gateway"]["desc"] = self.xmpp.SEARCH_TITLE reply["gateway"]["prompt"] = field.label elif iq["type"] == "set": prompt = iq["gateway"]["prompt"] result = await session.on_search({field.var: prompt}) if result is None or not result.items: raise XMPPError( "item-not-found", "No contact was found with the info you provided." ) if len(result.items) > 1: raise XMPPError( "bad-request", "Your search yielded more than one result." ) reply["gateway"]["jid"] = result.items[0]["jid"] reply.send() slidge/slidge/core/dispatcher/session_dispatcher.py000066400000000000000000000071071522535037600231320ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING, Any, Generic, TypeVar from slixmpp import Message from slixmpp.exceptions import IqError, IqTimeout from slixmpp.plugins.xep_0084.stanza import Info from slidge.util.types import AnySession from .caps import CapsMixin from .disco import DiscoMixin from .message import MessageMixin from .muc import MucMixin from .presence import PresenceHandlerMixin from .pubsub import PubSubMixin from .registration import RegistrationMixin from .search import SearchMixin from .util import exceptions_to_xmpp_errors from .vcard import VCardMixin if TYPE_CHECKING: from slidge.core.gateway import BaseGateway GatewayType = TypeVar("GatewayType", bound="BaseGateway[Any]") class SessionDispatcher( CapsMixin, DiscoMixin, RegistrationMixin, MessageMixin, MucMixin, PresenceHandlerMixin, PubSubMixin, SearchMixin, VCardMixin, Generic[GatewayType], ): __slots__: list[str] = ["_MamMixin__mam_cleanup_task", "xmpp"] xmpp: GatewayType def __init__(self, xmpp: GatewayType) -> None: super().__init__(xmpp) xmpp.add_event_handler( "avatar_metadata_publish", self.on_avatar_metadata_publish ) @exceptions_to_xmpp_errors async def on_avatar_metadata_publish(self, m: Message) -> None: session = await self._get_session(m, timeout=None) if not session.user.preferences.get("sync_avatar", False): session.log.debug("User does not want to sync their avatar") return info = m["pubsub_event"]["items"]["item"]["avatar_metadata"]["info"] await self.on_avatar_metadata_info(session, info) async def on_avatar_metadata_info(self, session: AnySession, info: Info) -> None: hash_ = info["id"] if session.user.avatar_hash == hash_: session.log.debug("We already know this avatar hash") return if hash_: try: iq = await self.xmpp.plugin["xep_0084"].retrieve_avatar( session.user_jid, hash_, ifrom=self.xmpp.boundjid.bare ) except (IqError, IqTimeout) as e: session.log.warning("Could not fetch the user's avatar: %s", e) return bytes_ = iq["pubsub"]["items"]["item"]["avatar_data"]["value"] type_ = info["type"] height = info["height"] width = info["width"] else: with self.xmpp.store.session(expire_on_commit=False) as orm: session.user.avatar_hash = None orm.add(session.user) orm.commit() bytes_ = type_ = height = width = hash_ = None try: await session.on_avatar(bytes_, hash_, type_, width, height) except NotImplementedError: pass except Exception as e: # If something goes wrong here, replying an error stanza will to the # avatar update will likely not show in most clients, so let's send # a normal message from the component to the user. session.send_gateway_message( f"Something went wrong trying to set your avatar: {e!r}" ) else: session.user.avatar_hash = hash_ with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(session.user) orm.commit() for room in session.bookmarks: participant = await room.get_user_participant() participant.send_last_presence(force=True, no_cache_online=True) log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/util.py000066400000000000000000000142571522535037600202220ustar00rootroot00000000000000import logging from collections.abc import Awaitable, Callable from functools import wraps from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from slixmpp import JID, Iq, Message, Presence from slixmpp.exceptions import XMPPError from slixmpp.xmlstream import StanzaBase from ...contact.roster import ContactIsUser from ...util.types import ( AnyMUC, AnyRecipient, AnySession, RecipientType, ) if TYPE_CHECKING: from slidge.util.types import AnyGateway class Ignore(BaseException): pass class DispatcherMixin: __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: self.xmpp = xmpp # type:ignore[misc] async def _get_session( self, stanza: Message | Presence | Iq, timeout: int | None = 10, wait_for_ready: bool = True, logged: bool = False, ) -> AnySession: xmpp = self.xmpp if stanza.get_from().server == xmpp.boundjid.bare: log.debug("Ignoring echo") raise Ignore if ( isinstance(stanza, Message) and stanza.get_type() == "chat" and stanza.get_to() == xmpp.boundjid.bare ): log.debug("Ignoring message to component") raise Ignore session = await self._get_session_from_jid( stanza.get_from(), timeout, wait_for_ready, logged ) if isinstance(stanza, Message) and _ignore(session, stanza): raise Ignore return session async def _get_session_from_jid( self, jid: JID, timeout: int | None = 10, wait_for_ready: bool = True, logged: bool = False, ) -> AnySession: session = self.xmpp.get_session_from_jid(jid) if session is None: raise XMPPError("registration-required") if logged: session.raise_if_not_logged() if wait_for_ready: await session.wait_for_ready(timeout) return session async def get_muc_from_stanza(self, iq: Iq | Message | Presence) -> AnyMUC: ito = iq.get_to() if ito == self.xmpp.boundjid.bare: raise XMPPError("bad-request", text="This is only handled for MUCs") session = await self._get_session(iq, logged=True) muc = await session.bookmarks.by_jid(ito) return muc # type:ignore[no-any-return] def _xmpp_msg_id_to_legacy( self, xmpp_id: str, recipient: AnyRecipient, origin: bool = False, ) -> str: with self.xmpp.store.session() as orm: sent = self.xmpp.store.id_map.get_legacy( orm, recipient.stored.id, xmpp_id, recipient.is_group, origin ) if sent is not None: return sent return xmpp_id async def _get_recipient_and_thread( self, msg: Message ) -> tuple[AnyRecipient, str | None]: session = await self._get_session(msg) e: AnyRecipient = await get_recipient(session, msg) legacy_thread = await self._xmpp_to_legacy_thread(session, msg, e) return e, legacy_thread async def _xmpp_to_legacy_thread( self, session: AnySession, msg: Message, recipient: RecipientType ) -> str | None: xmpp_thread = msg["thread"] if not xmpp_thread: return None if session.MESSAGE_IDS_ARE_THREAD_IDS: return self._xmpp_msg_id_to_legacy(xmpp_thread, recipient) with session.xmpp.store.session() as orm: legacy_thread_str = session.xmpp.store.id_map.get_thread( orm, recipient.stored.id, xmpp_thread, recipient.is_group ) if legacy_thread_str is not None: return legacy_thread_str async with session.thread_creation_lock: legacy_thread = await recipient.create_thread(xmpp_thread) with session.xmpp.store.session() as orm: session.xmpp.store.id_map.set_thread( orm, recipient.stored.id, str(legacy_thread), xmpp_thread, recipient.is_group, ) orm.commit() return legacy_thread def _ignore(session: AnySession, msg: Message) -> bool: i = msg.get_id() if i.startswith("slidge-carbon-"): return True if i not in session.ignore_messages: return False session.log.debug("Ignored sent carbon: %s", i) session.ignore_messages.remove(i) return True async def get_recipient(session: AnySession, m: Message) -> AnyRecipient: session.raise_if_not_logged() if m.get_type() == "groupchat": muc = await session.bookmarks.by_jid(m.get_to()) r = m.get_from().resource if r not in muc.get_user_resources(): session.create_task(muc.kick_resource(r)) raise XMPPError("not-acceptable", "You are not connected to this chat") return muc # type:ignore[no-any-return] else: return await session.contacts.by_jid(m.get_to()) # type:ignore[no-any-return] SelfType = TypeVar("SelfType") StanzaType = TypeVar("StanzaType", bound=StanzaBase) HandlerType = Callable[[SelfType, StanzaType], Awaitable[None]] P = ParamSpec("P") def exceptions_to_xmpp_errors(cb: HandlerType[Any, Any]) -> HandlerType[Any, Any]: @wraps(cb) async def wrapped(self: SelfType, stanza: StanzaType) -> None: try: await cb(self, stanza) except Ignore: pass except XMPPError: raise except NotImplementedError: log.debug("NotImplementedError raised in %s", cb) raise XMPPError( "feature-not-implemented", f"{cb.__name__} is not implemented by the legacy module", clear=False, ) except ContactIsUser: raise XMPPError( "bad-request", "Actions with your bridged self are not allowed." ) except Exception as e: log.error( "Failed to handle incoming stanza: %s - %s", self, stanza, exc_info=e ) raise XMPPError("internal-server-error", str(e)) return wrapped log = logging.getLogger(__name__) slidge/slidge/core/dispatcher/vcard.py000066400000000000000000000124551522535037600203420ustar00rootroot00000000000000from typing import TYPE_CHECKING from slixmpp import CoroutineCallback, Iq, StanzaPath, register_stanza_plugin from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0084.stanza import MetaData from slixmpp.plugins.xep_0292.stanza import NS as VCard4NS from slidge.util.util import fix_namespaces from ...contact import LegacyContact from ...group import LegacyParticipant from ...util.types import AnySession, Avatar from .util import DispatcherMixin, exceptions_to_xmpp_errors if TYPE_CHECKING: from slidge.util.types import AnyGateway class VCardMixin(DispatcherMixin): __slots__: list[str] = [] def __init__(self, xmpp: "AnyGateway") -> None: super().__init__(xmpp) xmpp.register_handler( CoroutineCallback( "get_vcard", StanzaPath("iq@type=get/vcard"), self.on_get_vcard ) ) xmpp.remove_handler("VCardTemp") xmpp.register_handler( CoroutineCallback( "VCardTemp", StanzaPath("iq/vcard_temp"), self.__vcard_temp_handler, ) ) # TODO: MR to slixmpp adding this to XEP-0084 register_stanza_plugin( self.xmpp.plugin["xep_0060"].stanza.Item, MetaData, ) @exceptions_to_xmpp_errors async def on_get_vcard(self, iq: Iq) -> None: session = await self._get_session(iq, logged=True) contact = await session.contacts.by_jid(iq.get_to()) vcard = await contact.get_vcard() reply = iq.reply() if vcard: reply.append(vcard) else: reply.enable("vcard") reply.send() @exceptions_to_xmpp_errors async def __vcard_temp_handler(self, iq: Iq) -> None: if iq["type"] == "get": return await self.__handle_get_vcard_temp(iq) if iq["type"] == "set": return await self.__handle_set_vcard_temp(iq) async def __fetch_user_avatar(self, session: AnySession) -> tuple[bytes, str]: hash_ = session.user.avatar_hash if not hash_: raise XMPPError( "item-not-found", "The slidge user does not have any avatar set" ) meta_iq = await self.xmpp.plugin["xep_0060"].get_item( session.user_jid, MetaData.namespace, hash_, ifrom=self.xmpp.boundjid.bare, ) info = meta_iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"] type_ = info["type"] data_iq = await self.xmpp.plugin["xep_0084"].retrieve_avatar( session.user_jid, hash_, ifrom=self.xmpp.boundjid.bare ) bytes_ = data_iq["pubsub"]["items"]["item"]["avatar_data"]["value"] return bytes_, type_ async def __handle_get_vcard_temp(self, iq: Iq) -> None: session = self.xmpp.get_session_from_stanza(iq) entity = await session.get_contact_or_group_or_participant(iq.get_to(), False) if not entity: raise XMPPError("item-not-found") bytes_ = None if isinstance(entity, LegacyParticipant): if entity.is_user: bytes_, type_ = await self.__fetch_user_avatar(session) if not bytes_: raise XMPPError( "internal-server-error", "Could not fetch the slidge user's avatar", ) avatar = None vcard = None elif not (contact := entity.contact): raise XMPPError("item-not-found", "This participant has no contact") else: vcard = await contact.get_vcard() avatar = contact.get_avatar() type_ = "image/png" else: avatar = entity.get_avatar() type_ = "image/png" if isinstance(entity, LegacyContact): vcard = await entity.get_vcard() else: vcard = None v = self.xmpp.plugin["xep_0054"].make_vcard() if avatar is not None and avatar.data: bytes_ = avatar.data.get_value() if bytes_: v["PHOTO"]["BINVAL"] = bytes_ v["PHOTO"]["TYPE"] = type_ if vcard: fix_namespaces( vcard.xml, VCard4NS, self.xmpp.plugin["xep_0054"].stanza.VCardTemp.namespace, ) for el in vcard.xml: v.append(el) reply = iq.reply() reply.append(v) reply.send() async def __handle_set_vcard_temp(self, iq: Iq) -> None: muc = await self.get_muc_from_stanza(iq) to = iq.get_to() if to.resource: raise XMPPError("bad-request", "You cannot set participants avatars") data = iq["vcard_temp"]["PHOTO"]["BINVAL"] or None try: legacy_id = await muc.on_avatar( data, iq["vcard_temp"]["PHOTO"]["TYPE"] or None ) except XMPPError: raise except Exception as e: raise XMPPError("internal-server-error", str(e)) reply = iq.reply(clear=True) reply.enable("vcard_temp") reply.send() if not data: await muc.set_avatar(None) return if legacy_id: await muc.set_avatar(Avatar(data=data, unique_id=legacy_id)) slidge/slidge/core/gateway.py000066400000000000000000001237571522535037600165660ustar00rootroot00000000000000""" This module extends slixmpp.ComponentXMPP to make writing new LegacyClients easier """ import abc import asyncio import contextlib import logging import re import tempfile from collections.abc import Callable, Sequence from copy import copy from datetime import datetime from pathlib import Path from typing import Any, ClassVar, Concatenate, Generic, ParamSpec, TypeVar, cast import aiohttp import qrcode from slixmpp import JID, ComponentXMPP, Iq from slixmpp.exceptions import IqError, IqTimeout, XMPPError from slixmpp.plugins.xep_0004.stanza.form import Form from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation from slixmpp.plugins.xep_0356.privilege import PrivilegedIqError from slixmpp.types import MessageTypes from slixmpp.xmlstream.xmlstream import NotConnectedError from sqlalchemy.orm import Session as OrmSession import slidge.command.categories from slidge.command.adhoc import AdhocProvider from slidge.command.admin import Exec from slidge.command.base import Command, FormField from slidge.command.chat_command import ChatCommandProvider from slidge.command.register import RegistrationType from slidge.contact import LegacyContact from slidge.core import config from slidge.core.dispatcher.session_dispatcher import SessionDispatcher from slidge.core.mixins.avatar import convert_avatar from slidge.core.mixins.message import MessageMixin from slidge.core.pubsub import PubSubComponent from slidge.db import GatewayUser, SlidgeStore from slidge.db.avatar import CachedAvatar, avatar_cache from slidge.db.meta import JSONSerializable from slidge.slixfix.delivery_receipt import DeliveryReceipt from slidge.slixfix.roster import RosterBackend from slidge.util import SubclassableOnce from slidge.util.types import AnyGateway, Avatar, MessageOrPresenceTypeVar, SessionType T = TypeVar("T") P = ParamSpec("P") class BaseGateway( ComponentXMPP, MessageMixin, SubclassableOnce, Generic[SessionType], abc.ABC, ): """ The gateway component, handling registrations and un-registrations. On slidge launch, a singleton is instantiated, and it will be made available to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the ``.xmpp`` attribute. Must be subclassed by a legacy module to set up various aspects of the XMPP component behaviour, such as its display name or welcome message, via class attributes :attr:`.COMPONENT_NAME` :attr:`.WELCOME_MESSAGE`. Abstract methods related to the registration process must be overriden for a functional :term:`Legacy Module`: - :meth:`.validate` - :meth:`.validate_two_factor_code` - :meth:`.get_qr_text` - :meth:`.confirm_qr` NB: Not all of these must be overridden, it depends on the :attr:`REGISTRATION_TYPE`. The other methods, such as :meth:`.send_text` or :meth:`.react` are the same as those of :class:`.LegacyContact` and :class:`.LegacyParticipant`, because the component itself is also a "messaging actor", ie, an :term:`XMPP Entity`. For these methods, you need to specify the JID of the recipient with the `mto` parameter. Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`,you also have a hand on low-level XMPP interactions via slixmpp methods, e.g.: .. code-block:: python self.send_presence( pfrom="somebody@component.example.com", pto="someonwelse@anotherexample.com", ) However, you should not need to do so often since the classes of the plugin API provides higher level abstractions around most commonly needed use-cases, such as sending messages, or displaying a custom status. """ COMPONENT_NAME: str = NotImplemented """Name of the component, as seen in service discovery by XMPP clients""" COMPONENT_TYPE: str = "" """Type of the gateway, should follow https://xmpp.org/registrar/disco-categories.html""" COMPONENT_AVATAR: Avatar | Path | str | None = None """ Path, bytes or URL used by the component as an avatar. """ REGISTRATION_FIELDS: Sequence[FormField] = [ FormField(var="username", label="User name", required=True), FormField(var="password", label="Password", required=True, private=True), ] """ Iterable of fields presented to the gateway user when registering using :xep:`0077` `extended `_ by :xep:`0004`. """ REGISTRATION_INSTRUCTIONS: str = "Enter your credentials" """ The text presented to a user who wants to register (or modify) their :term:`legacy ` account configuration. """ REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM """ This attribute determines how users register to the gateway, ie, how they login to the :term:`legacy network `. The credentials are then stored persistently, so this process should happen once per user (unless they unregister). The registration process always start with a basic data form (:xep:`0004`) presented to the user. But the legacy login flow might require something more sophisticated, see :class:`.RegistrationType` for more details. """ REGISTRATION_2FA_TITLE = "Enter your 2FA code" REGISTRATION_2FA_INSTRUCTIONS = ( "You should have received something via email or SMS, or something" ) REGISTRATION_QR_INSTRUCTIONS = "Flash this code or follow this link" PREFERENCES: ClassVar[list[FormField]] = [ FormField( var="sync_presence", label="Propagate your XMPP presence to the legacy network.", value="true", required=True, type="boolean", ), FormField( var="sync_avatar", label="Propagate your XMPP avatar to the legacy network.", value="true", required=True, type="boolean", ), FormField( var="always_invite_when_adding_bookmarks", label="Send an invitation to join MUCs after adding them to the bookmarks.", value="true", required=True, type="boolean", ), FormField( var="last_seen_fallback", label="Use contact presence status message to show when they were last seen.", value="true", required=True, type="boolean", ), FormField( var="roster_push", label="Add contacts to your roster.", value="true", required=True, type="boolean", ), FormField( var="reaction_fallback", label="Receive fallback messages for reactions (for legacy XMPP clients)", value="false", required=True, type="boolean", ), ] ROSTER_GROUP: str = "slidge" """ Name of the group assigned to a :class:`.LegacyContact` automagically added to the :term:`User`'s roster with :meth:`.LegacyContact.add_to_roster`. """ WELCOME_MESSAGE = ( "Thank you for registering. Type 'help' to list the available commands, " "or just start messaging away!" ) """ A welcome message displayed to users on registration. This is useful notably for clients that don't consider component JIDs as a valid recipient in their UI, yet still open a functional chat window on incoming messages from components. """ SEARCH_FIELDS: Sequence[FormField] = [ FormField(var="first", label="First name", required=True), FormField(var="last", label="Last name", required=True), FormField(var="phone", label="Phone number", required=False), ] """ Fields used for searching items via the component, through :xep:`0055` (jabber search). A common use case is to allow users to search for legacy contacts by something else than their usernames, eg their phone number. Plugins should implement search by overriding :meth:`.BaseSession.search` (restricted to registered users). If there is only one field, it can also be used via the ``jabber:iq:gateway`` protocol described in :xep:`0100`. Limitation: this only works if the search request returns one result item, and if this item has a 'jid' var. """ SEARCH_TITLE: str = "Search for legacy contacts" """ Title of the search form. """ SEARCH_INSTRUCTIONS: str = "" """ Instructions of the search form. """ MARK_ALL_MESSAGES = False """ Set this to True for :term:`legacy networks ` that expects read marks for *all* messages and not just the latest one that was read (as most XMPP clients will only send a read mark for the latest msg). """ PROPER_RECEIPTS = False """ Set this to True if the legacy service provides a real equivalent of message delivery receipts (:xep:`0184`), meaning that there is an event thrown when the actual device of a contact receives a message. Make sure to call Contact.received() adequately if this is set to True. """ GROUPS = False """ This must be set to True if this gateway supports groups. """ SPACES = False """ This must be set to True if this gateway supports spaces, cf :xep:`0503`. """ mtype: MessageTypes = "chat" is_group = False _can_send_carbon = False store: SlidgeStore _session_cls: type[SessionType] http: aiohttp.ClientSession avatar: CachedAvatar | None = None def __init__(self) -> None: if config.COMPONENT_NAME: self.COMPONENT_NAME = config.COMPONENT_NAME if config.WELCOME_MESSAGE: self.WELCOME_MESSAGE = config.WELCOME_MESSAGE self.log = log self.datetime_started = datetime.now() # FIXME: ugly hack to work with the BaseSender mixin :/ self.xmpp = cast("AnyGateway", self) self.default_ns = "jabber:component:accept" super().__init__( config.JID, config.SECRET, config.SERVER, config.PORT, plugin_whitelist=SLIXMPP_PLUGINS, plugin_config={ "xep_0077": { "form_fields": None, "form_instructions": self.REGISTRATION_INSTRUCTIONS, "enable_subscription": self.REGISTRATION_TYPE == RegistrationType.SINGLE_STEP_FORM, }, "xep_0100": { "component_name": self.COMPONENT_NAME, "type": self.COMPONENT_TYPE, }, "xep_0184": { "auto_ack": False, "auto_request": False, }, "xep_0363": { "upload_service": config.UPLOAD_SERVICE, }, }, fix_error_ns=True, ) self.loop.set_exception_handler(self.__exception_handler) self.loop.create_task(self.__set_http()) self.has_crashed: bool = False self.use_origin_id = False if config.USER_JID_VALIDATOR is None: config.USER_JID_VALIDATOR = f".*@{self.infer_real_domain()}" log.info( "No USER_JID_VALIDATOR was set, using '%s'.", config.USER_JID_VALIDATOR, ) self.jid_validator: re.Pattern[str] = re.compile(config.USER_JID_VALIDATOR) self.qr_pending_registrations = dict[ str, asyncio.Future[JSONSerializable | None] ]() self.register_plugins() self.__setup_legacy_module_subclasses() self.get_session_from_stanza = self._session_cls.from_stanza self.get_session_from_user = self._session_cls.from_user self.__register_slixmpp_events() self.__register_slixmpp_api() self.roster.set_backend(RosterBackend(self)) self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME}) self.pubsub: PubSubComponent = self.plugin["pubsub"] # type:ignore[typeddict-item] self.delivery_receipt = DeliveryReceipt(self) # with this we receive user avatar updates self.plugin["xep_0030"].add_feature("urn:xmpp:avatar:metadata+notify") self.plugin["xep_0030"].add_feature("urn:xmpp:chat-markers:0") if self.GROUPS: self.plugin["xep_0030"].add_feature("http://jabber.org/protocol/muc") self.plugin["xep_0030"].add_feature(self.plugin["xep_0463"].stanza.NS) self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2") self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2#extended") self.plugin["xep_0030"].add_feature(self.plugin["xep_0421"].namespace) self.plugin["xep_0030"].add_feature(self.plugin["xep_0317"].stanza.NS) self.plugin["xep_0030"].add_identity( category="conference", name=self.COMPONENT_NAME, itype="text", jid=self.boundjid, ) if self.SPACES: self.plugin["xep_0030"].add_feature("urn:xmpp:spaces:0") self.__adhoc_handler = AdhocProvider(self) self.__chat_commands_handler = ChatCommandProvider(self) self.__dispatcher = SessionDispatcher(self) self.__register_commands() MessageMixin.__init__(self) # ComponentXMPP does not call super().__init__() def __setup_legacy_module_subclasses(self) -> None: from ..contact.roster import LegacyRoster from ..group.bookmarks import LegacyBookmarks from ..group.participant import LegacyParticipant from ..group.room import LegacyMUC from .session import BaseSession self._session_cls = BaseSession.get_unique_subclass() # type:ignore contact_cls = LegacyContact.get_self_or_unique_subclass() muc_cls = LegacyMUC.get_self_or_unique_subclass() participant_cls = LegacyParticipant.get_self_or_unique_subclass() bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass() roster_cls = LegacyRoster.get_self_or_unique_subclass() if contact_cls.REACTIONS_SINGLE_EMOJI: # type:ignore[attr-defined] form = Form() form["type"] = "result" form.add_field( "FORM_TYPE", "hidden", value="urn:xmpp:reactions:0:restrictions" ) form.add_field("max_reactions_per_user", value="1", type="text-single") form.add_field("scope", value="domain") self.plugin["xep_0128"].add_extended_info(data=form) self._session_cls.xmpp = self contact_cls.xmpp = self # type:ignore[attr-defined] muc_cls.xmpp = self # type:ignore[attr-defined] self._session_cls._bookmarks_cls = bookmarks_cls # type:ignore[assignment] self._session_cls._roster_cls = roster_cls # type:ignore[assignment] LegacyRoster._contact_cls = contact_cls # type:ignore[misc] LegacyBookmarks._muc_cls = muc_cls # type:ignore[misc] LegacyMUC._participant_cls = participant_cls # type:ignore[misc] async def kill_session(self, jid: JID) -> None: await self._session_cls.kill_by_jid(jid) async def __set_http(self) -> None: self.http = aiohttp.ClientSession() if getattr(self, "_test_mode", False): return avatar_cache.http = self.http def __register_commands(self) -> None: for cls in Command.subclasses: # type:ignore[misc] if any(x is NotImplemented for x in [cls.CHAT_COMMAND, cls.NODE, cls.NAME]): log.debug("Not adding command '%s' because it looks abstract", cls) continue if cls is Exec: if config.DEV_MODE: log.warning(r"/!\ DEV MODE ENABLED /!\\") else: continue if cls.CATEGORY == slidge.command.categories.GROUPS and not self.GROUPS: continue if cls.CATEGORY == slidge.command.categories.SPACES and not self.SPACES: continue c = cls(cast(AnyGateway, self)) log.debug("Registering %s", cls) self.__adhoc_handler.register(c) self.__chat_commands_handler.register(c) def __exception_handler( self, loop: asyncio.AbstractEventLoop, context: dict[Any, Any] ) -> None: """ Called when a task created by loop.create_task() raises an Exception :param loop: :param context: :return: """ log.debug("Context in the exception handler: %s", context) exc = context.get("exception") if exc is None: log.debug("No exception in this context: %s", context) elif isinstance(exc, SystemExit): log.debug("SystemExit called in an asyncio task") else: log.error("Crash in an asyncio task: %s", context) log.exception("Crash in task", exc_info=exc) self.has_crashed = True loop.stop() def __register_slixmpp_events(self) -> None: self.del_event_handler("presence_subscribe", self._handle_subscribe) self.del_event_handler("presence_unsubscribe", self._handle_unsubscribe) self.del_event_handler("presence_subscribed", self._handle_subscribed) self.del_event_handler("presence_unsubscribed", self._handle_unsubscribed) self.del_event_handler( "roster_subscription_request", self._handle_new_subscription ) self.del_event_handler("presence_probe", self._handle_probe) self.add_event_handler("session_start", self.__on_session_start) def __register_slixmpp_api(self) -> None: def with_session( func: Callable[Concatenate[OrmSession, P], T], commit: bool = True ) -> Callable[P, T]: def wrapped(*a: P.args, **kw: P.kwargs) -> T: with self.store.session() as orm: res = func(orm, *a, **kw) if commit: orm.commit() return res return wrapped self.plugin["xep_0231"].api.register( with_session(self.store.bob.get_bob, False), "get_bob" ) self.plugin["xep_0231"].api.register( with_session(self.store.bob.set_bob), "set_bob" ) self.plugin["xep_0231"].api.register( with_session(self.store.bob.del_bob), "del_bob" ) @property # type:ignore[override] def jid(self) -> JID: # Override to avoid slixmpp deprecation warnings. return self.boundjid @jid.setter def jid(self, jid: JID) -> None: raise RuntimeError async def __on_session_start(self, event: object) -> None: log.debug("Gateway session start: %s", event) await self.__setup_attachments() # prevents XMPP clients from considering the gateway as an HTTP upload disco = self.plugin["xep_0030"] await disco.del_feature(feature="urn:xmpp:http:upload:0", jid=self.boundjid) await self.plugin["xep_0115"].update_caps(jid=self.boundjid) if self.COMPONENT_AVATAR is not None: log.debug("Setting gateway avatar…") avatar = convert_avatar(self.COMPONENT_AVATAR, "!!---slidge---special---") assert avatar is not None try: cached_avatar = await avatar_cache.convert_or_get(avatar) except Exception as e: log.exception("Could not set the component avatar.", exc_info=e) cached_avatar = None else: assert cached_avatar is not None self.avatar = cached_avatar else: cached_avatar = None with self.store.session() as orm: users = orm.query(GatewayUser).all() for user in users: # TODO: before this, we should check if the user has removed us from their roster # while we were offline and trigger unregister from there. Presence probe does not seem # to work in this case, there must be another way. privileged entity could be used # as last resort. try: await self["xep_0100"].add_component_to_roster(user.jid) await self.__add_component_to_mds_whitelist(user.jid) except (IqError, IqTimeout) as e: # TODO: remove the user when this happens? or at least # this can happen when the user has unsubscribed from the XMPP server log.warning( "Error with user %s, not logging them automatically", user, exc_info=e, ) continue session = self._session_cls.from_user(user) session.create_task(self.login_wrap(session)) if cached_avatar is not None: await self.pubsub.broadcast_avatar( self.boundjid.bare, session.user_jid, cached_avatar ) log.info("Slidge has successfully started") async def __setup_attachments(self) -> None: if config.NO_UPLOAD_PATH: if config.NO_UPLOAD_URL_PREFIX is None: raise RuntimeError( "If you set NO_UPLOAD_PATH you must set NO_UPLOAD_URL_PREFIX too." ) elif not config.UPLOAD_SERVICE: try: info_iq = await self.xmpp.plugin["xep_0363"].find_upload_service( self.infer_real_domain() ) except XMPPError: info_iq = None log.exception( "The upload service could not be automatically determine. " "Attachments to XMPP will not work. " "Either specify 'upload-service' or 'no-upload-path' to fix that." ) if info_iq is None: if self.REGISTRATION_TYPE == RegistrationType.QRCODE: log.warning( "No method was configured for attachment and slidge " "could not automatically determine the JID of a usable upload service. " "Users likely won't be able to register since this network uses a " "QR-code based registration flow." ) if not config.USE_ATTACHMENT_ORIGINAL_URLS: log.warning( "Setting USE_ATTACHMENT_ORIGINAL_URLS to True since no method was configured " "for attachments and no upload service was found. NB: this does not work for all " "networks, especially for the E2EE attachments." ) config.USE_ATTACHMENT_ORIGINAL_URLS = True else: log.info("Auto-discovered upload service: %s", info_iq["from"]) config.UPLOAD_SERVICE = info_iq["from"] def infer_real_domain(self) -> JID: return JID(re.sub(r"^.*?\.", "", self.xmpp.boundjid.bare)) async def __add_component_to_mds_whitelist(self, user_jid: JID) -> None: # Uses privileged entity to add ourselves to the whitelist of the PEP # MDS node so we receive MDS events iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") iq_creation["pubsub"]["create"]["node"] = self.plugin["xep_0490"].stanza.NS try: await self.plugin["xep_0356"].send_privileged_iq(iq_creation) except PermissionError: log.warning( "IQ privileges not granted for pubsub namespace, we cannot " "create the MDS node of %s", user_jid, ) except PrivilegedIqError as exc: nested = exc.nested_error() # conflict this means the node already exists, we can ignore that if nested is not None and nested.condition != "conflict": log.exception( "Could not create the MDS node of %s", user_jid, exc_info=exc ) except Exception as e: log.exception( "Error while trying to create to the MDS node of %s", user_jid, exc_info=e, ) iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self.plugin[ "xep_0490" ].stanza.NS aff = OwnerAffiliation() aff["jid"] = self.boundjid.bare aff["affiliation"] = "member" iq_affiliation["pubsub_owner"]["affiliations"].append(aff) try: await self.plugin["xep_0356"].send_privileged_iq(iq_affiliation) except PermissionError: log.warning( "IQ privileges not granted for pubsub#owner namespace, we cannot " "listen to the MDS events of %s", user_jid, ) except Exception as e: log.exception( "Error while trying to subscribe to the MDS node of %s", user_jid, exc_info=e, ) async def login_wrap(self, session: SessionType) -> str: session.send_gateway_status("Logging in…", show="dnd") session.is_logging_in = True try: status = await session.login() except Exception as e: log.warning("Login problem for %s", session.user_jid, exc_info=e) session.send_gateway_status(f"Could not login: {e}", show="dnd") msg = ( "You are not connected to this gateway! " f"Maybe this message will tell you why: {e}" ) session.send_gateway_message(msg) session.logged = False session.send_gateway_status("Login failed", show="dnd") return msg log.info("Login success for %s", session.user_jid) session.logged = True session.send_gateway_status("Syncing contacts…", show="dnd") with self.store.session() as orm: await session.contacts._fill(orm) if not (r := session.contacts.ready).done(): r.set_result(True) if self.GROUPS: session.send_gateway_status("Syncing groups…", show="dnd") await session.bookmarks.fill() if not (r := session.bookmarks.ready).done(): r.set_result(True) self.send_presence(pto=session.user.jid.bare, ptype="probe") if status is None: status = "Logged in" session.send_gateway_status(status, show="chat") if session.user.preferences.get("sync_avatar", False): session.create_task(self.fetch_user_avatar(session)) else: with self.store.session(expire_on_commit=False) as orm: session.user.avatar_hash = None orm.add(session.user) orm.commit() return status async def fetch_user_avatar(self, session: SessionType) -> None: try: iq = await self.xmpp.plugin["xep_0060"].get_items( session.user_jid.bare, self.xmpp.plugin["xep_0084"].stanza.MetaData.namespace, ifrom=self.boundjid.bare, ) except IqTimeout: self.log.warning("Iq timeout trying to fetch user avatar") return except IqError as e: self.log.debug("Iq error when trying to fetch user avatar: %s", e) if e.condition == "item-not-found": try: await session.on_avatar(None, None, None, None, None) except NotImplementedError: pass else: with self.store.session(expire_on_commit=False) as orm: session.user.avatar_hash = None orm.add(session.user) orm.commit() return await self.__dispatcher.on_avatar_metadata_info( session, iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"] ) def _send( self, stanza: MessageOrPresenceTypeVar, **send_kwargs: Any, # noqa:ANN401 ) -> MessageOrPresenceTypeVar: stanza.set_from(self.boundjid.bare) if mto := send_kwargs.get("mto"): stanza.set_to(mto) stanza.send() return stanza def raise_if_not_allowed_jid(self, jid: JID) -> None: if not self.jid_validator.match(jid.bare): raise XMPPError( condition="not-allowed", text="Your account is not allowed to use this gateway. " "The admin controls that with the USER_JID_VALIDATOR option.", ) def send_raw(self, data: str | bytes) -> None: # overridden from XMLStream to strip base64-encoded data from the logs # to make them more readable. if log.isEnabledFor(level=logging.DEBUG): stripped = copy(data) if isinstance(data, str) else data.decode("utf-8") # there is probably a way to do that in a single RE, # but since it's only for debugging, the perf penalty # does not matter much for el in LOG_STRIP_ELEMENTS: stripped = re.sub( f"(<{el}.*?>)(.*)()", "\1[STRIPPED]\3", stripped, flags=re.DOTALL | re.IGNORECASE, ) log.debug("SEND: %s", stripped) if not self.transport: raise NotConnectedError() if isinstance(data, str): data = data.encode("utf-8") self.transport.write(data) def get_session_from_jid(self, j: JID) -> SessionType | None: try: return self._session_cls.from_jid(j) except XMPPError: return None def exception(self, exception: Exception) -> None: # """ # Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception. # # Stop the event loop and exit on unhandled exception. # # The default :class:`slixmpp.basexmpp.BaseXMPP` behaviour is just to # log the exception, but we want to avoid undefined behaviour. # # :param exception: An unhandled :class:`Exception` object. # """ if isinstance(exception, IqError): iq = exception.iq log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"]) log.warning("You should catch IqError exceptions") elif isinstance(exception, IqTimeout): iq = exception.iq log.error("Request timed out: %s", iq) log.warning("You should catch IqTimeout exceptions") elif isinstance(exception, SyntaxError): # Hide stream parsing errors that occur when the # stream is disconnected (they've been handled, we # don't need to make a mess in the logs). pass else: if exception: log.exception(exception) self.loop.stop() exit(1) async def make_registration_form( self, _jid: JID, _node: str, _ifrom: JID, iq: Iq ) -> Iq: self.raise_if_not_allowed_jid(iq.get_from()) reg = iq["register"] with self.store.session() as orm: user = ( orm.query(GatewayUser).filter_by(jid=iq.get_from().bare).one_or_none() ) log.debug("User found: %s", user) form = reg["form"] form.add_field( "FORM_TYPE", ftype="hidden", value="jabber:iq:register", ) form["title"] = f"Registration to '{self.COMPONENT_NAME}'" form["instructions"] = self.REGISTRATION_INSTRUCTIONS if user is not None: reg["registered"] = False form.add_field( "remove", label="Remove my registration", required=True, ftype="boolean", value=False, ) for field in self.REGISTRATION_FIELDS: if field.var in reg.interfaces: val = None if user is None else user.get(field.var) if val is None: reg.add_field(field.var) else: reg[field.var] = val reg["instructions"] = self.REGISTRATION_INSTRUCTIONS for field in self.REGISTRATION_FIELDS: form.add_field( field.var, label=field.label, required=field.required, ftype=field.type, options=field.options, value=field.value if user is None else user.get(field.var, field.value), ) reply = iq.reply() reply.set_payload(reg) return reply # type:ignore[no-any-return] async def user_prevalidate( self, ifrom: JID, form_dict: dict[str, str | None] ) -> JSONSerializable | None: # Pre validate a registration form using the content of self.REGISTRATION_FIELDS # before passing it to the plugin custom validation logic. for field in self.REGISTRATION_FIELDS: if field.required and not form_dict.get(field.var): raise ValueError(f"Missing field: '{field.label}'") return await self.validate(ifrom, form_dict) @abc.abstractmethod async def validate( self, user_jid: JID, registration_form: dict[str, str | None] ) -> JSONSerializable | None: """ Validate a user's initial registration form. Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` if the registration does not allow to continue the registration process. If :py:attr:`REGISTRATION_TYPE` is a :attr:`.RegistrationType.SINGLE_STEP_FORM`, this method should raise something if it wasn't possible to successfully log in to the legacy service with the registration form content. It is also used for other types of :py:attr:`REGISTRATION_TYPE` too, since the first step is always a form. If :attr:`.REGISTRATION_FIELDS` is an empty list (ie, it declares no :class:`.FormField`), the "form" is effectively a confirmation dialog displaying :attr:`.REGISTRATION_INSTRUCTIONS`. :param user_jid: JID of the user that has just registered :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable. This dict can be modified and will be accessible as the ``legacy_module_data`` of the :return : A dict that will be stored as the persistent "legacy_module_data" for this user. If you don't return anything here, the whole registration_form content will be stored. """ raise NotImplementedError async def validate_two_factor_code( self, user: GatewayUser, code: str ) -> JSONSerializable | None: """ Called when the user enters their 2FA code. Should raise the appropriate :class:`slixmpp.exceptions.XMPPError` if the login fails, and return successfully otherwise. Only used when :attr:`REGISTRATION_TYPE` is :attr:`.RegistrationType.TWO_FACTOR_CODE`. :param user: The :class:`.GatewayUser` whose registration is pending Use their :attr:`.GatewayUser.bare_jid` and/or :attr:`.registration_form` attributes to get what you need. :param code: The code they entered, either via "chatbot" message or adhoc command :return : A dict which keys and values will be added to the persistent "legacy_module_data" for this user. """ raise NotImplementedError async def get_qr_text(self, user: GatewayUser) -> str: """ This is where slidge gets the QR code content for the QR-based registration process. It will turn it into a QR code image and send it to the not-yet-fully-registered :class:`.GatewayUser`. Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is :attr:`.RegistrationType.QRCODE`. :param user: The :class:`.GatewayUser` whose registration is pending Use their :attr:`.GatewayUser.bare_jid` and/or :attr:`.registration_form` attributes to get what you need. """ raise NotImplementedError async def confirm_qr( self, user_bare_jid: str, exception: Exception | None = None, legacy_data: JSONSerializable | None = None, ) -> None: """ This method is meant to be called to finalize QR code-based registration flows, once the legacy service confirms the QR flashing. Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is :attr:`.RegistrationType.QRCODE`. :param user_bare_jid: The bare JID of the almost-registered :class:`GatewayUser` instance :param exception: Optionally, an XMPPError to be raised to **not** confirm QR code flashing. :param legacy_data: dict which keys and values will be added to the persistent "legacy_module_data" for this user. """ fut = self.qr_pending_registrations[user_bare_jid] if exception is None: fut.set_result(legacy_data) else: fut.set_exception(exception) async def unregister_user( self, user: GatewayUser, msg: str = "You unregistered from this gateway." ) -> None: self.send_presence(pshow="dnd", pstatus=msg, pto=user.jid) await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid) # type:ignore[call-arg] await self.xmpp._session_cls.kill_by_jid(user.jid) async def unregister(self, session: SessionType) -> None: """ Optionally override this if you need to clean additional stuff after a user has been removed from the persistent user store. By default, this just calls :meth:`BaseSession.logout`. :param session: The session of the user who just unregistered """ with contextlib.suppress(NotImplementedError): await session.logout() async def input( self, jid: JID, text: str | None = None, mtype: MessageTypes = "chat", **input_kwargs: Any, # noqa:ANN401 ) -> str: """ Request arbitrary user input using a simple chat message, and await the result. You shouldn't need to call this directly bust instead use :meth:`.BaseSession.input` to directly target a user. :param jid: The JID we want input from :param text: A prompt to display for the user :param mtype: Message type :return: The user's reply """ return await self.__chat_commands_handler.input( jid, text, mtype=mtype, **input_kwargs ) async def send_qr( self, text: str, **msg_kwargs: Any, # noqa:ANN401 ) -> None: """ Sends a QR Code to a JID You shouldn't need to call directly bust instead use :meth:`.BaseSession.send_qr` to directly target a user. :param text: The text that will be converted to a QR Code :param msg_kwargs: Optional additional arguments to pass to :meth:`.BaseGateway.send_file`, such as the recipient of the QR, code """ qr = qrcode.make(text) with tempfile.NamedTemporaryFile(suffix=".png") as f: qr.save(f.name) await self.send_file(Path(f.name), **msg_kwargs) def shutdown(self) -> list[asyncio.Task[None]]: # """ # Called by the slidge entrypoint on normal exit. # # Sends offline presences from all contacts of all user sessions and from # the gateway component itself. # No need to call this manually, :func:`slidge.__main__.main` should take care of it. # """ log.debug("Shutting down") tasks = [] with self.store.session() as orm: for user in orm.query(GatewayUser).all(): tasks.append(self._session_cls.from_jid(user.jid).shutdown()) self.send_presence(ptype="unavailable", pto=user.jid) return tasks SLIXMPP_PLUGINS = [ "xep_0030", # Service discovery "xep_0045", # Multi-User Chat "xep_0050", # Adhoc commands "xep_0054", # VCard-temp (for MUC avatars) "xep_0055", # Jabber search "xep_0059", # Result Set Management "xep_0066", # Out of Band Data "xep_0071", # XHTML-IM (for stickers and custom emojis maybe later) "xep_0077", # In-band registration "xep_0084", # User Avatar "xep_0085", # Chat state notifications "xep_0100", # Gateway interaction "xep_0106", # JID Escaping "xep_0115", # Entity capabilities "xep_0122", # Data Forms Validation "xep_0128", # Service Discovery Extensions "xep_0153", # vCard-Based Avatars (for MUC avatars) "xep_0172", # User nickname "xep_0184", # Message Delivery Receipts "xep_0199", # XMPP Ping "xep_0221", # Data Forms Media Element "xep_0231", # Bits of Binary (for stickers and custom emojis maybe later) "xep_0249", # Direct MUC Invitations "xep_0264", # Jingle Content Thumbnails "xep_0280", # Carbons "xep_0292_provider", # VCard4 "xep_0308", # Last message correction "xep_0313", # Message Archive Management "xep_0317", # Hats "xep_0319", # Last User Interaction in Presence "xep_0333", # Chat markers "xep_0334", # Message Processing Hints "xep_0356", # Privileged Entity "xep_0363", # HTTP file upload "xep_0385", # Stateless in-line media sharing "xep_0402", # PEP Native Bookmarks "xep_0421", # Anonymous unique occupant identifiers for MUCs "xep_0424", # Message retraction "xep_0425", # Message moderation "xep_0444", # Message reactions "xep_0447", # Stateless File Sharing "xep_0449", # Stickers "xep_0461", # Message replies "xep_0462", # Pubsub Type Filtering "xep_0463", # MUC Affiliation Versioning "xep_0469", # Bookmark Pinning "xep_0490", # Message Displayed Synchronization "xep_0492", # Chat Notification Settings # "xep_0503", # Server-side spaces "xep_0511", # Link Metadata ] LOG_STRIP_ELEMENTS = ["data", "binval"] log = logging.getLogger(__name__) slidge/slidge/core/mixins/000077500000000000000000000000001522535037600160435ustar00rootroot00000000000000slidge/slidge/core/mixins/__init__.py000066400000000000000000000007171522535037600201610ustar00rootroot00000000000000""" Mixins """ from .avatar import AvatarMixin from .disco import ChatterDiscoMixin from .message import MessageCarbonMixin, MessageMixin from .presence import PresenceMixin class FullMixin(ChatterDiscoMixin, MessageMixin, PresenceMixin): pass class FullCarbonMixin(ChatterDiscoMixin, MessageCarbonMixin, PresenceMixin): pass __all__ = ( "AvatarMixin", "ChatterDiscoMixin", "FullCarbonMixin", "MessageMixin", "PresenceMixin", ) slidge/slidge/core/mixins/attachment.py000066400000000000000000000671251522535037600205600ustar00rootroot00000000000000from __future__ import annotations import base64 import contextlib import functools import io import logging import os import shutil import stat import tempfile import warnings from collections.abc import AsyncIterator, Collection, Sequence from datetime import datetime from itertools import chain from mimetypes import guess_extension, guess_type from pathlib import Path from typing import IO, Any, cast from urllib.parse import quote as urlquote from uuid import uuid4 from xml.etree import ElementTree as ET import aiohttp import thumbhash from PIL import Image, ImageOps from slixmpp import JID, Iq, Message from slixmpp.plugins.xep_0264.stanza import Thumbnail from slixmpp.plugins.xep_0447.stanza import StatelessFileSharing from ...db.avatar import avatar_cache from ...db.models import Attachment from ...util.types import LegacyAttachment, MessageReference from ...util.util import fix_suffix from .. import config from .message_text import TextMessageMixin class AttachmentMixin(TextMessageMixin): @property def __is_component(self) -> bool: return self.session is NotImplemented async def __upload(self, att: _AttachmentWithName) -> str: assert config.UPLOAD_SERVICE att = await _ensure_metadata(att) iq_slot = await self.__request_upload_slot( config.UPLOAD_SERVICE, att.name, att.size, att.content_type, ) slot = iq_slot["http_upload_slot"] headers = { "Content-Length": str(att.size), "Content-Type": att.content_type, **{header["name"]: header["value"] for header in slot["put"]["headers"]}, } async with ( aiohttp.ClientSession() as http, _get_data(att, http) as data, http.put(slot["put"]["url"], data=data, headers=headers) as resp, ): resp.raise_for_status() return slot["get"]["url"] # type:ignore[no-any-return] async def __request_upload_slot( self, upload_service: JID | str, filename: str, size: int, content_type: str, ) -> Iq: iq_request = self.xmpp.make_iq_get(ito=upload_service) request = iq_request["http_upload_request"] request["filename"] = filename request["size"] = str(size) request["content-type"] = content_type if not self.__is_component: iq_request.set_from(self.session.user_jid) try: return await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq_request) except Exception as e: warnings.warn( "Could not request upload slot on behalf of " f"{self.session.user_jid}: {e}." "Falling back to not using privileges." ) iq_request.set_from(config.UPLOAD_REQUESTER or self.xmpp.boundjid) return await iq_request.send() # type:ignore[no-any-return] @staticmethod async def __no_upload( att: _AttachmentWithName, legacy_file_id: str | None ) -> tuple[Path, str]: file_id = uuid4().hex if legacy_file_id is None else legacy_file_id assert config.NO_UPLOAD_PATH is not None assert config.NO_UPLOAD_URL_PREFIX is not None destination_dir = Path(config.NO_UPLOAD_PATH) / file_id if destination_dir.exists(): log.debug("Dest dir exists: %s", destination_dir) files = list(f for f in destination_dir.glob("**/*") if f.is_file()) if len(files) == 1: log.debug( "Found the legacy attachment '%s' at '%s'", legacy_file_id, files[0], ) name = files[0].name uu = files[0].parent.name # anti-obvious url trick, see below return files[0], "/".join([file_id, uu, name]) else: log.warning( ( "There are several or zero files in %s, " "slidge doesn't know which one to pick among %s. " "Removing the dir." ), destination_dir, files, ) shutil.rmtree(destination_dir) log.debug("Did not find a file in: %s", destination_dir) # let's use a UUID to avoid URLs being too obvious uu = str(uuid4()) destination_dir = destination_dir / uu destination_dir.mkdir(parents=True) assert att.name destination = destination_dir / att.name if att.path: assert isinstance(att.path, Path) try: destination.hardlink_to(att.path) except OSError as e: if is_temp_path(att.path): shutil.copy2(att.path, destination) else: log.debug("Could not hardlink: %s, attempting symlink", e) try: destination.symlink_to(att.path) except OSError as e: log.debug("Could not symlink: %s, copying data", e) shutil.copy2(att.path, destination) elif att.data: destination.write_bytes(att.data) else: with destination.open("wb") as f: if att.aio_stream: async for chunk in att.aio_stream: f.write(chunk) elif att.stream: shutil.copyfileobj(att.stream, f) elif att.url: async with ( aiohttp.ClientSession() as http, http.get(att.url) as resp, ): resp.raise_for_status() async for chunk in resp.content.iter_chunked(64 * 1024): f.write(chunk) else: raise RuntimeError if config.NO_UPLOAD_FILE_READ_OTHERS: log.debug("Changing perms of %s", destination) destination.chmod(destination.stat().st_mode | stat.S_IROTH) url = "/".join([file_id, uu, att.name]) return destination, url async def __valid_url(self, url: str) -> bool: async with self.session.http.head(url) as r: return r.status < 400 async def __get_stored(self, attachment: LegacyAttachment) -> Attachment: if attachment.legacy_file_id is not None and not self.__is_component: with self.xmpp.store.session() as orm: stored = ( orm.query(Attachment) .filter_by( legacy_file_id=str(attachment.legacy_file_id), user_account_id=self.session.user_pk, ) .one_or_none() ) if stored is not None: if not await self.__valid_url(stored.url): stored.url = None # type:ignore return stored return Attachment( user_account_id=None if self.__is_component else self.session.user_pk, legacy_file_id=None if attachment.legacy_file_id is None else str(attachment.legacy_file_id), url=attachment.url if config.USE_ATTACHMENT_ORIGINAL_URLS else None, ) async def __get_url(self, att: LegacyAttachment, stored: Attachment) -> str: att = _ensure_name(att) if len(att.name) > config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH: log.debug("Trimming long filename: %s", att.name) base, ext = os.path.splitext(att.name) att.name = ( base[: config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH - len(ext)] + ext ) if config.FIX_FILENAME_SUFFIX_MIME_TYPE and isinstance(att.path, Path): att.name, att.content_type = fix_suffix( att.path, att.content_type, att.name ) att.legacy_file_id = stored.legacy_file_id if config.NO_UPLOAD_PATH: att.path, new_url = await self.__no_upload(att, stored.legacy_file_id) new_url = (config.NO_UPLOAD_URL_PREFIX or "") + "/" + urlquote(new_url) else: new_url = await self.__upload(att) if stored.legacy_file_id: stored.url = new_url return new_url def __set_sims( self, msg: Message, uploaded_url: str, attachment: LegacyAttachment, stored: Attachment, thumbnail: Thumbnail | None, ) -> None: if stored.sims is not None: ref = self.xmpp.plugin["xep_0372"].stanza.Reference( xml=ET.fromstring(stored.sims) ) msg.append(ref) return if attachment.data or attachment.stream or attachment.path: ref = self.xmpp.plugin["xep_0385"].get_sims( attachment.path, # type:ignore[arg-type] [uploaded_url], attachment.content_type, attachment.caption, file=attachment.stream, data=attachment.data, ) else: sims = self.xmpp.plugin["xep_0385"].stanza.Sims() ref = self.xmpp.plugin["xep_0372"].stanza.Reference() ref["uri"] = uploaded_url ref["type"] = "data" sims["sources"].append(ref) ref = self.xmpp.plugin["xep_0372"].stanza.Reference() ref.append(sims) ref["type"] = "data" sims.enable("file") if attachment.content_type: sims["file"]["media-type"] = attachment.content_type if attachment.caption: sims["file"]["desc"] = attachment.caption if attachment.size: sims["file"]["size"] = attachment.size if attachment.name: ref["sims"]["file"]["name"] = attachment.name if attachment.aio_stream is not None: # revove the date in case we have downloaded the file to disk # because no size was provided by the legacy module ref["sims"]["file"]["date"] = None if thumbnail is not None: ref["sims"]["file"].append(thumbnail) stored.sims = str(ref) msg.append(ref) def __set_sfs( self, msg: Message, uploaded_url: str, attachment: LegacyAttachment, stored: Attachment, thumbnail: Thumbnail | None = None, ) -> None: if stored.sfs is not None: msg.append(StatelessFileSharing(xml=ET.fromstring(stored.sfs))) return if attachment.path or attachment.data or attachment.stream: sfs = self.xmpp.plugin["xep_0447"].get_sfs( # type:ignore attachment.path, [uploaded_url], attachment.content_type, attachment.caption, data=attachment.data, file=attachment.stream, ) else: sfs = self.xmpp.plugin["xep_0447"].stanza.StatelessFileSharing() ref = self.xmpp.plugin["xep_0447"].stanza.UrlData() ref["target"] = uploaded_url sfs["sources"].append(ref) sfs.enable("file") if attachment.content_type: sfs["file"]["media-type"] = attachment.content_type if attachment.name: sfs["file"]["name"] = attachment.name if attachment.disposition: sfs["disposition"] = attachment.disposition else: del sfs["disposition"] if thumbnail is not None: sfs["file"].append(thumbnail) if attachment.aio_stream is not None: # revove the date in case we have downloaded the file to disk # because no size was provided by the legacy module sfs["file"]["date"] = None if attachment.size: sfs["file"]["size"] = attachment.size stored.sfs = str(sfs) msg.append(sfs) def __send_url( self, msg: Message, legacy_msg_id: str | None, uploaded_url: str, caption: str | None = None, carbon: bool = False, when: datetime | None = None, correction: bool = False, **kwargs: Any, # noqa:ANN401 ) -> list[Message]: msg["oob"]["url"] = uploaded_url msg["body"] = uploaded_url if "sfs" in msg: msg["fallback"].enable("body") msg["fallback"]["for"] = self.xmpp.plugin["xep_0447"].stanza.NAMESPACE if caption: if correction: if not legacy_msg_id: raise TypeError msg["replace"]["id"] = self._replace_id(legacy_msg_id) elif legacy_msg_id: self._set_msg_id(msg, legacy_msg_id) m1 = self._send(msg, carbon=carbon, correction=correction, **kwargs) m2 = self.send_text( caption, legacy_msg_id=None, when=when, carbon=carbon, **kwargs ) return [m1, m2] if m2 else [m1] else: if correction: if not legacy_msg_id: raise TypeError msg["replace"]["id"] = self._replace_id(legacy_msg_id) elif legacy_msg_id: self._set_msg_id(msg, legacy_msg_id) return [self._send(msg, carbon=carbon, **kwargs)] def __get_base_message( self, legacy_msg_id: str | None = None, reply_to: MessageReference | None = None, when: datetime | None = None, thread: str | None = None, carbon: bool = False, correction: bool = False, mto: JID | None = None, ) -> Message: if correction: if not legacy_msg_id: raise TypeError xmpp_ids = self._legacy_to_xmpp(legacy_msg_id) if xmpp_ids: original_xmpp_id = xmpp_ids[0] for xmpp_id in xmpp_ids: if xmpp_id == original_xmpp_id: continue self.retract(xmpp_id, thread) if reply_to is not None and reply_to.body: # We cannot have a "quote fallback" for attachments since most (all?) # XMPP clients will only treat a message as an attachment if the # body is the URL and nothing else. reply_to_for_attachment: MessageReference | None = MessageReference( reply_to.legacy_id, reply_to.author ) else: reply_to_for_attachment = reply_to return self._make_message( when=when, reply_to=reply_to_for_attachment, carbon=carbon, mto=mto, thread=thread, ) async def send_file( self, attachment: LegacyAttachment | Path | str, legacy_msg_id: str | None = None, *, reply_to: MessageReference | None = None, when: datetime | None = None, thread: str | None = None, **kwargs: Any, # noqa:ANN401 ) -> tuple[str | None, list[Message]]: """ Send a single file from this :term:`XMPP Entity`. :param attachment: The file to send. Ideally, a :class:`.LegacyAttachment` with a unique ``legacy_file_id`` attribute set, to optimise potential future reuses. It can also be: - a :class:`pathlib.Path` instance to point to a local file, or - a ``str``, representing a fetchable HTTP URL. :param legacy_msg_id: If you want to be able to transport read markers from the gateway user to the legacy network, specify this :param reply_to: Quote another message (:xep:`0461`) :param when: when the file was sent, for a "delay" tag (:xep:`0203`) :param thread: """ coro = self.__send_file( attachment, legacy_msg_id, reply_to=reply_to, when=when, thread=thread, **kwargs, ) if ( self.__is_component or not isinstance(attachment, LegacyAttachment) or attachment.legacy_file_id is None ): return await coro else: # prevents race conditions where we download the same thing several time # and end up attempting to insert it twice in the DB, raising an # IntegrityError. async with self.session.lock(("attachment", attachment.legacy_file_id)): return await coro async def __send_file( self, attachment: LegacyAttachment | Path | str, legacy_msg_id: str | None = None, *, reply_to: MessageReference | None = None, when: datetime | None = None, thread: str | None = None, store_multi: bool = True, carbon: bool = False, mto: JID | None = None, correction: bool = False, **send_kwargs: Any, # noqa:ANN401 ) -> tuple[str | None, list[Message]]: if isinstance(attachment, str): attachment = LegacyAttachment(url=attachment) elif isinstance(attachment, Path): attachment = LegacyAttachment(path=attachment) msg = self.__get_base_message( legacy_msg_id=legacy_msg_id, reply_to=reply_to, when=when, thread=thread, carbon=carbon, correction=correction, mto=mto, ) if attachment.is_sticker: msg.enable("sticker") stored = await self.__get_stored(attachment) if attachment.content_type is None and ( name := (attachment.name or attachment.url or attachment.path) ): attachment.content_type, _ = guess_type(name) if not attachment.name: if attachment.url: attachment.url.split("/")[-1] elif isinstance(attachment.path, Path): attachment.name = attachment.path.name else: attachment.name = uuid4().hex if attachment.content_type: ext = guess_extension(attachment.content_type) if ext: attachment.name += ext if stored.url: new_url = stored.url else: try: new_url = await self.__get_url(attachment, stored) except Exception as e: log.error("Error with attachment: %s: %s", attachment, e) log.debug("", exc_info=e) msg["body"] = ( f"/me tried to send a file ({attachment.format_for_user()}), " f"but something went wrong: {e}. " ) self._set_msg_id(msg, legacy_msg_id) return None, [self._send(msg, **send_kwargs)] stored.url = new_url attachment.url = new_url thumbnail = await self.__get_thumbnail(attachment) self.__set_sims(msg, new_url, attachment, stored, thumbnail) self.__set_sfs(msg, new_url, attachment, stored, thumbnail) if isinstance(attachment.path, Path) and is_temp_path( attachment.path, async_iterator_download_only=True ): attachment.path.unlink() attachment.path = None if not self.__is_component: with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(stored) orm.commit() msgs = self.__send_url( msg, legacy_msg_id, uploaded_url=new_url, caption=attachment.caption, carbon=carbon, when=when, correction=correction, **send_kwargs, ) if not self.__is_component and store_multi and legacy_msg_id: self.__store_multi(legacy_msg_id, msgs) return new_url, msgs def __send_body( self, body: str | None = None, legacy_msg_id: str | None = None, reply_to: MessageReference | None = None, when: datetime | None = None, thread: str | None = None, **kwargs: Any, # noqa:ANN401 ) -> Message | None: if body: return self.send_text( body, legacy_msg_id, reply_to=reply_to, when=when, thread=thread, **kwargs, ) else: return None async def send_files( self, attachments: Collection[LegacyAttachment], legacy_msg_id: str | None = None, body: str | None = None, *, reply_to: MessageReference | None = None, when: datetime | None = None, thread: str | None = None, body_first: bool = False, correction: bool = False, correction_event_id: str | None = None, **kwargs: Any, # noqa:ANN401 ) -> None: # TODO: once the epic XEP-0385 vs XEP-0447 battle is over, pick # one and stop sending several attachments this way # we attach the legacy_message ID to the last message we send, because # we don't want several messages with the same ID (especially for MUC MAM) if not attachments and not body: # ignoring empty message return body_msg_id = ( legacy_msg_id if body_needs_msg_id(attachments, body, body_first) else None ) send_body = functools.partial( self.__send_body, body=body, reply_to=reply_to, when=when, thread=thread, correction=correction, legacy_msg_id=body_msg_id, correction_event_id=correction_event_id, **kwargs, ) all_msgs = [] if body_first: all_msgs.append(send_body()) for i, attachment in enumerate(attachments): legacy = legacy_msg_id if i == 0 and body_msg_id is None else None _url, msgs = await self.send_file( attachment, legacy, reply_to=reply_to, when=when, thread=thread, store_multi=False, **kwargs, ) all_msgs.extend(msgs) if not body_first: all_msgs.append(send_body()) self.__store_multi(legacy_msg_id, all_msgs) def __store_multi( self, legacy_msg_id: str | None, all_msgs: Sequence[Message | None], ) -> None: if legacy_msg_id is None: return ids = [] for msg in all_msgs: if not msg: continue if stanza_id := msg.get_plugin("stanza_id", check=True): ids.append(stanza_id["id"]) else: ids.append(msg.get_id()) with self.xmpp.store.session() as orm: self.xmpp.store.id_map.set_msg( orm, self._recipient_pk(), str(legacy_msg_id), ids, self.is_participant ) orm.commit() async def __get_thumbnail(self, attachment: LegacyAttachment) -> Thumbnail | None: if attachment.content_type is None: return None if not (attachment.data or attachment.stream or attachment.path): return None if not attachment.content_type.startswith("image"): return None try: h, x, y = await self.xmpp.loop.run_in_executor( avatar_cache._thread_pool, get_thumbhash, attachment, ) except Exception as e: log.debug("Could not generate a thumbhash", exc_info=e) return None thumbnail = Thumbnail() thumbnail["width"] = x thumbnail["height"] = y thumbnail["media-type"] = "image/thumbhash" thumbnail["uri"] = "data:image/thumbhash;base64," + urlquote(h) return thumbnail class _AttachmentWithName(LegacyAttachment): name: str path: Path | None class _AttachmentWithMetadata(_AttachmentWithName): size: int content_type: str def _ensure_name(att: LegacyAttachment) -> _AttachmentWithName: if not att.name: if att.path: att.name = Path(att.path).name elif att.url: att.name = att.url.split("/")[-1] else: att.name = "unnamed-file" return cast(_AttachmentWithName, att) async def _ensure_metadata(att: _AttachmentWithName) -> _AttachmentWithMetadata: if att.size is None: if att.data: att.size = len(att.data) elif att.stream: att.stream.seek(0, io.SEEK_END) att.size = att.stream.tell() att.stream.seek(0) elif att.path: assert isinstance(att.path, Path) att.size = att.path.stat().st_size elif att.url: async with ( aiohttp.ClientSession() as http, http.head(att.url) as resp, ): att.size = resp.content_length elif att.aio_stream: warnings.warn("A size should be passed with async iterators") tmp_dir = Path(tempfile.mkdtemp(prefix=_TEMP_PREFIX)) with (tmp_dir / att.name).open("wb") as fp: async for chunk in att.aio_stream: fp.write(chunk) att.path = Path(fp.name) att.size = att.path.stat().st_size if not att.content_type: att.content_type, _encoding = guess_type(att.name) if not att.content_type: att.content_type = "application/octet-stream" return cast(_AttachmentWithMetadata, att) def body_needs_msg_id( attachments: Collection[LegacyAttachment], body: str | None, body_first: bool ) -> bool: if attachments: return bool(body and body_first) else: return True def get_thumbhash(att: LegacyAttachment) -> tuple[str, int, int]: img, width, height = get_image(att) rgba_2d = list(img.get_flattened_data()) rgba = list(chain(*rgba_2d)) # type:ignore[arg-type,var-annotated] ints = thumbhash.rgba_to_thumb_hash(img.width, img.height, rgba) return base64.b64encode(bytes(ints)).decode(), width, height def get_image(att: LegacyAttachment) -> tuple[Image.Image, int, int]: if att.data: img = Image.open(io.BytesIO(att.data)) elif att.path: with cast(Path, att.path).open("rb") as fp: img = Image.open(fp) img.load() elif att.stream: att.stream.seek(0) img = Image.open(att.stream) else: raise RuntimeError("No way to read the image") width, height = img.size rgba = img.convert("RGBA") if width > 100 or height > 100: rgba.thumbnail((100, 100)) return ImageOps.exif_transpose(rgba), width, height def is_temp_path(path: Path, async_iterator_download_only: bool = False) -> bool: try: rel = path.relative_to(_TEMP_ROOT) except ValueError: return False if async_iterator_download_only: return rel.parts[0].startswith(_TEMP_PREFIX) else: return True @contextlib.asynccontextmanager async def _get_data( att: LegacyAttachment, http: aiohttp.ClientSession ) -> AsyncIterator[bytes | IO[bytes] | AsyncIterator[bytes]]: if (data := att.data or att.stream or att.aio_stream) is not None: yield data elif att.path is not None: assert isinstance(att.path, Path) with att.path.open("rb") as fp: yield fp elif att.url is not None: async with http.get(att.url) as resp_get: resp_get.raise_for_status() yield resp_get.content.iter_any() else: raise RuntimeError("NEVER") _TEMP_ROOT = Path(tempfile.gettempdir()) _TEMP_PREFIX = "slidge-async-iterator-download" log = logging.getLogger(__name__) slidge/slidge/core/mixins/avatar.py000066400000000000000000000134151522535037600176770ustar00rootroot00000000000000import hashlib from asyncio import Task from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Optional from PIL import UnidentifiedImageError from slixmpp import JID from sqlalchemy.orm.exc import DetachedInstanceError from ...db.avatar import CachedAvatar, avatar_cache from ...db.models import Contact, Room from ...util.types import AnySession, Avatar from .db import UpdateInfoMixin if TYPE_CHECKING: from ..pubsub import PepAvatar class AvatarMixin(UpdateInfoMixin): """ Mixin for XMPP entities that have avatars that represent them. Both :py:class:`slidge.LegacyContact` and :py:class:`slidge.LegacyMUC` use :py:class:`.AvatarMixin`. """ jid: JID = NotImplemented session: AnySession = NotImplemented stored: Contact | Room log: Logger def __init__(self) -> None: super().__init__() self._set_avatar_task: Task[None] | None = None @property def avatar(self) -> Avatar | None: """ This property can be used to set or unset the avatar. Unlike the awaitable :func:`.set_avatar`, it schedules the update for later execution and is not blocking """ try: if self.stored.avatar is None: return None except DetachedInstanceError: self.merge() if self.stored.avatar is None: return None if self.stored.avatar.legacy_id is None: unique_id = None else: unique_id = self.stored.avatar.legacy_id return Avatar( unique_id=unique_id, url=self.stored.avatar.url, ) @avatar.setter def avatar(self, avatar: Avatar | Path | str | None) -> None: avatar = convert_avatar(avatar) if self._set_avatar_task: self._set_avatar_task.cancel() self.log.debug("Setting avatar with property") self._set_avatar_task = self.session.create_task(self.set_avatar(avatar)) async def __has_changed(self, avatar: Avatar | None) -> bool: if self.avatar is None: return avatar is not None if avatar is None: return self.avatar is not None if self.avatar.unique_id is not None and avatar.unique_id is not None: return self.avatar.unique_id != avatar.unique_id if ( self.avatar.url is not None and avatar.url is not None and self.avatar.url == avatar.url ): return await avatar_cache.url_modified(avatar.url) if avatar.path is not None: cached = self.get_cached_avatar() if cached is not None: return cached.path.read_bytes() != avatar.path.read_bytes() return True async def set_avatar( self, avatar: Avatar | Path | str | None = None, delete: bool = False ) -> None: """ Set an avatar for this entity :param avatar: The avatar. Should ideally come with a legacy network-wide unique ID :param delete: If the avatar is provided as a Path, whether to delete it once used or not. """ avatar = convert_avatar(avatar) if avatar is not None and avatar.unique_id is None and avatar.data is not None: self.log.debug("Hashing bytes to generate a unique ID") avatar = Avatar( data=avatar.data, unique_id=hashlib.sha512(avatar.data).hexdigest() ) try: if not await self.__has_changed(avatar): return except Exception: self.log.exception("Could not determine if avatar has changed, giving up") return if avatar is None: cached_avatar = None else: try: cached_avatar = await avatar_cache.convert_or_get(avatar) except UnidentifiedImageError: self.log.warning("%s is not a valid image", avatar) cached_avatar = None except Exception: self.log.exception("Failed to set avatar '%s'", avatar) cached_avatar = None if delete: if avatar is None or avatar.path is None: self.log.warning("Requested avatar path delete, but no path provided") else: avatar.path.unlink() stored_avatar = None if cached_avatar is None else cached_avatar.stored if not self._updating_info: with self.xmpp.store.session() as orm, orm.no_autoflush: self.stored = orm.merge(self.stored) orm.refresh(self.stored) self.stored.avatar = stored_avatar self.commit() self._post_avatar_update(cached_avatar) def get_cached_avatar(self) -> Optional["CachedAvatar"]: try: if self.stored.avatar is None: return None except DetachedInstanceError: self.merge() if self.stored.avatar is None: return None return avatar_cache.get(self.stored.avatar) def get_avatar(self) -> Optional["PepAvatar"]: cached_avatar = self.get_cached_avatar() if cached_avatar is None: return None from ..pubsub import PepAvatar item = PepAvatar() item.set_avatar_from_cache(cached_avatar) return item def _post_avatar_update(self, cached_avatar: Optional["CachedAvatar"]) -> None: raise NotImplementedError def convert_avatar( avatar: Avatar | Path | str | None, unique_id: str | None = None ) -> Avatar | None: if isinstance(avatar, Path): return Avatar(path=avatar, unique_id=unique_id) if isinstance(avatar, str): return Avatar(url=avatar) if avatar is None or all(x is None for x in avatar): return None return avatar slidge/slidge/core/mixins/base.py000066400000000000000000000014301522535037600173250ustar00rootroot00000000000000from abc import ABC from typing import TYPE_CHECKING, Any from slixmpp import JID from ...util.types import AnySession, MessageOrPresenceTypeVar if TYPE_CHECKING: from ...util.types import AnyGateway class Base(ABC): session: AnySession = NotImplemented xmpp: "AnyGateway" = NotImplemented jid: JID = NotImplemented name: str | None = NotImplemented @property def user_jid(self) -> JID: return self.session.user_jid @property def user_pk(self) -> int: return self.session.user_pk class BaseSender(Base): is_participant: bool = NotImplemented def _send( self, stanza: MessageOrPresenceTypeVar, **send_kwargs: Any, # noqa:ANN401 ) -> MessageOrPresenceTypeVar: raise NotImplementedError slidge/slidge/core/mixins/db.py000066400000000000000000000063721522535037600170120ustar00rootroot00000000000000import logging import typing from contextlib import contextmanager import sqlalchemy as sa from ...db.meta import Base, JSONSerializable from ...db.models import Contact, Room if typing.TYPE_CHECKING: from slidge.util.types import AnyGateway class DBMixin: stored: Base xmpp: "AnyGateway" log: logging.Logger def merge(self) -> None: with self.xmpp.store.session() as orm: self.stored = orm.merge(self.stored) def commit(self) -> None: with self.xmpp.store.session(expire_on_commit=False) as orm: self.log.debug("Merging %s", self.stored) self.stored = orm.merge(self.stored) self.log.debug("Merged %s", self.stored) orm.add(self.stored) self.log.debug("Committing to DB") orm.commit() class UpdateInfoMixin(DBMixin): """ This mixin just adds a context manager that prevents commiting to the DB on every attribute change. """ stored: Contact | Room xmpp: "AnyGateway" log: logging.Logger def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) self._updating_info = False self.__deserialize() def __deserialize(self) -> None: if self.stored.extra_attributes is not None: self.deserialize_extra_attributes(self.stored.extra_attributes) def refresh(self, attrs: list[str] | None = None) -> None: with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(self.stored) orm.refresh(self.stored, attribute_names=attrs) if attrs is None or "extra_attributes" in attrs: self.__deserialize() def serialize_extra_attributes(self) -> JSONSerializable | None: """ If you want custom attributes of your instance to be stored persistently to the DB, here is where you have to return them as a dict to be used in `deserialize_extra_attributes()`. """ return None def deserialize_extra_attributes(self, data: JSONSerializable) -> None: """ This is where you get the dict that you passed in `serialize_extra_attributes()`. ⚠ Since it is serialized as json, dictionary keys are converted to strings! Be sure to convert to other types if necessary. """ pass @contextmanager def updating_info(self) -> typing.Iterator[None]: self._updating_info = True yield self._updating_info = False self.stored.updated = True self.commit() def commit(self) -> None: if self._updating_info: self.log.debug("Not updating %s right now", self.stored) else: self.stored.extra_attributes = self.serialize_extra_attributes() super().commit() def update_stored_attribute(self, **kwargs: object) -> None: for key, value in kwargs.items(): setattr(self.stored, key, value) if self._updating_info: return with self.xmpp.store.session() as orm: orm.execute( sa.update(self.stored.__class__) .where(self.stored.__class__.id == self.stored.id) .values(**kwargs) ) orm.commit() slidge/slidge/core/mixins/disco.py000066400000000000000000000117571522535037600175310ustar00rootroot00000000000000from collections.abc import Mapping from typing import TYPE_CHECKING, Any, ClassVar from slixmpp.plugins.xep_0004.stanza.form import Form from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo from slixmpp.plugins.xep_0030.stanza.items import DiscoItems from slixmpp.types import OptJid from .base import Base if TYPE_CHECKING: from slidge.command.base import ContactCommand, MUCCommand class BaseDiscoMixin(Base): DISCO_TYPE: str = NotImplemented DISCO_CATEGORY: str = NotImplemented DISCO_NAME: str = NotImplemented DISCO_LANG = None commands: ClassVar[ Mapping[str, "type[ContactCommand[Any]] | type[MUCCommand[Any]]"] ] def _get_disco_name(self) -> str | None: if self.DISCO_NAME is NotImplemented: return self.xmpp.COMPONENT_NAME return self.DISCO_NAME or self.xmpp.COMPONENT_NAME def features(self) -> list[str]: return [] async def extended_features(self) -> list[Form] | None: return None async def get_disco_info( self, jid: OptJid = None, node: str | None = None ) -> DiscoInfo: info = DiscoInfo() if node == "http://jabber.org/protocol/commands": info.add_identity(category="automation", itype="command-list") elif node and node in self.commands: info.add_identity( category="automation", itype="command-node", name=self.commands[node].NAME, ) info.add_feature("http://jabber.org/protocol/commands") info.add_feature("jabber:x:data") else: for feature in self.features(): info.add_feature(feature) info.add_identity( category=self.DISCO_CATEGORY, itype=self.DISCO_TYPE, name=self._get_disco_name(), lang=self.DISCO_LANG, ) if forms := await self.extended_features(): for form in forms: info.append(form) return info async def get_caps_ver(self, jid: OptJid = None, node: str | None = None) -> str: info = await self.get_disco_info(jid, node) caps = self.xmpp.plugin["xep_0115"] ver = caps.generate_verstring(info, caps.hash) return ver # type:ignore[no-any-return] async def get_disco_items(self, node: str | None) -> DiscoItems: items = DiscoItems() if node == "http://jabber.org/protocol/commands": for node, command in self.commands.items(): items.add_item(jid=self.jid, node=node, name=command.NAME) return items class ChatterDiscoMixin(BaseDiscoMixin): AVATAR = True RECEIPTS = True MARKS = True CHAT_STATES = True UPLOAD = True CORRECTION = True REACTION = True RETRACTION = True REPLIES = True INVITATION_RECIPIENT = False DISCO_TYPE = "pc" DISCO_CATEGORY = "client" DISCO_NAME = "" is_participant: bool def features(self) -> list[str]: features = [] if self.CHAT_STATES: features.append("http://jabber.org/protocol/chatstates") if self.RECEIPTS: features.append("urn:xmpp:receipts") if self.CORRECTION: features.append("urn:xmpp:message-correct:0") if self.MARKS: features.append("urn:xmpp:chat-markers:0") if self.UPLOAD: features.append("jabber:x:oob") if self.REACTION: features.append("urn:xmpp:reactions:0") if self.RETRACTION: features.append("urn:xmpp:message-retract:0") if self.REPLIES: features.append("urn:xmpp:reply:0") if self.INVITATION_RECIPIENT: features.append("jabber:x:conference") features.append("urn:ietf:params:xml:ns:vcard-4.0") if not self.is_participant: features.append("http://jabber.org/protocol/commands") return features async def extended_features(self) -> list[Form] | None: f = getattr(self, "restricted_emoji_extended_feature", None) if f is None: return None e = await f() if not e: return None return [e] class ContactAccountDiscoMixin(BaseDiscoMixin): async def get_disco_info( self, jid: OptJid = None, node: str | None = None ) -> DiscoInfo: if jid and jid.resource: return await super().get_disco_info(jid, node) info = DiscoInfo() info.add_feature("http://jabber.org/protocol/pubsub") info.add_feature("http://jabber.org/protocol/pubsub#retrieve-items") info.add_feature("http://jabber.org/protocol/pubsub#subscribe") info.add_identity( category="account", itype="registered", name=self._get_disco_name(), lang=self.DISCO_LANG, ) info.add_identity( category="pubsub", itype="pep", name=self._get_disco_name(), lang=self.DISCO_LANG, ) return info slidge/slidge/core/mixins/message.py000066400000000000000000000210731522535037600200440ustar00rootroot00000000000000import logging import uuid import warnings from slixmpp import Iq, Message from slixmpp.plugins.xep_0004.stanza.form import Form from ...util.types import AnyMUC, ChatState, Marker from .attachment import AttachmentMixin from .message_maker import MessageMaker from .message_text import TextMessageMixin # this is for MDS PUBLISH_OPTIONS = Form() PUBLISH_OPTIONS["type"] = "submit" PUBLISH_OPTIONS.add_field( "FORM_TYPE", "hidden", value="http://jabber.org/protocol/pubsub#publish-options" ) PUBLISH_OPTIONS.add_field("pubsub#persist_items", value="true") PUBLISH_OPTIONS.add_field("pubsub#max_items", value="max") PUBLISH_OPTIONS.add_field("pubsub#send_last_published_item", value="never") PUBLISH_OPTIONS.add_field("pubsub#access_model", value="whitelist") class ChatStateMixin(MessageMaker): def __init__(self) -> None: super().__init__() self.__last_chat_state: ChatState | None = None def _chat_state( self, state: ChatState, forced: bool = False, **kwargs: object ) -> None: carbon = kwargs.get("carbon", False) if carbon or (state == self.__last_chat_state and not forced): return self.__last_chat_state = state msg = self._make_message(state=state, hints={"no-store"}) self._send(msg, **kwargs) def active(self, **kwargs: object) -> None: """ Send an "active" chat state (:xep:`0085`) from this :term:`XMPP Entity`. """ self._chat_state("active", forced=False, **kwargs) def composing(self, **kwargs: object) -> None: """ Send a "composing" (ie "typing notification") chat state (:xep:`0085`) from this :term:`XMPP Entity`. """ self._chat_state("composing", forced=True, **kwargs) def paused(self, **kwargs: object) -> None: """ Send a "paused" (ie "typing paused notification") chat state (:xep:`0085`) from this :term:`XMPP Entity`. """ self._chat_state("paused", forced=False, **kwargs) def inactive(self, **kwargs: object) -> None: """ Send an "inactive" (ie "contact has not interacted with the chat session interface for an intermediate period of time") chat state (:xep:`0085`) from this :term:`XMPP Entity`. """ self._chat_state("inactive", forced=False, **kwargs) def gone(self, **kwargs: object) -> None: """ Send a "gone" (ie "contact has not interacted with the chat session interface, system, or device for a relatively long period of time") chat state (:xep:`0085`) from this :term:`XMPP Entity`. """ self._chat_state("gone", forced=False, **kwargs) class MarkerMixin(MessageMaker): def _make_marker( self, legacy_msg_id: str, marker: Marker, carbon: bool = False ) -> Message: msg = self._make_message(carbon=carbon) msg[marker]["id"] = self._legacy_to_xmpp(legacy_msg_id)[-1] return msg def ack(self, legacy_msg_id: str, **kwargs: object) -> None: """ Send an "acknowledged" message marker (:xep:`0333`) from this :term:`XMPP Entity`. :param legacy_msg_id: The message this marker refers to """ self._send( self._make_marker( legacy_msg_id, "acknowledged", carbon=bool(kwargs.get("carbon")) ), **kwargs, ) def received(self, legacy_msg_id: str, **kwargs: object) -> None: """ Send a "received" message marker (:xep:`0333`) from this :term:`XMPP Entity`. If called on a :class:`LegacyContact`, also send a delivery receipt marker (:xep:`0184`). :param legacy_msg_id: The message this marker refers to """ carbon = bool(kwargs.get("carbon")) if self.mtype == "chat": for msg_id in self._legacy_to_xmpp(legacy_msg_id): self._send( self.xmpp.delivery_receipt.make_ack( msg_id, mfrom=self.jid, mto=self.user_jid, ) ) self._send( self._make_marker(legacy_msg_id, "received", carbon=carbon), **kwargs ) def displayed(self, legacy_msg_id: str, **kwargs: object) -> None: """ Send a "displayed" message marker (:xep:`0333`) from this :term:`XMPP Entity`. :param legacy_msg_id: The message this marker refers to """ if ( self.xmpp.MARK_ALL_MESSAGES and (muc := getattr(self, "muc", None)) and getattr(self, "is_user", False) ): muc_jid = muc.jid.bare with self.xmpp.store.session() as orm: if self.xmpp.store.mam.is_displayed_by_user( orm, muc_jid, str(legacy_msg_id) ): self.session.log.debug( "Ignoring carbon marker for message already displayed by user." ) return else: muc.pop_unread_xmpp_ids_up_to( self._legacy_to_xmpp(legacy_msg_id)[-1] ) self._send( self._make_marker( legacy_msg_id, "displayed", carbon=bool(kwargs.get("carbon")) ), **kwargs, ) if getattr(self, "is_user", False): self.session.create_task(self.__send_mds(legacy_msg_id)) async def __send_mds(self, legacy_msg_id: str) -> None: # Send a MDS displayed marker on behalf of the user for a group chat if muc := getattr(self, "muc", None): muc_jid = muc.jid.bare else: # This is not implemented for 1:1 chat because it would rely on # storing the XMPP-server injected stanza-id, which we don't track # ATM. # In practice, MDS should mostly be useful for public group chats, # so it should not be an issue. # We'll see if we need to implement that later return xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)[-1] iq = Iq(sto=self.user_jid.bare, sfrom=self.user_jid.bare, stype="set") iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0490"].stanza.NS iq["pubsub"]["publish"]["item"]["id"] = muc_jid displayed = self.xmpp.plugin["xep_0490"].stanza.Displayed() displayed["stanza_id"]["id"] = xmpp_msg_id displayed["stanza_id"]["by"] = muc_jid iq["pubsub"]["publish"]["item"]["payload"] = displayed iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS try: await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq) except Exception as e: self.session.log.debug("Could not MDS mark", exc_info=e) class ContentMessageMixin(AttachmentMixin, TextMessageMixin): pass class CarbonMessageMixin(ContentMessageMixin, MarkerMixin): def _privileged_send(self, msg: Message) -> None: i = msg.get_id() if i: self.session.ignore_messages.add(i) else: i = "slidge-carbon-" + str(uuid.uuid4()) msg.set_id(i) msg.del_origin_id() try: self.xmpp.plugin["xep_0356"].send_privileged_message(msg) except PermissionError: warnings.warn( f"Slidge does not have the privilege (XEP-0356) to send messages on behalf of {self.user_jid}. " "If this is a local user, consider configuring your XMPP server for that." ) class InviteMixin(MessageMaker): def invite_to( self, muc: AnyMUC, reason: str | None = None, password: str | None = None, **send_kwargs: object, ) -> None: """ Send an invitation to join a group (:xep:`0249`) from this :term:`XMPP Entity`. :param muc: the muc the user is invited to :param reason: a text explaining why the user should join this muc :param password: maybe this will make sense later? not sure :param send_kwargs: additional kwargs to be passed to _send() (internal use by slidge) """ msg = self._make_message(mtype="normal") msg["groupchat_invite"]["jid"] = muc.jid if reason: msg["groupchat_invite"]["reason"] = reason if password: msg["groupchat_invite"]["password"] = password self._send(msg, **send_kwargs) class MessageMixin(InviteMixin, ChatStateMixin, MarkerMixin, ContentMessageMixin): pass class MessageCarbonMixin(InviteMixin, ChatStateMixin, CarbonMessageMixin): pass log = logging.getLogger(__name__) slidge/slidge/core/mixins/message_maker.py000066400000000000000000000203631522535037600212240ustar00rootroot00000000000000import base64 import io import logging import uuid import warnings from collections.abc import Iterable from datetime import UTC, datetime from pathlib import Path from typing import cast from PIL import Image from slixmpp import Message from slixmpp.plugins.xep_0511.stanza import LinkMetadata from slixmpp.types import MessageTypes from slidge.util import strip_illegal_chars from ...db.models import GatewayUser from ...util.types import ( AnyMUC, AnyParticipant, ChatState, LinkPreview, MessageReference, ProcessingHint, ) from .. import config from .base import BaseSender class MessageMaker(BaseSender): mtype: MessageTypes = NotImplemented _can_send_carbon: bool = NotImplemented STRIP_SHORT_DELAY = False USE_STANZA_ID = False muc: AnyMUC def _recipient_pk(self) -> int: return ( self.muc.stored.id if self.is_participant else self.stored.id # type:ignore ) def _make_message( self, state: ChatState | None = None, hints: Iterable[ProcessingHint] = (), legacy_msg_id: str | None = None, when: datetime | None = None, reply_to: MessageReference | None = None, carbon: bool = False, link_previews: Iterable[LinkPreview] | None = None, **kwargs: object, ) -> Message: body = kwargs.pop("mbody", None) mfrom = kwargs.pop("mfrom", self.jid) mto = kwargs.pop("mto", None) thread = kwargs.pop("thread", None) # the msg needs to have jabber:client as xmlns, so # we don't want to associate with the XML stream msg_cls = Message if carbon and self._can_send_carbon else self.xmpp.Message msg = msg_cls( sfrom=mfrom, stype=kwargs.pop("mtype", None) or self.mtype, sto=mto, **kwargs, ) if body: assert isinstance(body, str) msg["body"] = strip_illegal_chars(body, "�") state = "active" if thread: with self.xmpp.store.session() as orm: thread_str = str(thread) msg["thread"] = ( self.xmpp.store.id_map.get_thread( orm, self._recipient_pk(), thread_str, self.is_participant, ) or thread_str ) if state: msg["chat_state"] = state for hint in hints: msg.enable(hint) self._set_msg_id(msg, legacy_msg_id) self._add_delay(msg, when) if link_previews: self._add_link_previews(msg, link_previews) if reply_to: self._add_reply_to(msg, reply_to) return msg def _set_msg_id(self, msg: Message, legacy_msg_id: str | None = None) -> None: if legacy_msg_id is not None: msg.set_id(legacy_msg_id) if self.USE_STANZA_ID: msg["stanza_id"]["id"] = legacy_msg_id msg["stanza_id"]["by"] = self.muc.jid elif self.USE_STANZA_ID: msg["stanza_id"]["id"] = str(uuid.uuid4()) msg["stanza_id"]["by"] = self.muc.jid def _legacy_to_xmpp(self, legacy_id: str) -> list[str]: with self.xmpp.store.session() as orm: ids = self.xmpp.store.id_map.get_xmpp( orm, self._recipient_pk(), str(legacy_id), self.is_participant, ) if ids: return ids return [legacy_id] def _add_delay(self, msg: Message, when: datetime | None) -> None: if when: if when.tzinfo is None: when = when.astimezone(UTC) if self.STRIP_SHORT_DELAY: delay = (datetime.now().astimezone(UTC) - when).seconds if delay < config.IGNORE_DELAY_THRESHOLD: return msg["delay"].set_stamp(when) msg["delay"].set_from(self.xmpp.boundjid.bare) def _add_reply_to(self, msg: Message, reply_to: MessageReference) -> None: xmpp_id = self._legacy_to_xmpp(reply_to.legacy_id)[0] msg["reply"]["id"] = xmpp_id muc = getattr(self, "muc", None) if entity := reply_to.author: if entity == "user" or isinstance(entity, GatewayUser): if isinstance(entity, GatewayUser): warnings.warn( "Using a GatewayUser as the author of a " "MessageReference is deprecated. Use the string 'user' " "instead.", DeprecationWarning, ) if muc: msg["reply"]["to"] = muc.user_muc_jid fallback_nick = muc.user_nick else: msg["reply"]["to"] = self.session.user_jid # TODO: here we should use preferably use the PEP nick of the user # (but it doesn't matter much) fallback_nick = self.session.user_jid.user else: if muc: if hasattr(entity, "muc"): # TODO: accept a Contact here and use muc.get_participant_by_legacy_id() # a bit of work because right now this is a sync function entity = cast(AnyParticipant, entity) fallback_nick = entity.nickname else: warnings.warn( "The author of a message reference in a MUC must be a" " Participant instance, not a Contact" ) fallback_nick = entity.name else: fallback_nick = entity.name msg["reply"]["to"] = entity.jid else: fallback_nick = None if fallback := reply_to.body: msg["reply"].add_quoted_fallback(fallback, fallback_nick) def _add_link_previews( self, msg: Message, link_previews: Iterable[LinkPreview] ) -> None: for preview in link_previews: if preview.is_empty: continue element = LinkMetadata() for i, name in enumerate(preview._fields): val = preview[i] if isinstance(val, Path): val = val.read_bytes() if isinstance(val, bytes): val = self._process_link_preview_image(val) if not val: continue element[name] = val msg.append(element) @staticmethod def _process_link_preview_image(data: bytes) -> str | None: # this will block the main thread. if this proves to be an issue in practice, # this could be rewritten to use the thread pool we use to resize avatars. try: image = Image.open(io.BytesIO(data)) except Exception: log.exception("Skipping link preview image") return None rewrite = False if image.format != "JPEG": rewrite = True if any(x > MAX_LINK_PREVIEW_IMAGE_SIZE for x in image.size): image.thumbnail((MAX_LINK_PREVIEW_IMAGE_SIZE, MAX_LINK_PREVIEW_IMAGE_SIZE)) rewrite = True if rewrite: with io.BytesIO() as f: image.save(f, format="JPEG") data = f.getvalue() return "data:image/jpeg;base64," + base64.b64encode(data).decode("utf-8") # Instead of having a hardcoded value for this, we would ideally use # XEP-0478: Stream Limits Advertisement to know which size is authorized. # However, this isn't possible until XEP-0225: Component Connections is a thing. # Prosody defaults to 512kb for s2s connection and 10Mb for c2s connections. # Some quick tests about JPEG image: # median size of 50 base64-encoded JPEG random RGB image # 128x128 pixels: 14kb ± 0.04 # 256x256 pixels: 53kb ± 0.06 # sounds like a good tradeoff # 384x384 pixels: 119kb ± 0.11 # 512x512 pixels: 211kb ± 0.13 # 640x640 pixels: 329kb ± 0.15 # 768x768 pixels: 473kb ± 0.21 # 896x896 pixels: 644kb ± 0.27 # 1024x1024 pixels: 841kb ± 0.22 MAX_LINK_PREVIEW_IMAGE_SIZE = 256 log = logging.getLogger(__name__) slidge/slidge/core/mixins/message_text.py000066400000000000000000000230441522535037600211100ustar00rootroot00000000000000import logging from collections.abc import Iterable from datetime import datetime from slixmpp import Message from ...util.archive_msg import HistoryMessage from ...util.types import ( LinkPreview, MessageReference, ProcessingHint, ) from ...util.util import add_quote_prefix, remove_emoji_variation_selector_16 from .message_maker import MessageMaker class TextMessageMixin(MessageMaker): def __default_hints( self, hints: Iterable[ProcessingHint] | None = None ) -> Iterable[ProcessingHint]: if hints is not None: return hints elif self.mtype == "chat": return {"markable", "store"} elif self.mtype == "groupchat": return {"markable"} else: raise RuntimeError("Never") def _replace_id(self, legacy_msg_id: str) -> str: if self.mtype == "groupchat": with self.xmpp.store.session() as orm: ids = self.xmpp.store.id_map.get_origin( orm, self._recipient_pk(), str(legacy_msg_id) ) if ids: if len(ids) > 1: log.warning( "More than 1 origin msg ID for '%s': '%s'", legacy_msg_id, ids, ) return ids[0] return legacy_msg_id else: return self._legacy_to_xmpp(legacy_msg_id)[0] def send_text( self, body: str, legacy_msg_id: str | None = None, *, when: datetime | None = None, reply_to: MessageReference | None = None, thread: str | None = None, hints: Iterable[ProcessingHint] | None = None, carbon: bool = False, archive_only: bool = False, correction: bool = False, correction_event_id: str | None = None, link_previews: list[LinkPreview] | None = None, **send_kwargs: object, ) -> Message | None: """ Send a text message from this :term:`XMPP Entity`. :param body: Content of the message :param legacy_msg_id: If you want to be able to transport read markers from the gateway user to the legacy network, specify this :param when: when the message was sent, for a "delay" tag (:xep:`0203`) :param reply_to: Quote another message (:xep:`0461`) :param hints: :param thread: :param carbon: (only used if called on a :class:`LegacyContact`) Set this to ``True`` if this is actually a message sent **to** the :class:`LegacyContact` by the :term:`User`. Use this to synchronize outgoing history for legacy official apps. :param correction: whether this message is a correction or not :param correction_event_id: in the case where an ID is associated with the legacy 'correction event', specify it here to use it on the XMPP side. If not specified, a random ID will be used. :param link_previews: A little of sender (or server, or gateway)-generated previews of URLs linked in the body. :param archive_only: (only in groups) Do not send this message to user, but store it in the archive. Meant to be used during ``MUC.backfill()`` """ if carbon and not self.is_participant: with self.xmpp.store.session() as orm: if not correction and self.xmpp.store.id_map.was_sent_by_user( orm, self._recipient_pk(), str(legacy_msg_id), False ): log.warning( "Carbon message for a message an XMPP has sent? This is a bug! %s", legacy_msg_id, ) return None hints = self.__default_hints(hints) msg = self._make_message( mbody=body, legacy_msg_id=correction_event_id if correction else legacy_msg_id, when=when, reply_to=reply_to, hints=hints or (), carbon=carbon, thread=thread, link_previews=link_previews, ) if correction: if not legacy_msg_id: raise TypeError msg["replace"]["id"] = self._replace_id(legacy_msg_id) return self._send( msg, archive_only=archive_only, carbon=carbon, legacy_msg_id=legacy_msg_id, **send_kwargs, ) def correct( self, legacy_msg_id: str, new_text: str, *, when: datetime | None = None, reply_to: MessageReference | None = None, thread: str | None = None, hints: Iterable[ProcessingHint] | None = None, carbon: bool = False, archive_only: bool = False, correction_event_id: str | None = None, link_previews: list[LinkPreview] | None = None, **send_kwargs: object, ) -> None: """ Modify a message that was previously sent by this :term:`XMPP Entity`. Uses last message correction (:xep:`0308`) :param new_text: New content of the message :param legacy_msg_id: The legacy message ID of the message to correct :param when: when the message was sent, for a "delay" tag (:xep:`0203`) :param reply_to: Quote another message (:xep:`0461`) :param hints: :param thread: :param carbon: (only in 1:1) Reflect a message sent to this ``Contact`` by the user. Use this to synchronize outgoing history for legacy official apps. :param archive_only: (only in groups) Do not send this message to user, but store it in the archive. Meant to be used during ``MUC.backfill()`` :param correction_event_id: in the case where an ID is associated with the legacy 'correction event', specify it here to use it on the XMPP side. If not specified, a random ID will be used. :param link_previews: A little of sender (or server, or gateway)-generated previews of URLs linked in the body. """ self.send_text( new_text, legacy_msg_id, when=when, reply_to=reply_to, hints=hints, carbon=carbon, thread=thread, correction=True, archive_only=archive_only, correction_event_id=correction_event_id, link_previews=link_previews, **send_kwargs, ) def react( self, legacy_msg_id: str, emojis: Iterable[str] = (), thread: str | None = None, **kwargs: object, ) -> None: """ Send a reaction (:xep:`0444`) from this :term:`XMPP Entity`. :param legacy_msg_id: The message which the reaction refers to. :param emojis: An iterable of emojis used as reactions :param thread: """ xmpp_id = kwargs.pop("xmpp_id", None) emojis = set(remove_emoji_variation_selector_16(e) for e in emojis) if xmpp_id: assert isinstance(xmpp_id, str) xmpp_ids = [xmpp_id] else: xmpp_ids = self._legacy_to_xmpp(legacy_msg_id) for xmpp_id in xmpp_ids: msg = self._make_message( hints={"store"}, carbon=bool(kwargs.get("carbon")), thread=thread ) self.xmpp.plugin["xep_0444"].set_reactions( msg, to_id=xmpp_id, reactions=emojis ) self.__add_reaction_fallback(msg, legacy_msg_id, emojis) self._send(msg, **kwargs) def __add_reaction_fallback( self, msg: Message, legacy_msg_id: str, emojis: Iterable[str] = (), ) -> None: if not self.session.user.preferences.get("reaction_fallback", False): return msg["fallback"]["for"] = self.xmpp.plugin["xep_0444"].namespace msg["fallback"].enable("body") msg["body"] = " ".join(emojis) if not self.is_participant: return with self.xmpp.store.session() as orm: archived = self.xmpp.store.mam.get_by_legacy_id( orm, self.muc.stored.id, str(legacy_msg_id) ) if archived is None: return history_msg = HistoryMessage(archived.stanza) msg["body"] = ( add_quote_prefix(history_msg.stanza["body"]) + "\n" + msg["body"] ) def retract( self, legacy_msg_id: str, thread: str | None = None, **kwargs: object, ) -> None: """ Send a message retraction (:XEP:`0424`) from this :term:`XMPP Entity`. :param legacy_msg_id: Legacy ID of the message to delete :param thread: """ xmpp_ids = self._legacy_to_xmpp(legacy_msg_id) replace_id = self._replace_id(legacy_msg_id) if replace_id not in xmpp_ids: xmpp_ids.append(replace_id) for xmpp_id in xmpp_ids: msg = self._make_message( state=None, hints={"store"}, mbody=f"/me retracted the message {legacy_msg_id}", carbon=bool(kwargs.get("carbon")), thread=thread, ) msg.enable("fallback") # namespace version mismatch between slidge and slixmpp, update me later msg["fallback"]["for"] = self.xmpp.plugin["xep_0424"].namespace[:-1] + "1" msg["retract"]["id"] = msg["replace"]["id"] = xmpp_id self._send(msg, **kwargs) log = logging.getLogger(__name__) slidge/slidge/core/mixins/presence.py000066400000000000000000000235601522535037600202270ustar00rootroot00000000000000import contextlib import re from asyncio import Task, sleep from datetime import UTC, datetime, timedelta from functools import partial from typing import TYPE_CHECKING from slixmpp import Presence from slixmpp.types import PresenceShows, PresenceTypes from sqlalchemy.orm.exc import DetachedInstanceError from ...db.models import Contact, Participant from ...util.types import AnySession, CachedPresence from .base import BaseSender from .db import DBMixin if TYPE_CHECKING: pass class _NoChange(Exception): pass _FRIEND_REQUEST_PRESENCES = {"subscribe", "unsubscribe", "subscribed", "unsubscribed"} _UPDATE_LAST_SEEN_FALLBACK_TASKS = dict[int, Task[None]]() _ONE_WEEK_SECONDS = 3600 * 24 * 7 async def _update_last_seen_fallback(session: AnySession, contact_pk: int) -> None: await sleep(_ONE_WEEK_SECONDS) with session.xmpp.store.session() as orm: stored = orm.get(Contact, contact_pk) if stored is None: return contact = session.contacts.from_store(stored) contact.send_last_presence(force=True, no_cache_online=False) def _clear_last_seen_task(contact_pk: int, _task: Task[None]) -> None: with contextlib.suppress(KeyError): del _UPDATE_LAST_SEEN_FALLBACK_TASKS[contact_pk] class PresenceMixin(BaseSender, DBMixin): _ONLY_SEND_PRESENCE_CHANGES = False # this attribute actually only exists for contacts and not participants _updating_info: bool stored: Contact | Participant def __init__(self, *a: object, **k: object) -> None: super().__init__(*a, **k) # this is only used when a presence is set during Contact.update_info(), # when the contact does not have a DB primary key yet, and is written # to DB at the end of update_info() self.cached_presence: CachedPresence | None = None def __is_contact(self) -> bool: return isinstance(self.stored, Contact) def __stored(self) -> Contact | None: if self.__is_contact(): assert isinstance(self.stored, Contact) return self.stored else: assert isinstance(self.stored, Participant) try: return self.stored.contact except DetachedInstanceError: with self.xmpp.store.session() as orm: orm.add(self.stored) if self.stored.contact is None: return None orm.refresh(self.stored.contact) orm.merge(self.stored) return self.stored.contact @property def __contact_pk(self) -> int | None: stored = self.__stored() return None if stored is None else stored.id def _get_last_presence(self) -> CachedPresence | None: stored = self.__stored() if stored is None or not stored.cached_presence: return None return CachedPresence( None if stored.last_seen is None else stored.last_seen.replace(tzinfo=UTC), stored.ptype, # type:ignore stored.pstatus, stored.pshow, # type:ignore ) def _store_last_presence(self, new: CachedPresence) -> None: if self.__is_contact(): contact = self elif (contact := getattr(self, "contact", None)) is None: # type:ignore[assignment] return contact.update_stored_attribute( # type:ignore[attr-defined] cached_presence=True, **new._asdict(), ) def _make_presence( self, *, last_seen: datetime | None = None, force: bool = False, bare: bool = False, ptype: PresenceTypes | None = None, pstatus: str | None = None, pshow: PresenceShows | None = None, ) -> Presence: if last_seen and last_seen.tzinfo is None: last_seen = last_seen.astimezone(UTC) old = self._get_last_presence() if ptype not in _FRIEND_REQUEST_PRESENCES: new = CachedPresence( last_seen=last_seen, ptype=ptype, pstatus=pstatus, pshow=pshow ) if old != new: if hasattr(self, "muc") and ptype == "unavailable": stored = self.__stored() if stored is not None: stored.cached_presence = False self.commit() else: self._store_last_presence(new) if old and not force and self._ONLY_SEND_PRESENCE_CHANGES: if old == new: self.session.log.debug("Presence is the same as cached") raise _NoChange self.session.log.debug( "Presence is not the same as cached: %s vs %s", old, new ) p = self.xmpp.make_presence( pfrom=self.jid.bare if bare else self.jid, ptype=ptype, pshow=pshow, pstatus=pstatus, ) if last_seen: # it's ugly to check for the presence of this string, but a better fix is more work if not re.match( ".*Last seen .*", p["status"] ) and self.session.user.preferences.get("last_seen_fallback", True): last_seen_fallback, recent = get_last_seen_fallback(last_seen) if p["status"]: p["status"] = p["status"] + " -- " + last_seen_fallback else: p["status"] = last_seen_fallback pk = self.__contact_pk if recent and pk is not None: # if less than a week, we use sth like 'Last seen: Monday, 8:05", # but if lasts more than a week, this is not very informative, so # we need to force resend an updated presence status task = _UPDATE_LAST_SEEN_FALLBACK_TASKS.get(pk) if task is not None: task.cancel() task = self.session.create_task( _update_last_seen_fallback(self.session, pk) ) _UPDATE_LAST_SEEN_FALLBACK_TASKS[pk] = task task.add_done_callback(partial(_clear_last_seen_task, pk)) p["idle"]["since"] = last_seen return p def send_last_presence( self, force: bool = False, no_cache_online: bool = False ) -> None: if (cache := self._get_last_presence()) is None: if force: if no_cache_online: self.online() else: self.offline() return self._send( self._make_presence( last_seen=cache.last_seen, force=True, ptype=cache.ptype, pshow=cache.pshow, pstatus=cache.pstatus, ) ) def online( self, status: str | None = None, last_seen: datetime | None = None, ) -> None: """ Send an "online" presence from this contact to the user. :param status: Arbitrary text, details of the status, eg: "Listening to Britney Spears" :param last_seen: For :xep:`0319` """ with contextlib.suppress(_NoChange): self._send(self._make_presence(pstatus=status, last_seen=last_seen)) def away( self, status: str | None = None, last_seen: datetime | None = None, ) -> None: """ Send an "away" presence from this contact to the user. This is a global status, as opposed to :meth:`.LegacyContact.inactive` which concerns a specific conversation, ie a specific "chat window" :param status: Arbitrary text, details of the status, eg: "Gone to fight capitalism" :param last_seen: For :xep:`0319` """ with contextlib.suppress(_NoChange): self._send( self._make_presence(pstatus=status, pshow="away", last_seen=last_seen) ) def extended_away( self, status: str | None = None, last_seen: datetime | None = None, ) -> None: """ Send an "extended away" presence from this contact to the user. This is a global status, as opposed to :meth:`.LegacyContact.inactive` which concerns a specific conversation, ie a specific "chat window" :param status: Arbitrary text, details of the status, eg: "Gone to fight capitalism" :param last_seen: For :xep:`0319` """ with contextlib.suppress(_NoChange): self._send( self._make_presence(pstatus=status, pshow="xa", last_seen=last_seen) ) def busy( self, status: str | None = None, last_seen: datetime | None = None, ) -> None: """ Send a "busy" (ie, "dnd") presence from this contact to the user, :param status: eg: "Trying to make sense of XEP-0100" :param last_seen: For :xep:`0319` """ with contextlib.suppress(_NoChange): self._send( self._make_presence(pstatus=status, pshow="dnd", last_seen=last_seen) ) def offline( self, status: str | None = None, last_seen: datetime | None = None, ) -> None: """ Send an "offline" presence from this contact to the user. :param status: eg: "Trying to make sense of XEP-0100" :param last_seen: For :xep:`0319` """ with contextlib.suppress(_NoChange): self._send( self._make_presence( pstatus=status, ptype="unavailable", last_seen=last_seen ) ) def get_last_seen_fallback(last_seen: datetime) -> tuple[str, bool]: now = datetime.now(tz=UTC) if now - last_seen < timedelta(days=7): return f"Last seen {last_seen:%A %H:%M %p GMT}", True else: return f"Last seen {last_seen:%b %-d %Y}", False slidge/slidge/core/mixins/recipient.py000066400000000000000000000074531522535037600204100ustar00rootroot00000000000000import abc from slixmpp import JID from slixmpp.plugins.xep_0004.stanza.form import Form from ...util.types import ChatState, Sticker, XMPPMessage class ReactionMixin: REACTIONS_SINGLE_EMOJI = False async def restricted_emoji_extended_feature(self) -> Form | None: available = await self.available_emojis() if not self.REACTIONS_SINGLE_EMOJI and available is None: return None form = Form() form["type"] = "result" form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:reactions:0:restrictions") if self.REACTIONS_SINGLE_EMOJI: form.add_field("max_reactions_per_user", value="1", type="text-single") if available: form.add_field("allowlist", value=list(available), type="text-multi") return form @abc.abstractmethod async def available_emojis( self, legacy_msg_id: str | None = None ) -> set[str] | None: """ Override this to restrict the subset of reactions this recipient can handle. :return: A set of emojis or None if any emoji is allowed """ return None class ThreadMixin: @abc.abstractmethod async def create_thread(self, xmpp_id: str) -> str: return xmpp_id class MessageMixin: @abc.abstractmethod async def on_message(self, message: XMPPMessage) -> str | None: """ Triggered when the user sends a message to this :term:`Recipient`. """ raise NotImplementedError @abc.abstractmethod async def on_sticker(self, sticker: Sticker) -> str | None: """ Triggered when the user sends a sticker to this :term:`Recipient`. :param sticker: The sticker sent by the user. :return: An ID of some sort that can be used later to ack and mark the message as read by the user """ raise NotImplementedError async def on_chat_state(self, chat_state: ChatState, thread: str | None) -> None: """ Triggered when the user sends a chat state (:xep:`0085`) to this :term:`Recipient`. :param chat_state: The sticker sent by the user. """ raise NotImplementedError async def on_displayed(self, legacy_msg_id: str, thread: str | None) -> None: """ Triggered when the user sends a read marker (:xep:`0333`) to this :term:`Recipient`. This is only possible if a valid ``legacy_msg_id`` was passed when transmitting a message from a legacy chat to the user, eg in :meth:`slidge.contact.LegacyContact.send_text` or :meth:`slidge.group.LegacyParticipant.send_text`. :param legacy_msg_id: Identifier of the message/ :param thread: """ raise NotImplementedError async def on_react( self, legacy_msg_id: str, emojis: list[str], thread: str | None ) -> None: """ Triggered when the user sends an emoji reaction (:xep:`0444`) to this :term:`Recipient`. :param legacy_msg_id: ID of the message the user reacts to :param emojis: Unicode characters representing reactions to the message ``legacy_msg_id``. An empty list means "no reaction", ie, remove all reactions if any were present before :param thread: """ raise NotImplementedError async def on_retract(self, legacy_msg_id: str, thread: str | None) -> None: """ Triggered when the user retracts (:xep:`0424`) a message. :param legacy_msg_id: Legacy ID of the retracted message :param thread: """ raise NotImplementedError class RecipientMixin(ReactionMixin, ThreadMixin, MessageMixin): jid: JID is_group: bool def __eq__(self, other: object) -> bool: return isinstance(other, self.__class__) and self.jid == other.jid slidge/slidge/core/pubsub.py000066400000000000000000000343141522535037600164130ustar00rootroot00000000000000import logging from copy import copy from pathlib import Path from typing import ClassVar from slixmpp import ( JID, CoroutineCallback, ElementBase, Iq, Presence, StanzaPath, register_stanza_plugin, ) from slixmpp.exceptions import IqError, IqTimeout, XMPPError from slixmpp.plugins.base import BasePlugin, register_plugin from slixmpp.plugins.xep_0060.stanza import Event, EventItem, EventItems, Item from slixmpp.plugins.xep_0084.stanza import Data as AvatarData from slixmpp.plugins.xep_0084.stanza import MetaData as AvatarMetadata from slixmpp.plugins.xep_0172.stanza import UserNick from slixmpp.plugins.xep_0292.stanza import VCard4 from slixmpp.plugins.xep_0402.stanza import Conference from slixmpp.types import JidStr, OptJidStr from ..contact import LegacyContact from ..db.avatar import CachedAvatar, avatar_cache from ..db.models import GatewayUser, Space from ..util.lock import NamedLockMixin from ..util.types import AnyGateway, AnySession VCARD4_NAMESPACE = "urn:xmpp:vcard4" class PepAvatar: def __init__(self) -> None: self.metadata: AvatarMetadata | None = None self.id: str | None = None self._avatar_data_path: Path | None = None @property def data(self) -> AvatarData | None: if self._avatar_data_path is None: return None data = AvatarData() data.set_value(self._avatar_data_path.read_bytes()) return data def set_avatar_from_cache(self, cached_avatar: CachedAvatar) -> None: metadata = AvatarMetadata() self.id = cached_avatar.hash metadata.add_info( id=cached_avatar.hash, itype="image/png", ibytes=cached_avatar.path.stat().st_size, height=str(cached_avatar.height), width=str(cached_avatar.width), ) self.metadata = metadata self._avatar_data_path = cached_avatar.path class PubSubComponent(NamedLockMixin, BasePlugin): xmpp: "AnyGateway" name = "pubsub" description = "Pubsub component" dependencies: ClassVar[set[str]] = { "xep_0030", "xep_0060", "xep_0115", "xep_0163", } default_config: ClassVar[dict[str, str | None]] = {"component_name": None} component_name: str def __init__(self, *a: object, **kw: object) -> None: super().__init__(*a, **kw) # slixmpp usually does this via self.xmpp["xep_0163"].register_pep(), # in the session_bind hook of plugins. However, for components, # session_bind is never called. register_stanza_plugin(EventItem, UserNick) register_stanza_plugin(EventItem, Conference) def plugin_init(self) -> None: self.xmpp.register_handler( CoroutineCallback( "pubsub_get_avatar_data", StanzaPath(f"iq@type=get/pubsub/items@node={AvatarData.namespace}"), self._get_avatar_data, # type:ignore ) ) self.xmpp.register_handler( CoroutineCallback( "pubsub_get_avatar_metadata", StanzaPath(f"iq@type=get/pubsub/items@node={AvatarMetadata.namespace}"), self._get_avatar_metadata, # type:ignore ) ) self.xmpp.register_handler( CoroutineCallback( "pubsub_get_vcard", StanzaPath(f"iq@type=get/pubsub/items@node={VCARD4_NAMESPACE}"), self._get_vcard, # type:ignore ) ) disco = self.xmpp.plugin["xep_0030"] disco.add_identity("pubsub", "pep", self.component_name) disco.add_identity("account", "registered", self.component_name) disco.add_feature("http://jabber.org/protocol/pubsub#event") disco.add_feature("http://jabber.org/protocol/pubsub#retrieve-items") disco.add_feature("http://jabber.org/protocol/pubsub#persistent-items") async def __get_features(self, presence: Presence) -> list[str]: from_ = presence.get_from() ver_string = presence["caps"]["ver"] if ver_string: info = await self.xmpp.plugin["xep_0115"].get_caps(from_) else: info = None if info is None: async with self.lock(from_): try: iq = await self.xmpp.plugin["xep_0030"].get_info(from_) except (IqError, IqTimeout): log.debug("Could not get disco#info of %s, ignoring", from_) return [] info = iq["disco_info"] return info["features"] # type:ignore[no-any-return] async def on_presence_available( self, p: Presence, contact: LegacyContact | None ) -> None: if "muc_join" in p: log.debug("Ignoring MUC presence here") return to = p.get_to() # we don't want to push anything for contacts that are not in the user's roster if to != self.xmpp.boundjid.bare and (contact is None or not contact.is_friend): return from_ = p.get_from() features = await self.__get_features(p) if AvatarMetadata.namespace + "+notify" in features: try: pep_avatar = await self._get_authorized_avatar(p, contact) except XMPPError: pass else: if pep_avatar.metadata is not None: await self.__broadcast( data=pep_avatar.metadata, from_=p.get_to().bare, to=from_, id=pep_avatar.metadata["info"]["id"], ) if UserNick.namespace + "+notify" in features: try: pep_nick = await self._get_authorized_nick(p, contact) except XMPPError: pass else: await self.__broadcast(data=pep_nick, from_=p.get_to(), to=from_) if contact is not None and VCARD4_NAMESPACE + "+notify" in features: vcard = await contact.get_vcard() if vcard is not None: await self.broadcast_vcard_event(p.get_to(), from_, vcard) async def broadcast_vcard_event(self, from_: JID, to: JID, vcard: VCard4) -> None: item = Item() item.namespace = VCARD4_NAMESPACE item["id"] = "current" # vcard: VCard4 = await self.xmpp["xep_0292_provider"].get_vcard(from_, to) # The vcard content should NOT be in this event according to the spec: # https://xmpp.org/extensions/xep-0292.html#sect-idm45669698174224 # but movim expects it to be here, and I guess it does not hurt log.debug("Broadcast vcard4 event: %s", vcard) await self.__broadcast( data=vcard, from_=JID(from_).bare, to=to, id="current", node=VCARD4_NAMESPACE, ) async def __get_contact(self, stanza: Iq | Presence) -> LegacyContact: session = self.xmpp.get_session_from_stanza(stanza) return await session.contacts.by_jid(stanza.get_to()) # type:ignore[no-any-return] async def _get_authorized_avatar( self, stanza: Iq | Presence, contact: LegacyContact | None = None ) -> PepAvatar: if stanza.get_to() == self.xmpp.boundjid.bare: item = PepAvatar() if self.xmpp.avatar is not None: item.set_avatar_from_cache(self.xmpp.avatar) return item if contact is None: contact = await self.__get_contact(stanza) item = PepAvatar() if contact.stored.avatar is not None: stored = avatar_cache.get(contact.stored.avatar) assert stored is not None item.set_avatar_from_cache(stored) return item async def _get_authorized_nick( self, stanza: Iq | Presence, contact: LegacyContact | None = None ) -> UserNick: if stanza.get_to() == self.xmpp.boundjid.bare: return get_user_nick(self.xmpp.COMPONENT_NAME) if contact is None: contact = await self.__get_contact(stanza) if contact.name is not None: return get_user_nick(contact.name) else: return UserNick() def __reply_with( self, iq: Iq, content: AvatarData | AvatarMetadata | None, item_id: str | None ) -> None: requested_items = iq["pubsub"]["items"] if len(requested_items) == 0: self._reply_with_payload(iq, content, item_id) else: for item in requested_items: if item["id"] == item_id: self._reply_with_payload(iq, content, item_id) return else: raise XMPPError("item-not-found") async def _get_avatar_data(self, iq: Iq) -> None: pep_avatar = await self._get_authorized_avatar(iq) self.__reply_with(iq, pep_avatar.data, pep_avatar.id) async def _get_avatar_metadata(self, iq: Iq) -> None: pep_avatar = await self._get_authorized_avatar(iq) self.__reply_with(iq, pep_avatar.metadata, pep_avatar.id) async def _get_vcard(self, iq: Iq) -> None: # this is not the proper way that clients should retrieve VCards, but # gajim does it this way. # https://xmpp.org/extensions/xep-0292.html#sect-idm45669698174224 session = self.xmpp.get_session_from_stanza(iq) contact = await session.contacts.by_jid(iq.get_to()) vcard = await contact.get_vcard() if vcard is None: raise XMPPError("item-not-found") self._reply_with_payload(iq, vcard, "current", VCARD4_NAMESPACE) @staticmethod def _reply_with_payload( iq: Iq, payload: AvatarMetadata | AvatarData | VCard4 | None, id_: str | None, namespace: str | None = None, ) -> None: result = iq.reply() item = Item() if payload: item.set_payload(payload.xml) item["id"] = id_ result["pubsub"]["items"]["node"] = ( namespace if namespace else payload.namespace ) result["pubsub"]["items"].append(item) result.send() async def __broadcast( self, data: ElementBase | None, from_: JidStr, to: OptJidStr = None, items: EventItems | None = None, **kwargs: object, ) -> None: from_ = JID(from_) if from_ != self.xmpp.boundjid.bare and to is not None: to = JID(to) session = self.xmpp.get_session_from_jid(to) if session is None: return await session.ready item = EventItem() if data: item.set_payload(data.xml) for k, v in kwargs.items(): item[k] = v if items is None: items = EventItems() items.append(item) assert data is not None items["node"] = kwargs.get("node") or data.namespace event = Event() event.append(items) msg = self.xmpp.Message() msg.set_type("headline") msg.set_from(from_) msg.append(event) if to is None: with self.xmpp.store.session() as orm: for u in orm.query(GatewayUser).all(): new_msg = copy(msg) new_msg.set_to(u.jid.bare) new_msg.send() else: msg.set_to(to) msg.send() async def broadcast_avatar( self, from_: JidStr, to: JidStr, cached_avatar: CachedAvatar | None ) -> None: if cached_avatar is None: await self.__broadcast(AvatarMetadata(), from_, to) else: pep_avatar = PepAvatar() pep_avatar.set_avatar_from_cache(cached_avatar) assert pep_avatar.metadata is not None await self.__broadcast( pep_avatar.metadata, from_, to, id=pep_avatar.metadata["info"]["id"] ) def broadcast_nick( self, user_jid: JID, jid: JidStr, nick: str | None = None, ) -> None: jid = JID(jid) nickname = get_user_nick(nick) log.debug("New nickname: %s", nickname) self.xmpp.loop.create_task(self.__broadcast(nickname, jid, user_jid.bare)) def broadcast_space(self, session: AnySession, space: Space) -> None: items = EventItems() items["node"] = str(space.legacy_id) for room in space.rooms: item = EventItem() item["id"] = room.jid item.enable("conference") if room.name: item["conference"]["name"] = room.name items.append(item) session.create_task( self.__broadcast( None, self.xmpp.boundjid.bare, session.user_jid, items=items, ) ) def broadcast_space_metadata( self, session: AnySession, space: Space, node: str ) -> None: msg = self.xmpp.make_message( mtype="headline", mto=session.user_jid, mfrom=self.xmpp.boundjid.bare ) msg["pubsub_event"]["configuration"]["node"] = node form = msg["pubsub_event"]["configuration"]["form"] form["type"] = "result" form.add_field( var="FORM_TYPE", ftype="hidden", value="http://jabber.org/protocol/pubsub#node_config", ) form.add_field(var="pubsub#title", value=space.name) # MUSTs form.add_field(var="pubsub#type", value="urn:xmpp:spaces:0") form.add_field(var="pubsub#notify_retract", value="true") form.add_field(var="pubsub#persist_items", value="true") form.add_field(var="pubsub#purge_offline", value="false") # SHOULDs form.add_field(var="pubsub#notify_sub", value="true") form.add_field(var="pubsub#notify_config", value="true") form.add_field(var="pubsub#notify_delete", value="true") form.add_field(var="pubsub#publish_model", value="publishers") # MUST (private spaces) form.add_field(var="pubsub#access_model", value="authorize") msg.send() def get_user_nick(nick: str | None = None) -> UserNick: user_nick = UserNick() if nick is not None: user_nick["nick"] = nick return user_nick log = logging.getLogger(__name__) register_plugin(PubSubComponent) slidge/slidge/core/session.py000066400000000000000000000427141522535037600166010ustar00rootroot00000000000000import abc import asyncio import logging from asyncio.tasks import Task from collections.abc import Coroutine from typing import TYPE_CHECKING, Any, Generic, NamedTuple, Self, cast import aiohttp import sqlalchemy as sa from slixmpp import JID, Iq, Message, Presence from slixmpp.exceptions import XMPPError from slixmpp.types import PresenceShows, ResourceDict from slidge.db.meta import JSONSerializable from ..command import SearchResult from ..contact import LegacyContact from ..db.models import Contact, GatewayUser from ..util import SubclassableOnce from ..util.lock import NamedLockMixin from ..util.types import ( AnyBookmarks, AnyMUC, AnyParticipant, AnyRoster, AnySession, LegacyContactType, PseudoPresenceShow, ) from ..util.util import noop_coro if TYPE_CHECKING: from .gateway import BaseGateway class CachedPresence(NamedTuple): status: str | None show: str | None kwargs: dict[str, Any] class BaseSession( Generic[LegacyContactType], NamedLockMixin, SubclassableOnce, abc.ABC ): """ The session of a registered :term:`User`. Represents a gateway user logged in to the legacy network and performing actions. Will be instantiated automatically on slidge startup for each registered user, or upon registration for new (validated) users. Must be subclassed for a functional :term:`Legacy Module`. """ """ Since we cannot set the XMPP ID of messages sent by XMPP clients, we need to keep a mapping between XMPP IDs and legacy message IDs if we want to further refer to a message that was sent by the user. This also applies to 'carboned' messages, ie, messages sent by the user from the official client of a legacy network. """ xmpp: "BaseGateway[Self]" """ The gateway instance singleton. Use it for low-level XMPP calls or custom methods that are not session-specific. """ MESSAGE_IDS_ARE_THREAD_IDS = False """ Set this to True if the legacy service uses message IDs as thread IDs, eg Mattermost, where you can only 'create a thread' by replying to the message, in which case the message ID is also a thread ID (and all messages are potential threads). """ SPECIAL_MSG_ID_PREFIX: str | None = None """ If you set this, XMPP message IDs starting with this won't be converted to legacy ID, but passed as is to :meth:`LegacyContact.on_react`, and usual checks for emoji restriction won't be applied. This can be used to implement voting in polls in a hacky way. """ _roster_cls: type[AnyRoster] _bookmarks_cls: type[AnyBookmarks] def __init__(self, user: GatewayUser) -> None: super().__init__() self.user = user """ The :term:`slidge user `. """ self.log = logging.getLogger(user.jid.bare) self.ignore_messages = set[str]() self.contacts: AnyRoster = self._roster_cls(self) self.is_logging_in = False self._logged = False self.__reset_ready() self.bookmarks = self._bookmarks_cls(self) self.thread_creation_lock = asyncio.Lock() self.__cached_presence: CachedPresence | None = None self.__tasks = set[asyncio.Task[Any]]() @property def user_jid(self) -> JID: return self.user.jid @property def user_pk(self) -> int: return self.user.id @property def http(self) -> aiohttp.ClientSession: return self.xmpp.http def __remove_task(self, fut: Task[Any]) -> None: self.log.debug("Removing fut %s", fut) self.__tasks.remove(fut) def create_task( self, coro: Coroutine[Any, Any, Any], name: str | None = None ) -> asyncio.Task[Any]: task = self.xmpp.loop.create_task(coro, name=name) self.__tasks.add(task) self.log.debug("Creating task %s", task) task.add_done_callback(lambda _: self.__remove_task(task)) return task def cancel_all_tasks(self) -> None: for task in self.__tasks: task.cancel() @abc.abstractmethod async def login(self) -> str | None: """ Logs in the gateway user to the legacy network. Triggered when the gateway start and on user registration. It is recommended that this function returns once the user is logged in, so if you need to await forever (for instance to listen to incoming events), it's a good idea to wrap your listener in an asyncio.Task. :return: Optionally, a text to use as the gateway status, e.g., "Connected as 'dude@legacy.network'" """ raise NotImplementedError async def logout(self) -> None: """ Logs out the gateway user from the legacy network. Called on gateway shutdown. """ raise NotImplementedError async def on_presence( self, resource: str, show: PseudoPresenceShow, status: str, resources: dict[str, ResourceDict], merged_resource: ResourceDict | None, ) -> None: """ Called when the gateway component receives a presence, ie, when one of the user's clients goes online of offline, or changes its status. :param resource: The XMPP client identifier, arbitrary string. :param show: The presence ````, if available. If the resource is just 'available' without any ```` element, this is an empty str. :param status: A status message, like a deeply profound quote, eg, "Roses are red, violets are blue, [INSERT JOKE]". :param resources: A summary of all the resources for this user. :param merged_resource: A global presence for the user account, following rules described in :meth:`merge_resources` """ raise NotImplementedError async def on_search(self, form_values: dict[str, str]) -> SearchResult | None: """ Triggered when the user uses Jabber Search (:xep:`0055`) on the component Form values is a dict in which keys are defined in :attr:`.BaseGateway.SEARCH_FIELDS` :param form_values: search query, defined for a specific plugin by overriding in :attr:`.BaseGateway.SEARCH_FIELDS` :return: """ raise NotImplementedError async def on_avatar( self, bytes_: bytes | None, hash_: str | None, type_: str | None, width: int | None, height: int | None, ) -> None: """ Triggered when the user uses modifies their avatar via :xep:`0084`. :param bytes_: The data of the avatar. According to the spec, this should always be a PNG, but some implementations do not respect that. If `None` it means the user has unpublished their avatar. :param hash_: The SHA1 hash of the avatar data. This is an identifier of the avatar. :param type_: The MIME type of the avatar. :param width: The width of the avatar image. :param height: The height of the avatar image. """ raise NotImplementedError async def on_create_group( self, name: str, contacts: list[LegacyContactType] ) -> str: """ Triggered when the user request the creation of a group via the dedicated :term:`Command`. :param name: Name of the group :param contacts: list of contacts that should be members of the group """ raise NotImplementedError async def on_leave_space(self, space_legacy_id: str) -> None: """ Triggered when the user sends a request to leave a :xep:`0503` space. :param space_legacy_id: The legacy ID of the space to leave """ raise NotImplementedError async def on_preferences( self, previous: dict[str, Any], new: dict[str, Any] ) -> None: """ This is called when the user updates their preferences. Override this if you need set custom preferences field and need to trigger something when a preference has changed. """ raise NotImplementedError def __reset_ready(self) -> None: self.ready = self.xmpp.loop.create_future() @property def logged(self) -> bool: return self._logged @logged.setter def logged(self, v: bool) -> None: self.is_logging_in = False self._logged = v if self.ready.done(): if v: return self.__reset_ready() self.shutdown(logout=False) with self.xmpp.store.session() as orm: self.xmpp.store.mam.reset_source(orm) self.xmpp.store.rooms.reset_updated(orm) self.xmpp.store.contacts.reset_updated(orm) orm.commit() else: if v: self.ready.set_result(True) def __repr__(self) -> str: return f"" def shutdown(self, logout: bool = True) -> asyncio.Task[None]: for m in self.bookmarks: m.shutdown() with self.xmpp.store.session() as orm: for jid in orm.execute( sa.select(Contact.jid).filter_by(user=self.user, is_friend=True) ).scalars(): pres = self.xmpp.make_presence( pfrom=jid, pto=self.user_jid, ptype="unavailable", pstatus="Gateway has shut down.", ) pres.send() if logout: return self.xmpp.loop.create_task(self.__logout()) else: return self.xmpp.loop.create_task(noop_coro()) async def __logout(self) -> None: try: await self.logout() except NotImplementedError: pass except KeyboardInterrupt: pass def raise_if_not_logged(self) -> None: if not self.logged: raise XMPPError( "internal-server-error", text="You are not logged to the legacy network", ) @classmethod def _from_user_or_none(cls, user: GatewayUser | None) -> Self: if user is None: log.debug("user not found") raise XMPPError(text="User not found", condition="subscription-required") session = _sessions.get(user.jid.bare) if session is None: _sessions[user.jid.bare] = session = cls(user) assert isinstance(session, cls) return session @classmethod def from_user(cls, user: GatewayUser) -> Self: return cls._from_user_or_none(user) @classmethod def from_stanza(cls, s: Message | Iq | Presence) -> Self: # """ # Get a user's :class:`.LegacySession` using the "from" field of a stanza # # Meant to be called from :class:`BaseGateway` only. # # :param s: # :return: # """ return cls.from_jid(s.get_from()) @classmethod def from_jid(cls, jid: JID) -> Self: # """ # Get a user's :class:`.LegacySession` using its jid # # Meant to be called from :class:`BaseGateway` only. # # :param jid: # :return: # """ session = _sessions.get(jid.bare) if session is not None: assert isinstance(session, cls) return session with cls.xmpp.store.session() as orm: user = orm.query(GatewayUser).filter_by(jid=jid.bare).one_or_none() return cls._from_user_or_none(user) @classmethod async def kill_by_jid(cls, jid: JID) -> None: # """ # Terminate a user session. # # Meant to be called from :class:`BaseGateway` only. # # :param jid: # :return: # """ log.debug("Killing session of %s", jid) for user_jid, session in _sessions.items(): if user_jid == jid.bare: break else: log.debug("Did not find a session for %s", jid) return for c in session.contacts: c.unsubscribe() for m in session.bookmarks: m.shutdown() try: session = _sessions.pop(jid.bare) except KeyError: log.warning("User not found during unregistration") return session.cancel_all_tasks() await cls.xmpp.unregister(cast(Self, session)) with cls.xmpp.store.session() as orm: orm.delete(session.user) orm.commit() def __ack(self, msg: Message) -> None: if not self.xmpp.PROPER_RECEIPTS: self.xmpp.delivery_receipt.ack(msg) def send_gateway_status( self, status: str | None = None, show: PresenceShows | None = None, **kwargs: Any, # noqa ) -> None: """ Send a presence from the gateway to the user. Can be used to indicate the user session status, ie "SMS code required", "connected", … :param status: A status message :param show: Presence stanza 'show' element. I suggest using "dnd" to show that the gateway is not fully functional """ self.__cached_presence = CachedPresence(status, show, kwargs) self.xmpp.send_presence( pto=self.user_jid.bare, pstatus=status, pshow=show, **kwargs ) def send_cached_presence(self, to: JID) -> None: if not self.__cached_presence: self.xmpp.send_presence(pto=to, ptype="unavailable") return self.xmpp.send_presence( pto=to, pstatus=self.__cached_presence.status, pshow=self.__cached_presence.show, **self.__cached_presence.kwargs, ) def send_gateway_message( self, text: str, **msg_kwargs: Any, # noqa ) -> None: """ Send a message from the gateway component to the user. Can be used to indicate the user session status, ie "SMS code required", "connected", … :param text: A text """ self.xmpp.send_text(text, mto=self.user_jid, **msg_kwargs) def send_gateway_invite( self, muc: AnyMUC, reason: str | None = None, password: str | None = None, ) -> None: """ Send an invitation to join a MUC, emanating from the gateway component. :param muc: :param reason: :param password: """ self.xmpp.invite_to(muc, reason=reason, password=password, mto=self.user_jid) async def input(self, text: str, **msg_kwargs: Any) -> str: # noqa """ Request user input via direct messages from the gateway component. Wraps call to :meth:`.BaseSession.input` :param text: The prompt to send to the user :param msg_kwargs: Extra attributes :return: """ return await self.xmpp.input(self.user_jid, text, **msg_kwargs) async def send_qr(self, text: str) -> None: """ Sends a QR code generated from 'text' via HTTP Upload and send the URL to ``self.user`` :param text: Text to encode as a QR code """ await self.xmpp.send_qr(text, mto=self.user_jid) async def get_contact_or_group_or_participant( self, jid: JID, create: bool = True ) -> "LegacyContact | AnyMUC | AnyParticipant | None": if (contact := self.contacts.by_jid_only_if_exists(jid)) is not None: return contact # type:ignore[no-any-return] if (muc := self.bookmarks.by_jid_only_if_exists(JID(jid.bare))) is not None: return await self.__get_muc_or_participant(muc, jid) else: muc = None if not create: return None try: return await self.contacts.by_jid(jid) # type:ignore[no-any-return] except XMPPError: if muc is None: try: muc = await self.bookmarks.by_jid(jid) except XMPPError: return None return await self.__get_muc_or_participant(muc, jid) @staticmethod async def __get_muc_or_participant( muc: AnyMUC, jid: JID ) -> "AnyMUC | AnyParticipant | None": if nick := jid.resource: return await muc.get_participant(nick, create=False, fill_first=True) return muc async def wait_for_ready(self, timeout: int | float | None = 10) -> None: # """ # Wait until session, contacts and bookmarks are ready # # (slidge internal use) # # :param timeout: # :return: # """ try: await asyncio.wait_for(asyncio.shield(self.ready), timeout) await asyncio.wait_for(asyncio.shield(self.contacts.ready), timeout) await asyncio.wait_for(asyncio.shield(self.bookmarks.ready), timeout) except TimeoutError: raise XMPPError( "recipient-unavailable", "Legacy session is not fully initialized, retry later", ) def legacy_module_data_update(self, data: JSONSerializable) -> None: user = self.user user.legacy_module_data.update(data) self.xmpp.store.users.update(user) def legacy_module_data_set(self, data: JSONSerializable) -> None: user = self.user user.legacy_module_data = data self.xmpp.store.users.update(user) def legacy_module_data_clear(self) -> None: user = self.user user.legacy_module_data.clear() self.xmpp.store.users.update(user) # keys = user.jid.bare _sessions: dict[str, AnySession] = {} log = logging.getLogger(__name__) slidge/slidge/db/000077500000000000000000000000001522535037600141715ustar00rootroot00000000000000slidge/slidge/db/__init__.py000066400000000000000000000001511522535037600162770ustar00rootroot00000000000000from .models import GatewayUser from .store import SlidgeStore __all__ = ("GatewayUser", "SlidgeStore") slidge/slidge/db/alembic/000077500000000000000000000000001522535037600155655ustar00rootroot00000000000000slidge/slidge/db/alembic/__init__.py000066400000000000000000000000001522535037600176640ustar00rootroot00000000000000slidge/slidge/db/alembic/env.py000066400000000000000000000031521522535037600167300ustar00rootroot00000000000000from alembic import context from slidge import global_config from slidge.db.meta import Base, get_engine config = context.config target_metadata = Base.metadata def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, render_as_batch=True, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ try: # in prod connectable = get_engine(global_config.DB_URL) except AttributeError: # during dev, to generate migrations connectable = get_engine("sqlite+pysqlite:///dev/slidge.sqlite") with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=True, ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() slidge/slidge/db/alembic/script.py.mako000066400000000000000000000011731522535037600203730ustar00rootroot00000000000000"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: Union[str, None] = ${repr(down_revision)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} def upgrade() -> None: ${upgrades if upgrades else "pass"} def downgrade() -> None: ${downgrades if downgrades else "pass"} slidge/slidge/db/alembic/versions/000077500000000000000000000000001522535037600174355ustar00rootroot00000000000000slidge/slidge/db/alembic/versions/259a1b71eadf_add_origin_group_id_table.py000066400000000000000000000026541522535037600271270ustar00rootroot00000000000000"""Add origin group ID table Revision ID: 259a1b71eadf Revises: cef02a8b1451 Create Date: 2025-11-26 06:50:04.509013 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "259a1b71eadf" down_revision: str | None = "cef02a8b1451" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.create_table( "group_msg_origin", sa.Column("foreign_key", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("xmpp_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["foreign_key"], ["room.id"], name=op.f("fk_group_msg_origin_foreign_key_room"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_group_msg_origin")), ) with op.batch_alter_table("group_msg_origin", schema=None) as batch_op: batch_op.create_index( "ix_group_msg_origin_legacy_id", ["legacy_id", "foreign_key"], unique=False ) # ### end Alembic commands ### def downgrade() -> None: with op.batch_alter_table("group_msg_origin", schema=None) as batch_op: batch_op.drop_index("ix_group_msg_origin_legacy_id") op.drop_table("group_msg_origin") # ### end Alembic commands ### slidge/slidge/db/alembic/versions/805fdc0bf984_add_unique_constraint_for_occupant_id.py000066400000000000000000000015251522535037600315200ustar00rootroot00000000000000"""Add unique constraint for occupant ID Revision ID: 805fdc0bf984 Revises: b7a06a86416a Create Date: 2025-12-17 19:55:11.058263 """ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. revision: str = "805fdc0bf984" down_revision: str | None = "b7a06a86416a" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: with op.batch_alter_table("participant", schema=None) as batch_op: batch_op.create_unique_constraint( "uq_participant_room_id_occupant_id", ["room_id", "occupant_id"] ) def downgrade() -> None: with op.batch_alter_table("participant", schema=None) as batch_op: batch_op.drop_constraint( batch_op.f("uq_participant_room_id_occupant_id"), type_="unique" ) slidge/slidge/db/alembic/versions/86d980dbb426_add_displayed_by_user_column_to_.py000066400000000000000000000020501522535037600303660ustar00rootroot00000000000000"""Add 'displayed_by_user' column to ArchivedMessage, for xmpp.MARK_ALL_MESSAGES Revision ID: 86d980dbb426 Revises: 259a1b71eadf Create Date: 2025-12-02 20:47:51.919688 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "86d980dbb426" down_revision: str | None = "259a1b71eadf" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: with op.batch_alter_table("mam", schema=None) as batch_op: batch_op.add_column(sa.Column("displayed_by_user", sa.Boolean())) # Set all existing rows to displayed, to avoid sending large busts of read markers # for messages archived before the introduction of the new column. mam = sa.table("mam", sa.column("displayed_by_user", sa.Boolean())) op.execute(mam.update().values(displayed_by_user=True)) def downgrade() -> None: with op.batch_alter_table("mam", schema=None) as batch_op: batch_op.drop_column("displayed_by_user") slidge/slidge/db/alembic/versions/abea9b63fa34_add_unique_constraint_on_avatar_url.py000066400000000000000000000016251522535037600313430ustar00rootroot00000000000000"""Add unique constraint on avatar URL Revision ID: abea9b63fa34 Revises: 805fdc0bf984 Create Date: 2026-02-09 20:20:53.576218 """ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. revision: str = "abea9b63fa34" down_revision: str | None = "805fdc0bf984" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.execute(""" DELETE FROM avatar WHERE id NOT IN ( SELECT MIN(id) FROM avatar GROUP BY url ); """) with op.batch_alter_table("avatar", schema=None) as batch_op: batch_op.create_unique_constraint(batch_op.f("uq_avatar_url"), ["url"]) def downgrade() -> None: with op.batch_alter_table("avatar", schema=None) as batch_op: batch_op.drop_constraint(batch_op.f("uq_avatar_url"), type_="unique") slidge/slidge/db/alembic/versions/b7a06a86416a_add_occupant_id_column_to_participant.py000066400000000000000000000017471522535037600314070ustar00rootroot00000000000000"""Add 'occupant_id' column to Participant Revision ID: b7a06a86416a Revises: 86d980dbb426 Create Date: 2025-12-14 21:39:45.025086 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "b7a06a86416a" down_revision: str | None = "86d980dbb426" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.execute("DELETE FROM participant") # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("participant", schema=None) as batch_op: batch_op.add_column(sa.Column("occupant_id", sa.String(), nullable=False)) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("participant", schema=None) as batch_op: batch_op.drop_column("occupant_id") # ### end Alembic commands ### slidge/slidge/db/alembic/versions/bdf1c8b85ea3_spaces.py000066400000000000000000000051711522535037600233300ustar00rootroot00000000000000"""Spaces Revision ID: bdf1c8b85ea3 Revises: e365e04e9ac3 Create Date: 2026-05-19 20:20:35.452386 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "bdf1c8b85ea3" down_revision: str | None = "e365e04e9ac3" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.create_table( "space", sa.Column("id", sa.Integer(), nullable=False), sa.Column("updated", sa.Boolean(), nullable=False), sa.Column("user_account_id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("name", sa.String(), nullable=True), sa.Column("description", sa.String(), nullable=True), sa.Column("member_count", sa.Integer(), nullable=True), sa.Column("creator_pk", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["creator_pk"], ["contact.id"], name=op.f("fk_space_creator_pk_contact") ), sa.ForeignKeyConstraint( ["user_account_id"], ["user_account.id"], name=op.f("fk_space_user_account_id_user_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_space")), sa.UniqueConstraint( "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id" ), ) op.create_table( "space_owner_association", sa.Column("space_id", sa.Integer(), nullable=False), sa.Column("contact_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["contact_id"], ["contact.id"], name=op.f("fk_space_owner_association_contact_id_contact"), ), sa.ForeignKeyConstraint( ["space_id"], ["space.id"], name=op.f("fk_space_owner_association_space_id_space"), ), sa.PrimaryKeyConstraint( "space_id", "contact_id", name=op.f("pk_space_owner_association") ), ) with op.batch_alter_table("room", schema=None) as batch_op: batch_op.add_column(sa.Column("space_id", sa.Integer(), nullable=True)) batch_op.create_foreign_key( batch_op.f("fk_room_space_id_space"), "space", ["space_id"], ["id"] ) def downgrade() -> None: with op.batch_alter_table("room", schema=None) as batch_op: batch_op.drop_constraint( batch_op.f("fk_room_space_id_space"), type_="foreignkey" ) batch_op.drop_column("space_id") op.drop_table("space_owner_association") op.drop_table("space") slidge/slidge/db/alembic/versions/cef02a8b1451_initial_schema.py000066400000000000000000000351011522535037600246450ustar00rootroot00000000000000"""Initial schema Revision ID: cef02a8b1451 Revises: Create Date: 2025-08-28 12:48:16.890606 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op import slidge # revision identifiers, used by Alembic. revision: str = "cef02a8b1451" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( "avatar", sa.Column("id", sa.Integer(), nullable=False), sa.Column("hash", sa.String(), nullable=False), sa.Column("height", sa.Integer(), nullable=False), sa.Column("width", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=True), sa.Column("url", sa.String(), nullable=True), sa.Column("etag", sa.String(), nullable=True), sa.Column("last_modified", sa.String(), nullable=True), sa.PrimaryKeyConstraint("id", name=op.f("pk_avatar")), sa.UniqueConstraint("hash", name=op.f("uq_avatar_hash")), sa.UniqueConstraint("legacy_id", name=op.f("uq_avatar_legacy_id")), ) op.create_table( "bob", sa.Column("id", sa.Integer(), nullable=False), sa.Column("file_name", sa.String(), nullable=False), sa.Column("sha_1", sa.String(), nullable=False), sa.Column("sha_256", sa.String(), nullable=False), sa.Column("sha_512", sa.String(), nullable=False), sa.Column("content_type", sa.String(), nullable=False), sa.PrimaryKeyConstraint("id", name=op.f("pk_bob")), sa.UniqueConstraint("sha_1", name=op.f("uq_bob_sha_1")), sa.UniqueConstraint("sha_256", name=op.f("uq_bob_sha_256")), sa.UniqueConstraint("sha_512", name=op.f("uq_bob_sha_512")), ) op.create_table( "user_account", sa.Column("id", sa.Integer(), nullable=False), sa.Column("jid", slidge.db.meta.JIDType(), nullable=False), sa.Column( "registration_date", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False, ), sa.Column( "legacy_module_data", slidge.db.meta.JSONEncodedDict(), nullable=False ), sa.Column("preferences", slidge.db.meta.JSONEncodedDict(), nullable=False), sa.Column("avatar_hash", sa.String(), nullable=True), sa.PrimaryKeyConstraint("id", name=op.f("pk_user_account")), sa.UniqueConstraint("jid", name=op.f("uq_user_account_jid")), ) op.create_table( "attachment", sa.Column("id", sa.Integer(), nullable=False), sa.Column("user_account_id", sa.Integer(), nullable=False), sa.Column("legacy_file_id", sa.String(), nullable=True), sa.Column("url", sa.String(), nullable=False), sa.Column("sims", sa.String(), nullable=True), sa.Column("sfs", sa.String(), nullable=True), sa.ForeignKeyConstraint( ["user_account_id"], ["user_account.id"], name=op.f("fk_attachment_user_account_id_user_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_attachment")), sa.UniqueConstraint( "user_account_id", "legacy_file_id", name="uq_attachment_user_account_id_legacy_file_id", ), ) with op.batch_alter_table("attachment", schema=None) as batch_op: batch_op.create_index( batch_op.f("ix_attachment_legacy_file_id"), ["legacy_file_id"], unique=False ) batch_op.create_index(batch_op.f("ix_attachment_url"), ["url"], unique=False) op.create_table( "contact", sa.Column("id", sa.Integer(), nullable=False), sa.Column("user_account_id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("jid", slidge.db.meta.JIDType(), nullable=False), sa.Column("avatar_id", sa.Integer(), nullable=True), sa.Column("nick", sa.String(), nullable=True), sa.Column("cached_presence", sa.Boolean(), nullable=False), sa.Column("last_seen", sa.DateTime(), nullable=True), sa.Column("ptype", sa.String(), nullable=True), sa.Column("pstatus", sa.String(), nullable=True), sa.Column("pshow", sa.String(), nullable=True), sa.Column("caps_ver", sa.String(), nullable=True), sa.Column("is_friend", sa.Boolean(), nullable=False), sa.Column("added_to_roster", sa.Boolean(), nullable=False), sa.Column("extra_attributes", slidge.db.meta.JSONEncodedDict(), nullable=True), sa.Column("updated", sa.Boolean(), nullable=False), sa.Column("vcard", sa.String(), nullable=True), sa.Column("vcard_fetched", sa.Boolean(), nullable=False), sa.Column( "client_type", sa.Enum( "bot", "console", "game", "handheld", "pc", "phone", "sms", "tablet", "web", native_enum=False, ), nullable=False, ), sa.ForeignKeyConstraint( ["avatar_id"], ["avatar.id"], name=op.f("fk_contact_avatar_id_avatar") ), sa.ForeignKeyConstraint( ["user_account_id"], ["user_account.id"], name=op.f("fk_contact_user_account_id_user_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_contact")), sa.UniqueConstraint( "user_account_id", "jid", name="uq_contact_user_account_id_jid" ), sa.UniqueConstraint( "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id" ), ) op.create_table( "room", sa.Column("id", sa.Integer(), nullable=False), sa.Column("user_account_id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("jid", slidge.db.meta.JIDType(), nullable=False), sa.Column("avatar_id", sa.Integer(), nullable=True), sa.Column("name", sa.String(), nullable=True), sa.Column("description", sa.String(), nullable=True), sa.Column("subject", sa.String(), nullable=True), sa.Column("subject_date", sa.DateTime(), nullable=True), sa.Column("subject_setter", sa.String(), nullable=True), sa.Column("n_participants", sa.Integer(), nullable=True), sa.Column( "muc_type", sa.Enum("GROUP", "CHANNEL", "CHANNEL_NON_ANONYMOUS", name="muctype"), nullable=False, ), sa.Column("user_nick", sa.String(), nullable=True), sa.Column("user_resources", sa.String(), nullable=True), sa.Column("participants_filled", sa.Boolean(), nullable=False), sa.Column("history_filled", sa.Boolean(), nullable=False), sa.Column("extra_attributes", slidge.db.meta.JSONEncodedDict(), nullable=True), sa.Column("updated", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint( ["avatar_id"], ["avatar.id"], name=op.f("fk_room_avatar_id_avatar") ), sa.ForeignKeyConstraint( ["user_account_id"], ["user_account.id"], name=op.f("fk_room_user_account_id_user_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_room")), sa.UniqueConstraint( "user_account_id", "jid", name="uq_room_user_account_id_jid" ), sa.UniqueConstraint( "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id" ), ) op.create_table( "contact_sent", sa.Column("id", sa.Integer(), nullable=False), sa.Column("contact_id", sa.Integer(), nullable=False), sa.Column("msg_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["contact_id"], ["contact.id"], name=op.f("fk_contact_sent_contact_id_contact"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_contact_sent")), sa.UniqueConstraint( "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" ), ) op.create_table( "direct_msg", sa.Column("foreign_key", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("xmpp_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["foreign_key"], ["contact.id"], name=op.f("fk_direct_msg_foreign_key_contact"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_direct_msg")), ) with op.batch_alter_table("direct_msg", schema=None) as batch_op: batch_op.create_index( "ix_direct_msg_legacy_id", ["legacy_id", "foreign_key"], unique=False ) op.create_table( "direct_thread", sa.Column("foreign_key", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("xmpp_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["foreign_key"], ["contact.id"], name=op.f("fk_direct_thread_foreign_key_contact"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_direct_thread")), ) with op.batch_alter_table("direct_thread", schema=None) as batch_op: batch_op.create_index( "ix_direct_direct_thread_id", ["legacy_id", "foreign_key"], unique=False ) op.create_table( "group_msg", sa.Column("foreign_key", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("xmpp_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["foreign_key"], ["room.id"], name=op.f("fk_group_msg_foreign_key_room") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_group_msg")), ) with op.batch_alter_table("group_msg", schema=None) as batch_op: batch_op.create_index( "ix_group_msg_legacy_id", ["legacy_id", "foreign_key"], unique=False ) op.create_table( "group_thread", sa.Column("foreign_key", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False), sa.Column("legacy_id", sa.String(), nullable=False), sa.Column("xmpp_id", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["foreign_key"], ["room.id"], name=op.f("fk_group_thread_foreign_key_room") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_group_thread")), ) with op.batch_alter_table("group_thread", schema=None) as batch_op: batch_op.create_index( "ix_direct_group_thread_id", ["legacy_id", "foreign_key"], unique=False ) op.create_table( "mam", sa.Column("id", sa.Integer(), nullable=False), sa.Column("room_id", sa.Integer(), nullable=False), sa.Column("stanza_id", sa.String(), nullable=False), sa.Column("timestamp", sa.DateTime(), nullable=False), sa.Column("author_jid", slidge.db.meta.JIDType(), nullable=False), sa.Column( "source", sa.Enum("LIVE", "BACKFILL", name="archivedmessagesource"), nullable=False, ), sa.Column("legacy_id", sa.String(), nullable=True), sa.Column("stanza", sa.String(), nullable=False), sa.ForeignKeyConstraint( ["room_id"], ["room.id"], name=op.f("fk_mam_room_id_room") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_mam")), sa.UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"), ) op.create_table( "participant", sa.Column("id", sa.Integer(), nullable=False), sa.Column("room_id", sa.Integer(), nullable=False), sa.Column("contact_id", sa.Integer(), nullable=True), sa.Column("is_user", sa.Boolean(), nullable=False), sa.Column( "affiliation", sa.Enum("outcast", "member", "admin", "owner", "none", native_enum=False), nullable=False, ), sa.Column( "role", sa.Enum("moderator", "participant", "visitor", "none", native_enum=False), nullable=False, ), sa.Column("presence_sent", sa.Boolean(), nullable=False), sa.Column("resource", sa.String(), nullable=False), sa.Column("nickname", sa.String(), nullable=False), sa.Column("nickname_no_illegal", sa.String(), nullable=False), sa.Column("hats", sa.JSON(), nullable=False), sa.Column("extra_attributes", slidge.db.meta.JSONEncodedDict(), nullable=True), sa.ForeignKeyConstraint( ["contact_id"], ["contact.id"], name=op.f("fk_participant_contact_id_contact"), ), sa.ForeignKeyConstraint( ["room_id"], ["room.id"], name=op.f("fk_participant_room_id_room") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_participant")), sa.UniqueConstraint( "room_id", "contact_id", name="uq_participant_room_id_contact_id" ), sa.UniqueConstraint( "room_id", "resource", name="uq_participant_room_id_resource" ), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.drop_table("participant") op.drop_table("mam") with op.batch_alter_table("group_thread", schema=None) as batch_op: batch_op.drop_index("ix_direct_group_thread_id") op.drop_table("group_thread") with op.batch_alter_table("group_msg", schema=None) as batch_op: batch_op.drop_index("ix_group_msg_legacy_id") op.drop_table("group_msg") with op.batch_alter_table("direct_thread", schema=None) as batch_op: batch_op.drop_index("ix_direct_direct_thread_id") op.drop_table("direct_thread") with op.batch_alter_table("direct_msg", schema=None) as batch_op: batch_op.drop_index("ix_direct_msg_legacy_id") op.drop_table("direct_msg") op.drop_table("contact_sent") op.drop_table("room") op.drop_table("contact") with op.batch_alter_table("attachment", schema=None) as batch_op: batch_op.drop_index(batch_op.f("ix_attachment_url")) batch_op.drop_index(batch_op.f("ix_attachment_legacy_file_id")) op.drop_table("attachment") op.drop_table("user_account") op.drop_table("bob") op.drop_table("avatar") # ### end Alembic commands ### slidge/slidge/db/alembic/versions/e365e04e9ac3_fix_missing_existing_constraints_in_.py000066400000000000000000000041141522535037600314140ustar00rootroot00000000000000"""Fix missing existing constraints in SQLite Revision ID: e365e04e9ac3 Revises: abea9b63fa34 Create Date: 2026-02-11 19:57:06.448033 """ import logging from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "e365e04e9ac3" down_revision: str | None = "abea9b63fa34" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: fix_constraints("contact", "user_account_id", "jid", "legacy_id") fix_constraints("contact_sent", "contact_id", "msg_id") fix_constraints("mam", "room_id", "stanza_id") fix_constraints("attachment", "user_account_id", "legacy_file_id") fix_constraints("participant", "room_id", "resource", "contact_id", "occupant_id") def fix_constraints(table: str, col1: str, *cols: str) -> None: bogus_name = f"uq_{table}_{col1}" inspector = sa.inspect(op.get_bind()) constraints = inspector.get_unique_constraints(table) constraint_exists = any( constraint["name"] == bogus_name for constraint in constraints ) if not constraint_exists: # If this runs on a newly created DB, this is unnecessary because we actually # edited past migration to allow running with postgresql. logging.info(f"No need to fix constraints for {bogus_name}") return with op.batch_alter_table(table, schema=None) as batch_op: logging.info(f"Dropping constraint {bogus_name}") batch_op.drop_constraint(bogus_name, type_="unique") for col2 in cols: op.execute(f""" DELETE FROM '{table}' WHERE id NOT IN ( SELECT MIN(id) FROM '{table}' GROUP BY '{col1}', '{col2}' ); """) proper_name = f"uq_{table}_{col1}_{col2}" logging.info(f"Creating constraint {proper_name}") batch_op.create_unique_constraint(proper_name, [col1, col2]) def downgrade() -> None: raise RuntimeError("Downgrade not supported!") slidge/slidge/db/avatar.py000066400000000000000000000206051522535037600160240ustar00rootroot00000000000000import asyncio import hashlib import io import logging from concurrent.futures import ThreadPoolExecutor from http import HTTPStatus from pathlib import Path import aiohttp from multidict import CIMultiDictProxy from PIL.Image import Image from PIL.Image import open as open_image from sqlalchemy import select from ..core import config from ..util.lock import NamedLockMixin from ..util.types import Avatar as AvatarType from .models import Avatar from .store import AvatarStore class CachedAvatar: def __init__(self, stored: Avatar, root_dir: Path) -> None: self.stored = stored self._root = root_dir @property def pk(self) -> int | None: return self.stored.id @property def hash(self) -> str: return self.stored.hash @property def height(self) -> int: return self.stored.height @property def width(self) -> int: return self.stored.width @property def etag(self) -> str | None: return self.stored.etag @property def last_modified(self) -> str | None: return self.stored.last_modified @property def data(self) -> bytes: return self.path.read_bytes() @property def path(self) -> Path: return (self._root / self.hash).with_suffix(".png") class NotModified(Exception): pass class AvatarCache(NamedLockMixin): dir: Path http: aiohttp.ClientSession store: AvatarStore def __init__(self) -> None: self._thread_pool = ThreadPoolExecutor(config.AVATAR_RESAMPLING_THREADS) super().__init__() def get(self, stored: Avatar) -> CachedAvatar: return CachedAvatar(stored, self.dir) def set_dir(self, path: Path) -> None: self.dir = path self.dir.mkdir(exist_ok=True) log.debug("Checking avatar files") with self.store.session(expire_on_commit=False) as orm: for stored in orm.query(Avatar).all(): avatar = CachedAvatar(stored, path) if avatar.path.exists(): continue log.warning( "Removing avatar %s from store because %s does not exist", avatar.hash, avatar.path, ) orm.delete(stored) orm.commit() def close(self) -> None: self._thread_pool.shutdown(cancel_futures=True) def __get_http_headers( self, cached: CachedAvatar | Avatar | None = None ) -> dict[str, str]: headers = {} if cached and (self.dir / cached.hash).with_suffix(".png").exists(): if last_modified := cached.last_modified: headers["If-Modified-Since"] = last_modified if etag := cached.etag: headers["If-None-Match"] = etag return headers async def __download( self, url: str, headers: dict[str, str], ) -> tuple[Image, CIMultiDictProxy[str]]: async with self.http.get(url, headers=headers) as response: if response.status == HTTPStatus.NOT_MODIFIED: log.debug("Using avatar cache for %s", url) raise NotModified response.raise_for_status() return ( open_image(io.BytesIO(await response.read())), response.headers, ) async def __is_modified(self, url: str, headers: dict[str, str]) -> bool: async with self.http.head(url, headers=headers) as response: return response.status != HTTPStatus.NOT_MODIFIED async def url_modified(self, url: str) -> bool: with self.store.session() as orm: cached = orm.query(Avatar).filter_by(url=url).one_or_none() if cached is None: return True headers = self.__get_http_headers(cached) return await self.__is_modified(url, headers) @staticmethod async def _get_image(avatar: AvatarType) -> Image: if avatar.data is not None: return open_image(io.BytesIO(avatar.data)) elif avatar.path is not None: return open_image(avatar.path) raise TypeError("Avatar must be bytes or a Path", avatar) async def convert_or_get(self, avatar: AvatarType) -> CachedAvatar: if avatar.unique_id is not None: with self.store.session() as orm: stored = ( orm.query(Avatar) .filter_by(legacy_id=str(avatar.unique_id)) .one_or_none() ) if stored is not None: return self.get(stored) if avatar.url is not None: return await self.__convert_url(avatar) return await self.convert(avatar, await self._get_image(avatar)) async def __convert_url(self, avatar: AvatarType) -> CachedAvatar: assert avatar.url is not None async with self.lock(avatar.unique_id or avatar.url): with self.store.session() as orm: if avatar.unique_id is None: stored = orm.query(Avatar).filter_by(url=avatar.url).one_or_none() else: stored = ( orm.query(Avatar) .filter_by(legacy_id=str(avatar.unique_id)) .one_or_none() ) if stored is not None: return self.get(stored) try: img, response_headers = await self.__download( avatar.url, self.__get_http_headers(stored) ) except NotModified: assert stored is not None return self.get(stored) return await self.convert(avatar, img, response_headers) async def convert( self, avatar: AvatarType, img: Image, response_headers: CIMultiDictProxy[str] | None = None, ) -> CachedAvatar: resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size) if resize: await asyncio.get_event_loop().run_in_executor( self._thread_pool, img.thumbnail, (size, size) ) log.debug("Resampled image to %s", img.size) if ( not resize and img.format == "PNG" and avatar.path is not None and avatar.path.exists() ): img_bytes = avatar.path.read_bytes() else: with io.BytesIO() as f: img.save(f, format="PNG") img_bytes = f.getvalue() hash_ = hashlib.sha1(img_bytes).hexdigest() file_path = (self.dir / hash_).with_suffix(".png") if file_path.exists(): log.warning("Overwriting %s", file_path) with file_path.open("wb") as file: file.write(img_bytes) with self.store.session(expire_on_commit=False) as orm: stored = orm.execute(select(Avatar).where(Avatar.hash == hash_)).scalar() if stored is not None: if ( avatar.unique_id is not None and str(avatar.unique_id) != stored.legacy_id ): log.warning( "Updating the 'unique' ID of an avatar, was '%s', is now '%s'", stored.legacy_id, avatar.unique_id, ) stored.legacy_id = str(avatar.unique_id) orm.add(stored) orm.commit() return self.get(stored) stored = Avatar( hash=hash_, height=img.height, width=img.width, url=avatar.url, legacy_id=avatar.unique_id, ) if response_headers: stored.etag = response_headers.get("etag") stored.last_modified = response_headers.get("last-modified") with self.store.session(expire_on_commit=False) as orm: if avatar.url is not None: existing = orm.execute( select(Avatar).filter_by(url=avatar.url) ).scalar_one_or_none() if existing is not None: orm.delete(existing) orm.commit() orm.add(stored) orm.commit() return self.get(stored) avatar_cache = AvatarCache() log = logging.getLogger(__name__) _download_lock = asyncio.Lock() __all__ = ( "CachedAvatar", "avatar_cache", ) slidge/slidge/db/meta.py000066400000000000000000000043601522535037600154740ustar00rootroot00000000000000from __future__ import annotations import json from typing import ClassVar, Union import sqlalchemy as sa from slixmpp import JID from sqlalchemy import Dialect class JIDType(sa.TypeDecorator[JID]): """ Custom SQLAlchemy type for JIDs """ impl = sa.types.TEXT cache_ok = True def process_bind_param(self, value: JID | None, dialect: sa.Dialect) -> str | None: if value is None: return value return str(value) def process_result_value( self, value: str | None, dialect: sa.Dialect ) -> JID | None: if value is None: return value return JID(value) JSONSerializableTypes = Union[str, float, None, "JSONSerializable", bool] JSONSerializable = dict[str, JSONSerializableTypes] class JSONEncodedDict(sa.TypeDecorator[JSONSerializable]): """ Custom SQLAlchemy type for dictionaries stored as JSON Note that mutations of the dictionary are not detected by SQLAlchemy, which is why use ``attributes.flag_modified()`` in ``UserStore.update()`` """ impl = sa.VARCHAR cache_ok = True def process_bind_param( self, value: JSONSerializable | None, dialect: Dialect ) -> str | None: if value is None: return None return json.dumps(value) def process_result_value( self, value: object, dialect: Dialect ) -> JSONSerializable | None: if value is None: return None return json.loads(value) # type:ignore class Base(sa.orm.DeclarativeBase): type_annotation_map: ClassVar[dict[object, object]] = { JSONSerializable: JSONEncodedDict, JID: JIDType, } Base.metadata.naming_convention = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_`%(constraint_name)s`", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", "pk": "pk_%(table_name)s", } def get_engine(path: str, echo: bool = False, pool_size: int = 5) -> sa.Engine: from sqlalchemy import log as sqlalchemy_log engine = sa.create_engine(path, pool_size=pool_size) if echo: sqlalchemy_log._add_default_handler = lambda x: None # type:ignore engine.echo = True return engine slidge/slidge/db/models.py000066400000000000000000000375201522535037600160350ustar00rootroot00000000000000import warnings from datetime import datetime from enum import IntEnum from typing import Any import sqlalchemy as sa from slixmpp import JID from slixmpp.types import MucAffiliation, MucRole from sqlalchemy import JSON, Column, ForeignKey, Index, Table, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from ..util.types import ClientType, Hat, MucType from .meta import Base, JSONSerializable, JSONSerializableTypes class ArchivedMessageSource(IntEnum): """ Whether an archived message comes from ``LegacyMUC.backfill()`` or was received as a "live" message. """ LIVE = 1 BACKFILL = 2 class GatewayUser(Base): """ A user, registered to the gateway component. """ __tablename__ = "user_account" id: Mapped[int] = mapped_column(primary_key=True) jid: Mapped[JID] = mapped_column(unique=True) registration_date: Mapped[datetime] = mapped_column( sa.DateTime, server_default=sa.func.now() ) legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={}) """ Arbitrary non-relational data that legacy modules can use """ preferences: Mapped[JSONSerializable] = mapped_column(default={}) avatar_hash: Mapped[str | None] = mapped_column(default=None) """ Hash of the user's avatar, to avoid re-publishing the same avatar on the legacy network """ contacts: Mapped[list["Contact"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) rooms: Mapped[list["Room"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) attachments: Mapped[list["Attachment"]] = relationship(cascade="all, delete-orphan") spaces: Mapped[list["Space"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) def __repr__(self) -> str: return f"User(id={self.id!r}, jid={self.jid!r})" def get(self, field: str, default: str = "") -> JSONSerializableTypes: # """ # Get fields from the registration form (required to comply with slixmpp backend protocol) # # :param field: Name of the field # :param default: Default value to return if the field is not present # # :return: Value of the field # """ return self.legacy_module_data.get(field, default) @property def registration_form(self) -> dict[str, Any]: # Kept for retrocompat, should be # FIXME: delete me warnings.warn( "GatewayUser.registration_form is deprecated.", DeprecationWarning ) return self.legacy_module_data class Avatar(Base): """ Avatars of contacts, rooms and participants. To comply with XEPs, we convert them all to PNG before storing them. """ __tablename__ = "avatar" id: Mapped[int] = mapped_column(primary_key=True) hash: Mapped[str] = mapped_column(unique=True) height: Mapped[int] = mapped_column() width: Mapped[int] = mapped_column() legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True) # this is only used when avatars are available as HTTP URLs and do not # have a legacy_id url: Mapped[str | None] = mapped_column(unique=True, default=None) etag: Mapped[str | None] = mapped_column(default=None) last_modified: Mapped[str | None] = mapped_column(default=None) contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar") rooms: Mapped[list["Room"]] = relationship(back_populates="avatar") space_owner_association = Table( "space_owner_association", Base.metadata, Column("space_id", ForeignKey("space.id"), primary_key=True), Column("contact_id", ForeignKey("contact.id"), primary_key=True), ) class Contact(Base): """ Legacy contacts """ __tablename__ = "contact" __table_args__ = ( UniqueConstraint( "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id" ), UniqueConstraint( "user_account_id", "jid", name="uq_contact_user_account_id_jid" ), ) id: Mapped[int] = mapped_column(primary_key=True) user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts") legacy_id: Mapped[str] = mapped_column(nullable=False) jid: Mapped[JID] = mapped_column() avatar_id: Mapped[int | None] = mapped_column( ForeignKey("avatar.id"), nullable=True ) avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="contacts") nick: Mapped[str | None] = mapped_column(nullable=True) cached_presence: Mapped[bool] = mapped_column(default=False) last_seen: Mapped[datetime | None] = mapped_column(nullable=True) ptype: Mapped[str | None] = mapped_column(nullable=True) pstatus: Mapped[str | None] = mapped_column(nullable=True) pshow: Mapped[str | None] = mapped_column(nullable=True) caps_ver: Mapped[str | None] = mapped_column(nullable=True) is_friend: Mapped[bool] = mapped_column(default=False) added_to_roster: Mapped[bool] = mapped_column(default=False) sent_order: Mapped[list["ContactSent"]] = relationship( back_populates="contact", cascade="all, delete-orphan" ) extra_attributes: Mapped[JSONSerializable | None] = mapped_column( default=None, nullable=True ) updated: Mapped[bool] = mapped_column(default=False) vcard: Mapped[str | None] = mapped_column() vcard_fetched: Mapped[bool] = mapped_column(default=False) participants: Mapped[list["Participant"]] = relationship(back_populates="contact") client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc") messages: Mapped[list["DirectMessages"]] = relationship( cascade="all, delete-orphan" ) threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan") spaces_created: Mapped[list["Space"]] = relationship( back_populates="creator", cascade="all, delete-orphan", ) spaces_owned: Mapped[list["Space"]] = relationship( back_populates="owners", secondary=space_owner_association, ) class ContactSent(Base): """ Keep track of XMPP msg ids sent by a specific contact for networks in which all messages need to be marked as read. (XMPP displayed markers convey a "read up to here" semantic.) """ __tablename__ = "contact_sent" __table_args__ = ( UniqueConstraint( "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" ), ) id: Mapped[int] = mapped_column(primary_key=True) contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id")) contact: Mapped[Contact] = relationship(back_populates="sent_order") msg_id: Mapped[str] = mapped_column() class Room(Base): """ Legacy room """ __table_args__ = ( UniqueConstraint( "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id" ), UniqueConstraint("user_account_id", "jid", name="uq_room_user_account_id_jid"), ) __tablename__ = "room" id: Mapped[int] = mapped_column(primary_key=True) user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms") legacy_id: Mapped[str] = mapped_column(nullable=False) jid: Mapped[JID] = mapped_column(nullable=False) avatar_id: Mapped[int | None] = mapped_column( ForeignKey("avatar.id"), nullable=True ) avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="rooms") name: Mapped[str | None] = mapped_column(nullable=True) description: Mapped[str | None] = mapped_column(nullable=True) subject: Mapped[str | None] = mapped_column(nullable=True) subject_date: Mapped[datetime | None] = mapped_column(nullable=True) subject_setter: Mapped[str | None] = mapped_column(nullable=True) n_participants: Mapped[int | None] = mapped_column(default=None) muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL) user_nick: Mapped[str | None] = mapped_column() user_resources: Mapped[str | None] = mapped_column(nullable=True) participants_filled: Mapped[bool] = mapped_column(default=False) history_filled: Mapped[bool] = mapped_column(default=False) extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) updated: Mapped[bool] = mapped_column(default=False) participants: Mapped[list["Participant"]] = relationship( back_populates="room", primaryjoin="Participant.room_id == Room.id", cascade="all, delete-orphan", ) archive: Mapped[list["ArchivedMessage"]] = relationship( cascade="all, delete-orphan" ) messages: Mapped[list["GroupMessages"]] = relationship(cascade="all, delete-orphan") threads: Mapped[list["GroupThreads"]] = relationship(cascade="all, delete-orphan") space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True) space: Mapped["Space"] = relationship(back_populates="rooms", lazy=False) class ArchivedMessage(Base): """ Messages of rooms, that we store to act as a MAM server """ __tablename__ = "mam" __table_args__ = ( UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"), ) id: Mapped[int] = mapped_column(primary_key=True) room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) room: Mapped[Room] = relationship(lazy=True, back_populates="archive") stanza_id: Mapped[str] = mapped_column(nullable=False) timestamp: Mapped[datetime] = mapped_column(nullable=False) author_jid: Mapped[JID] = mapped_column(nullable=False) source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False) legacy_id: Mapped[str | None] = mapped_column(nullable=True) stanza: Mapped[str] = mapped_column(nullable=False) displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True) class _LegacyToXmppIdsBase: """ XMPP-client generated IDs, and mapping to the corresponding legacy IDs. A single legacy ID can map to several XMPP ids. """ id: Mapped[int] = mapped_column(primary_key=True) legacy_id: Mapped[str] = mapped_column(nullable=False) xmpp_id: Mapped[str] = mapped_column(nullable=False) class DirectMessages(_LegacyToXmppIdsBase, Base): __tablename__ = "direct_msg" __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),) foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False) class GroupMessages(_LegacyToXmppIdsBase, Base): __tablename__ = "group_msg" __table_args__ = (Index("ix_group_msg_legacy_id", "legacy_id", "foreign_key"),) foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base): """ This maps "origin ids" to legacy message IDs We need that for message corrections and retractions, which do not reference messages by their "Unique and Stable Stanza IDs (XEP-0359)" """ __tablename__ = "group_msg_origin" __table_args__ = ( Index("ix_group_msg_origin_legacy_id", "legacy_id", "foreign_key"), ) foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) class DirectThreads(_LegacyToXmppIdsBase, Base): __tablename__ = "direct_thread" __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),) foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False) class GroupThreads(_LegacyToXmppIdsBase, Base): __tablename__ = "group_thread" __table_args__ = (Index("ix_direct_group_thread_id", "legacy_id", "foreign_key"),) foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) class Attachment(Base): """ Legacy attachments """ __tablename__ = "attachment" __table_args__ = ( UniqueConstraint( "user_account_id", "legacy_file_id", name="uq_attachment_user_account_id_legacy_file_id", ), ) id: Mapped[int] = mapped_column(primary_key=True) user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) user: Mapped[GatewayUser] = relationship(back_populates="attachments") legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True) url: Mapped[str] = mapped_column(index=True, nullable=False) sims: Mapped[str | None] = mapped_column() sfs: Mapped[str | None] = mapped_column() class Participant(Base): __tablename__ = "participant" __table_args__ = ( UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"), UniqueConstraint( "room_id", "contact_id", name="uq_participant_room_id_contact_id" ), UniqueConstraint( "room_id", "occupant_id", name="uq_participant_room_id_occupant_id" ), ) id: Mapped[int] = mapped_column(primary_key=True) room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) room: Mapped[Room] = relationship( lazy=False, back_populates="participants", primaryjoin=Room.id == room_id ) contact_id: Mapped[int | None] = mapped_column( ForeignKey("contact.id"), nullable=True ) contact: Mapped[Contact | None] = relationship( lazy=False, back_populates="participants" ) occupant_id: Mapped[str] = mapped_column(nullable=False) is_user: Mapped[bool] = mapped_column(default=False) affiliation: Mapped[MucAffiliation] = mapped_column( default="member", nullable=False ) role: Mapped[MucRole] = mapped_column(default="participant", nullable=False) presence_sent: Mapped[bool] = mapped_column(default=False) resource: Mapped[str] = mapped_column(nullable=False) nickname: Mapped[str] = mapped_column(nullable=False, default=None) nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None) hats: Mapped[list[Hat]] = mapped_column(JSON, default=list) extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) self.role = "participant" self.affiliation = "member" class Bob(Base): __tablename__ = "bob" id: Mapped[int] = mapped_column(primary_key=True) file_name: Mapped[str] = mapped_column(nullable=False) sha_1: Mapped[str] = mapped_column(nullable=False, unique=True) sha_256: Mapped[str] = mapped_column(nullable=False, unique=True) sha_512: Mapped[str] = mapped_column(nullable=False, unique=True) content_type: Mapped[str] = mapped_column(nullable=False) class Space(Base): __tablename__ = "space" __table_args__ = ( UniqueConstraint( "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id" ), ) id: Mapped[int] = mapped_column(primary_key=True) updated: Mapped[bool] = mapped_column(default=False, nullable=False) user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces") legacy_id: Mapped[str] = mapped_column(nullable=False) name: Mapped[str | None] = mapped_column(nullable=True) description: Mapped[str | None] = mapped_column(nullable=True) member_count: Mapped[int | None] = mapped_column(nullable=True) creator_pk: Mapped[int | None] = mapped_column( ForeignKey("contact.id"), nullable=True ) creator: Mapped[Contact | None] = relationship(back_populates="spaces_created") owners: Mapped[list[Contact]] = relationship( back_populates="spaces_owned", secondary=space_owner_association, ) rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan") slidge/slidge/db/store.py000066400000000000000000000655741522535037600157200ustar00rootroot00000000000000from __future__ import annotations import hashlib import logging import shutil import uuid from collections.abc import Callable, Collection, Iterable, Iterator from datetime import UTC, datetime, timedelta from mimetypes import guess_extension from typing import Any, ClassVar import sqlalchemy as sa from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0231.stanza import BitsOfBinary from sqlalchemy import ColumnElement, Engine, delete, event, select, update from sqlalchemy.exc import InvalidRequestError from sqlalchemy.orm import Session, attributes, joinedload, load_only, sessionmaker from ..core import config from ..util.archive_msg import HistoryMessage from ..util.types import MamMetadata, Sticker from .meta import Base from .models import ( ArchivedMessage, ArchivedMessageSource, Avatar, Bob, Contact, ContactSent, DirectMessages, DirectThreads, GatewayUser, GroupMessages, GroupMessagesOrigin, GroupThreads, Participant, Room, Space, ) class UpdatedMixin: model: type[Base] = NotImplemented def __init__(self, session: Session) -> None: self.reset_updated(session) def get_by_pk(self, session: Session, pk: int) -> type[Base]: stmt = select(self.model).where(self.model.id == pk) # type:ignore[attr-defined] return session.scalar(stmt) # type:ignore[no-any-return] def reset_updated(self, session: Session) -> None: session.execute(update(self.model).values(updated=False)) class SlidgeStore: def __init__(self, engine: Engine) -> None: self._engine = engine self.session = sessionmaker[Any](engine) self.users = UserStore(self.session) self.avatars = AvatarStore(self.session) self.id_map = IdMapStore() self.bob = BobStore() with self.session() as session: self.contacts = ContactStore(session) self.mam = MAMStore(session, self.session) self.rooms = RoomStore(session) self.participants = ParticipantStore(session) self.spaces = SpaceStore(session) session.commit() class UserStore: def __init__(self, session_maker: sessionmaker[Any]) -> None: self.session = session_maker def update(self, user: GatewayUser) -> None: with self.session(expire_on_commit=False) as session: # https://github.com/sqlalchemy/sqlalchemy/discussions/6473 try: attributes.flag_modified(user, "legacy_module_data") attributes.flag_modified(user, "preferences") except InvalidRequestError: pass session.add(user) session.commit() class AvatarStore: def __init__(self, session_maker: sessionmaker[Any]) -> None: self.session = session_maker LegacyToXmppType = ( type[DirectMessages] | type[DirectThreads] | type[GroupMessages] | type[GroupThreads] | type[GroupMessagesOrigin] ) class IdMapStore: @staticmethod def _set( session: Session, foreign_key: int, legacy_id: str, xmpp_ids: list[str], type_: LegacyToXmppType, ) -> None: kwargs = dict(foreign_key=foreign_key, legacy_id=legacy_id) ids = list( session.scalars( select(type_.id).filter( type_.foreign_key == foreign_key, type_.legacy_id == legacy_id ) ) ) if ids: log.debug("Resetting legacy ID %s", legacy_id) session.execute(delete(type_).where(type_.id.in_(ids))) for xmpp_id in xmpp_ids: msg = type_(xmpp_id=xmpp_id, **kwargs) session.add(msg) def set_thread( self, session: Session, foreign_key: int, legacy_id: str, xmpp_id: str, group: bool, ) -> None: self._set( session, foreign_key, legacy_id, [xmpp_id], GroupThreads if group else DirectThreads, ) def set_msg( self, session: Session, foreign_key: int, legacy_id: str, xmpp_ids: list[str], group: bool, ) -> None: self._set( session, foreign_key, legacy_id, xmpp_ids, GroupMessages if group else DirectMessages, ) def set_origin( self, session: Session, foreign_key: int, legacy_id: str, xmpp_id: str ) -> None: self._set( session, foreign_key, legacy_id, [xmpp_id], GroupMessagesOrigin, ) def get_origin( self, session: Session, foreign_key: int, legacy_id: str ) -> list[str]: return self._get( session, foreign_key, legacy_id, GroupMessagesOrigin, ) @staticmethod def _get( session: Session, foreign_key: int, legacy_id: str, type_: LegacyToXmppType ) -> list[str]: return list( session.scalars( select(type_.xmpp_id).filter_by( foreign_key=foreign_key, legacy_id=str(legacy_id) ) ) ) def get_xmpp( self, session: Session, foreign_key: int, legacy_id: str, group: bool ) -> list[str]: return self._get( session, foreign_key, legacy_id, GroupMessages if group else DirectMessages, ) @staticmethod def _get_legacy( session: Session, foreign_key: int, xmpp_id: str, type_: LegacyToXmppType ) -> str | None: return session.scalar( select(type_.legacy_id).filter_by(foreign_key=foreign_key, xmpp_id=xmpp_id) ) def get_legacy( self, session: Session, foreign_key: int, xmpp_id: str, group: bool, origin: bool = False, ) -> str | None: if origin and group: return self._get_legacy( session, foreign_key, xmpp_id, GroupMessagesOrigin, ) return self._get_legacy( session, foreign_key, xmpp_id, GroupMessages if group else DirectMessages, ) def get_thread( self, session: Session, foreign_key: int, xmpp_id: str, group: bool ) -> str | None: return self._get_legacy( session, foreign_key, xmpp_id, GroupThreads if group else DirectThreads, ) @staticmethod def was_sent_by_user( session: Session, foreign_key: int, legacy_id: str, group: bool ) -> bool: type_ = GroupMessages if group else DirectMessages return ( session.scalar( select(type_.id).filter_by(foreign_key=foreign_key, legacy_id=legacy_id) ) is not None ) class ContactStore(UpdatedMixin): model = Contact def __init__(self, session: Session) -> None: super().__init__(session) session.execute(update(Contact).values(cached_presence=False)) session.execute(update(Contact).values(caps_ver=None)) @staticmethod def add_to_sent(session: Session, contact_pk: int, msg_id: str) -> None: if ( session.query(ContactSent.id) .where(ContactSent.contact_id == contact_pk) .where(ContactSent.msg_id == msg_id) .first() ) is not None: log.warning("Contact %s has already sent message %s", contact_pk, msg_id) return new = ContactSent(contact_id=contact_pk, msg_id=msg_id) session.add(new) @staticmethod def pop_sent_up_to(session: Session, contact_pk: int, msg_id: str) -> list[str]: result = [] to_del = [] for row in session.execute( select(ContactSent) .where(ContactSent.contact_id == contact_pk) .order_by(ContactSent.id) ).scalars(): to_del.append(row.id) result.append(row.msg_id) if row.msg_id == msg_id: break session.execute(delete(ContactSent).where(ContactSent.id.in_(to_del))) return result class MAMStore: def __init__(self, session: Session, session_maker: sessionmaker[Any]) -> None: self.session = session_maker self.reset_source(session) @staticmethod def reset_source(session: Session) -> None: session.execute( update(ArchivedMessage).values(source=ArchivedMessageSource.BACKFILL) ) @staticmethod def nuke_older_than(session: Session, days: int) -> None: session.execute( delete(ArchivedMessage).where( ArchivedMessage.timestamp < datetime.now() - timedelta(days=days) ) ) @staticmethod def add_message( session: Session, room_pk: int, message: HistoryMessage, archive_only: bool, legacy_msg_id: str | None, ) -> None: source = ( ArchivedMessageSource.BACKFILL if archive_only else ArchivedMessageSource.LIVE ) existing = session.execute( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .where(ArchivedMessage.stanza_id == message.id) ).scalar() if existing is None and legacy_msg_id is not None: existing = session.execute( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .where(ArchivedMessage.legacy_id == str(legacy_msg_id)) ).scalar() if existing is not None: log.debug("Updating message %s in room %s", message.id, room_pk) existing.timestamp = message.when existing.stanza = str(message.stanza) existing.author_jid = message.stanza.get_from() existing.source = source existing.legacy_id = legacy_msg_id session.add(existing) return mam_msg = ArchivedMessage( stanza_id=message.id, timestamp=message.when, stanza=str(message.stanza), author_jid=message.stanza.get_from(), room_id=room_pk, source=source, legacy_id=legacy_msg_id, ) session.add(mam_msg) @staticmethod def get_messages( session: Session, room_pk: int, start_date: datetime | None = None, end_date: datetime | None = None, before_id: str | None = None, after_id: str | None = None, ids: Collection[str] = (), last_page_n: int | None = None, sender: str | None = None, flip: bool = False, ) -> Iterator[HistoryMessage]: q = select(ArchivedMessage).where(ArchivedMessage.room_id == room_pk) if start_date is not None: q = q.where(ArchivedMessage.timestamp >= start_date) if end_date is not None: q = q.where(ArchivedMessage.timestamp <= end_date) if before_id is not None: stamp = session.execute( select(ArchivedMessage.timestamp).where( ArchivedMessage.stanza_id == before_id, ArchivedMessage.room_id == room_pk, ) ).scalar_one_or_none() if stamp is None: raise XMPPError( "item-not-found", f"Message {before_id} not found", ) q = q.where(ArchivedMessage.timestamp < stamp) if after_id is not None: stamp = session.execute( select(ArchivedMessage.timestamp).where( ArchivedMessage.stanza_id == after_id, ArchivedMessage.room_id == room_pk, ) ).scalar_one_or_none() if stamp is None: raise XMPPError( "item-not-found", f"Message {after_id} not found", ) q = q.where(ArchivedMessage.timestamp > stamp) if ids: q = q.filter(ArchivedMessage.stanza_id.in_(ids)) if sender is not None: q = q.where(ArchivedMessage.author_jid == sender) if flip: q = q.order_by(ArchivedMessage.timestamp.desc()) else: q = q.order_by(ArchivedMessage.timestamp.asc()) msgs = list(session.execute(q).scalars()) if ids and len(msgs) != len(ids): raise XMPPError( "item-not-found", "One of the requested messages IDs could not be found " "with the given constraints.", ) if last_page_n is not None: msgs = msgs[:last_page_n] if flip else msgs[-last_page_n:] for h in msgs: yield HistoryMessage( stanza=str(h.stanza), when=h.timestamp.replace(tzinfo=UTC) ) @staticmethod def get_first( session: Session, room_pk: int, with_legacy_id: bool = False ) -> ArchivedMessage | None: q = ( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .order_by(ArchivedMessage.timestamp.asc()) ) if with_legacy_id: q = q.filter(ArchivedMessage.legacy_id.isnot(None)) return session.execute(q).scalar() @staticmethod def get_last( session: Session, room_pk: int, source: ArchivedMessageSource | None = None ) -> ArchivedMessage | None: q = select(ArchivedMessage).where(ArchivedMessage.room_id == room_pk) if source is not None: q = q.where(ArchivedMessage.source == source) return session.execute(q.order_by(ArchivedMessage.timestamp.desc())).scalar() def get_first_and_last(self, session: Session, room_pk: int) -> list[MamMetadata]: r = [] first = self.get_first(session, room_pk) if first is not None: r.append(MamMetadata(first.stanza_id, first.timestamp)) last = self.get_last(session, room_pk) if last is not None: r.append(MamMetadata(last.stanza_id, last.timestamp)) return r @staticmethod def get_most_recent_with_legacy_id( session: Session, room_pk: int, source: ArchivedMessageSource | None = None ) -> ArchivedMessage | None: q = ( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .where(ArchivedMessage.legacy_id.isnot(None)) ) if source is not None: q = q.where(ArchivedMessage.source == source) return session.execute(q.order_by(ArchivedMessage.timestamp.desc())).scalar() @staticmethod def get_least_recent_with_legacy_id_after( session: Session, room_pk: int, after_id: str, source: ArchivedMessageSource = ArchivedMessageSource.LIVE, ) -> ArchivedMessage | None: after_timestamp = ( session.query(ArchivedMessage.timestamp) .filter(ArchivedMessage.room_id == room_pk) .filter(ArchivedMessage.legacy_id == after_id) .scalar() ) q = ( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .where(ArchivedMessage.legacy_id.isnot(None)) .where(ArchivedMessage.source == source) .where(ArchivedMessage.timestamp > after_timestamp) ) return session.execute(q.order_by(ArchivedMessage.timestamp.asc())).scalar() @staticmethod def get_by_legacy_id( session: Session, room_pk: int, legacy_id: str ) -> ArchivedMessage | None: return ( session.query(ArchivedMessage) .filter(ArchivedMessage.room_id == room_pk) .filter(ArchivedMessage.legacy_id == legacy_id) .first() ) @staticmethod def pop_unread_up_to(session: Session, room_pk: int, stanza_id: str) -> list[str]: q = ( select(ArchivedMessage.id, ArchivedMessage.stanza_id) .where(ArchivedMessage.room_id == room_pk) .where(~ArchivedMessage.displayed_by_user) .where(ArchivedMessage.legacy_id.is_not(None)) .order_by(ArchivedMessage.timestamp.asc()) ) ref = session.scalar( select(ArchivedMessage) .where(ArchivedMessage.room_id == room_pk) .where(ArchivedMessage.stanza_id == stanza_id) ) if ref is None: log.debug( "(pop unread in muc): message not found, returning all MAM messages." ) rows = session.execute(q) else: rows = session.execute(q.where(ArchivedMessage.timestamp <= ref.timestamp)) pks: list[int] = [] stanza_ids: list[str] = [] for id_, stanza_id in rows: pks.append(id_) stanza_ids.append(stanza_id) session.execute( update(ArchivedMessage) .where(ArchivedMessage.id.in_(pks)) .values(displayed_by_user=True) ) return stanza_ids @staticmethod def is_displayed_by_user( session: Session, room_jid: str, legacy_msg_id: str ) -> bool: return any( session.execute( select(ArchivedMessage.displayed_by_user) .join(Room) .where(Room.jid == room_jid) .where(ArchivedMessage.legacy_id == legacy_msg_id) ).scalars() ) class RoomStore(UpdatedMixin): model = Room def reset_updated(self, session: Session) -> None: super().reset_updated(session) session.execute( update(Room).values( subject_setter=None, user_resources=None, history_filled=False, participants_filled=False, ) ) @staticmethod def get_all(session: Session, user_pk: int) -> Iterator[Room]: yield from session.scalars(select(Room).where(Room.user_account_id == user_pk)) @staticmethod def get(session: Session, user_pk: int, legacy_id: str) -> Room: return session.execute( select(Room) .where(Room.user_account_id == user_pk) .where(Room.legacy_id == legacy_id) ).scalar_one() @staticmethod def nick_available(session: Session, room_pk: int, nickname: str) -> bool: return ( session.execute( select(Participant.id).filter_by(room_id=room_pk, nickname=nickname) ) ).one_or_none() is None class ParticipantStore: def __init__(self, session: Session) -> None: session.execute(delete(Participant)) @staticmethod def get_all( session: Session, room_pk: int, user_included: bool = True ) -> Iterator[Participant]: query = select(Participant).where(Participant.room_id == room_pk) if not user_included: query = query.where(~Participant.is_user) yield from session.scalars(query).unique() @staticmethod def delete(session: Session, pk: int) -> None: session.execute(delete(Participant).where(Participant.id == pk)) class BobStore: _ATTR_MAP: ClassVar[dict[str, str]] = { "sha-1": "sha_1", "sha1": "sha_1", "sha-256": "sha_256", "sha256": "sha_256", "sha-512": "sha_512", "sha512": "sha_512", } _ALG_MAP: ClassVar[dict[str, Callable[[bytes], hashlib._Hash]]] = { "sha_1": hashlib.sha1, "sha_256": hashlib.sha256, "sha_512": hashlib.sha512, } def __init__(self) -> None: if (config.HOME_DIR / "slidge_stickers").exists(): shutil.move( config.HOME_DIR / "slidge_stickers", config.HOME_DIR / "bob_store" ) self.root_dir = config.HOME_DIR / "bob_store" self.root_dir.mkdir(exist_ok=True) @staticmethod def __split_cid(cid: str) -> list[str]: return cid.removesuffix("@bob.xmpp.org").split("+") def __get_condition(self, cid: str) -> ColumnElement[bool]: alg_name, digest = self.__split_cid(cid) attr = self._ATTR_MAP.get(alg_name) if attr is None: log.warning("Unknown hash algorithm: %s", alg_name) raise ValueError return getattr(Bob, attr) == digest # type:ignore[no-any-return] def get(self, session: Session, cid: str) -> Bob | None: try: return session.query(Bob).filter(self.__get_condition(cid)).scalar() # type:ignore[no-any-return] except ValueError: log.warning("Cannot get Bob with CID: %s", cid) return None def get_sticker(self, session: Session, cid: str) -> Sticker | None: bob = self.get(session, cid) if bob is None: return None return self.__sticker_from_bob(bob) def __sticker_from_bob(self, bob: Bob) -> Sticker: return Sticker( self.root_dir / bob.file_name, bob.content_type, {h: getattr(bob, h) for h in self._ALG_MAP}, ) def get_bob( self, session: Session, _jid: object, _node: object, _ifrom: object, cid: str ) -> BitsOfBinary | None: stored = self.get(session, cid) if stored is None: return None bob = BitsOfBinary() bob["data"] = (self.root_dir / stored.file_name).read_bytes() if stored.content_type is not None: bob["type"] = stored.content_type bob["cid"] = cid return bob def del_bob( self, session: Session, _jid: object, _node: object, _ifrom: object, cid: str ) -> None: try: file_name = session.scalar( delete(Bob).where(self.__get_condition(cid)).returning(Bob.file_name) ) except ValueError: log.warning("Cannot delete Bob with CID: %s", cid) return None if file_name is None: log.warning("No BoB with CID: %s", cid) return None (self.root_dir / file_name).unlink() def set_bob( self, session: Session, _jid: object, _node: object, _ifrom: object, bob: BitsOfBinary, ) -> Sticker | None: return self.set_sticker(session, bob["cid"], bob["data"], bob["type"]) def set_sticker( self, session: Session, cid: str, bytes_: bytes, content_type: str | None, ) -> Sticker | None: try: alg_name, digest = self.__split_cid(cid) except ValueError: log.warning("Invalid CID provided: %s", cid) return None attr = self._ATTR_MAP.get(alg_name) if attr is None: log.warning("Cannot set Bob: Unknown algorithm type: %s", alg_name) return None existing = self.get(session, cid) if existing: log.debug("Bob already exists") return None path = self.root_dir / uuid.uuid4().hex if content_type is None: try: import magic except ImportError: content_type = "application/octet-stream" else: content_type = magic.from_buffer(bytes_, mime=True) path = path.with_suffix(guess_extension(content_type) or "") path.write_bytes(bytes_) hashes = {k: v(bytes_).hexdigest() for k, v in self._ALG_MAP.items()} if hashes[attr] != digest: path.unlink(missing_ok=True) raise ValueError("Provided CID does not match calculated hash") row = Bob(file_name=path.name, content_type=content_type, **hashes) session.add(row) return self.__sticker_from_bob(row) class SpaceStore(UpdatedMixin): model = Space def __init__(self, session: Session) -> None: session.execute(delete(Space)) @staticmethod def add_or_get(session: Session, user_pk: int, legacy_id: str) -> Space: space = session.execute( select(Space) .where(Space.user_account_id == user_pk) .where(Space.legacy_id == legacy_id) ).scalar_one_or_none() if space is None: space = Space(user_account_id=user_pk, legacy_id=legacy_id) session.add(space) session.commit() return space @staticmethod def get_all(session: Session, user_pk: int) -> Iterable[Space]: return session.execute( select(Space).where(Space.user_account_id == user_pk) ).scalars() @staticmethod def get_by_legacy_id( session: Session, user_pk: int, legacy_id: str, full: bool = False ) -> Space | None: stmt = ( select(Space) .where(Space.user_account_id == user_pk) .where(Space.legacy_id == legacy_id) ) if full: stmt = stmt.options( joinedload(Space.owners), joinedload(Space.creator), ) return session.execute(stmt).unique().scalar_one_or_none() @staticmethod def get_unupdated(session: Session, user_pk: int) -> list[Space]: return list( session.execute( select(Space) .where(Space.user_account_id == user_pk) .where(Space.updated.is_(False)) ).scalars() ) @staticmethod def get_rooms( session: Session, user_pk: int, legacy_id: str, room_legacy_ids: Iterable[str] = (), ) -> list[Room]: q = ( select(Room) .join(Room.space) .where(Room.user_account_id == user_pk) .where(Space.legacy_id == legacy_id) .options(load_only(Room.jid, Room.name)) ) if room_legacy_ids: q = q.where(Room.legacy_id.in_(room_legacy_ids)) return list(session.execute(q).scalars()) @staticmethod def exists(session: Session, user_pk: int, legacy_id: str) -> bool: return session.execute( select( sa.exists() .where(Space.user_account_id == user_pk) .where(Space.legacy_id == legacy_id) ) ).scalar_one() @event.listens_for(sa.orm.Session, "after_flush") def _check_avatar_orphans(session: Session, flush_context: sa.ExecutionContext) -> None: if not session.deleted: return potentially_orphaned = set() for obj in session.deleted: if isinstance(obj, (Contact, Room)) and obj.avatar_id: potentially_orphaned.add(obj.avatar_id) if not potentially_orphaned: return result = session.execute( sa.delete(Avatar).where( sa.and_( Avatar.id.in_(potentially_orphaned), sa.not_(sa.exists().where(Contact.avatar_id == Avatar.id)), sa.not_(sa.exists().where(Room.avatar_id == Avatar.id)), ) ) ) deleted_count = result.rowcount # type:ignore[attr-defined] log.debug("Auto-deleted %s orphaned avatars", deleted_count) log = logging.getLogger(__name__) slidge/slidge/group/000077500000000000000000000000001522535037600147405ustar00rootroot00000000000000slidge/slidge/group/__init__.py000066400000000000000000000004021522535037600170450ustar00rootroot00000000000000""" Everything related to groups. """ from ..util.types import MucType from .bookmarks import LegacyBookmarks from .participant import LegacyParticipant from .room import LegacyMUC __all__ = ("LegacyBookmarks", "LegacyMUC", "LegacyParticipant", "MucType") slidge/slidge/group/archive.py000066400000000000000000000140471522535037600167410ustar00rootroot00000000000000import logging import uuid import warnings from collections.abc import Collection, Iterator from copy import copy from datetime import UTC, datetime from slixmpp import Iq, Message from ..db.models import ArchivedMessage, ArchivedMessageSource, Room from ..db.store import SlidgeStore from ..util.archive_msg import HistoryMessage from ..util.types import AnyParticipant, HoleBound class MessageArchive: def __init__(self, room: Room, store: SlidgeStore) -> None: self.room = room self.__rooms_store = store.rooms self.__store = store.mam def add( self, msg: Message, participant: "AnyParticipant | None" = None, archive_only: bool = False, legacy_msg_id: str | None = None, ) -> None: """ Add a message to the archive if it is deemed archivable :param msg: :param participant: :param archive_only: :param legacy_msg_id: """ if not archivable(msg): return new_msg = copy(msg) if participant and not participant.muc.is_anonymous: new_msg["muc"]["role"] = participant.role or "participant" new_msg["muc"]["affiliation"] = participant.affiliation or "member" if participant.contact: new_msg["muc"]["jid"] = participant.contact.jid.bare elif participant.is_user: new_msg["muc"]["jid"] = participant.user_jid.bare elif participant.is_system: new_msg["muc"]["jid"] = participant.muc.jid else: warnings.warn( f"No real JID for participant '{participant.nickname}' in '{self.room.name}'" ) new_msg["muc"]["jid"] = ( f"{uuid.uuid4()}@{participant.xmpp.boundjid.bare}" ) if "replace" in msg: legacy_msg_id = None with self.__store.session(expire_on_commit=False) as orm: if self.room.id is None: self.room = self.__rooms_store.get( orm, self.room.user_account_id, self.room.legacy_id ) self.__store.add_message( orm, self.room.id, HistoryMessage(new_msg), archive_only, None if legacy_msg_id is None else str(legacy_msg_id), ) orm.commit() def __iter__(self) -> Iterator[HistoryMessage]: return iter(self.get_all()) @staticmethod def __to_bound(stored: ArchivedMessage) -> HoleBound: return HoleBound( stored.legacy_id, # type:ignore stored.timestamp.replace(tzinfo=UTC), ) def get_hole_bounds(self) -> tuple[HoleBound | None, HoleBound | None]: with self.__store.session() as orm: most_recent = self.__store.get_most_recent_with_legacy_id(orm, self.room.id) if most_recent is None: return None, None if most_recent.source == ArchivedMessageSource.BACKFILL: # most recent = only backfill, fetch everything since last backfill return self.__to_bound(most_recent), None most_recent_back_filled = self.__store.get_most_recent_with_legacy_id( orm, self.room.id, ArchivedMessageSource.BACKFILL ) if most_recent_back_filled is None: # group was never back-filled, fetch everything before first live least_recent_live = self.__store.get_first(orm, self.room.id, True) assert least_recent_live is not None return None, self.__to_bound(least_recent_live) assert most_recent_back_filled.legacy_id is not None least_recent_live = self.__store.get_least_recent_with_legacy_id_after( orm, self.room.id, most_recent_back_filled.legacy_id ) assert least_recent_live is not None # this is a hole caused by slidge downtime return self.__to_bound(most_recent_back_filled), self.__to_bound( least_recent_live ) def get_all( self, start_date: datetime | None = None, end_date: datetime | None = None, before_id: str | None = None, after_id: str | None = None, ids: Collection[str] = (), last_page_n: int | None = None, sender: str | None = None, flip: bool = False, ) -> Iterator[HistoryMessage]: with self.__store.session() as orm: yield from self.__store.get_messages( orm, self.room.id, before_id=before_id, after_id=after_id, ids=ids, last_page_n=last_page_n, sender=sender, start_date=start_date, end_date=end_date, flip=flip, ) async def send_metadata(self, iq: Iq) -> None: """ Send archive extent, as per the spec :param iq: :return: """ reply = iq.reply() with self.__store.session() as orm: messages = self.__store.get_first_and_last(orm, self.room.id) if messages: for x, m in [("start", messages[0]), ("end", messages[-1])]: reply["mam_metadata"][x]["id"] = m.id reply["mam_metadata"][x]["timestamp"] = m.sent_on.replace(tzinfo=UTC) else: reply.enable("mam_metadata") reply.send() def archivable(msg: Message) -> bool: """ Determine if a message stanza is worth archiving, ie, convey meaningful info :param msg: :return: """ if "no-store" in msg: return False if "no-permanent-store" in msg: return False if "store" in msg: return True if msg["body"]: return True if "retract" in msg: return True if "reactions" in msg: return True if "displayed" in msg: return True return bool(msg["thread"] and msg["subject"]) log = logging.getLogger(__name__) slidge/slidge/group/bookmarks.py000066400000000000000000000263731522535037600173150ustar00rootroot00000000000000import abc import logging from collections.abc import Iterator from typing import Any, Generic, Literal, overload from slixmpp import JID from slixmpp.exceptions import XMPPError from sqlalchemy.orm import Session as OrmSession from slidge.contact import LegacyContact from ..db.models import Contact, Room, Space from ..util import SubclassableOnce from ..util.jid_escaping import EscapeMixin from ..util.lock import NamedLockMixin from ..util.types import ( AnyMUC, AnySession, LegacyMUCType, SpaceMetadata, ) class LegacyBookmarks( Generic[LegacyMUCType], EscapeMixin, NamedLockMixin, SubclassableOnce, ): """ This is instantiated once per :class:`~slidge.BaseSession` """ _muc_cls: type[LegacyMUCType] def __init__(self, session: AnySession) -> None: self.session = session self.xmpp = session.xmpp self.user_jid = session.user_jid self._user_nick: str = self.session.user_jid.node super().__init__() self.log = logging.getLogger(f"{self.user_jid.bare}:bookmarks") self.ready = self.session.xmpp.loop.create_future() if not self.xmpp.GROUPS: self.ready.set_result(True) @property def user_nick(self) -> str: return self._user_nick @user_nick.setter def user_nick(self, nick: str) -> None: self._user_nick = nick def orm( self, **kwargs: Any, # noqa:ANN401 ) -> OrmSession: return self.session.xmpp.store.session(**kwargs) def from_store(self, stored: Room) -> LegacyMUCType: return self._muc_cls(self.session, stored) def __iter__(self) -> Iterator[LegacyMUCType]: with self.xmpp.store.session() as orm: rooms = ( orm.query(Room).filter_by(user=self.session.user, updated=True).all() ) for stored in rooms: yield self.from_store(stored) def __repr__(self) -> str: return f"" async def legacy_id_to_jid_local_part(self, legacy_id: str) -> str: return await self.legacy_id_to_jid_username(legacy_id) async def jid_local_part_to_legacy_id(self, local_part: str) -> str: return await self.jid_username_to_legacy_id(local_part) async def by_jid(self, jid: JID) -> LegacyMUCType: if jid.resource: jid = JID(jid.bare) async with self.lock(("bare", jid.bare)): legacy_id = await self.jid_local_part_to_legacy_id(jid.node) if self.get_lock(("legacy_id", legacy_id)): self.session.log.debug("Already updating %s via by_legacy_id()", jid) return await self.by_legacy_id(legacy_id) with self.session.xmpp.store.session() as orm: stored = ( orm.query(Room) .filter_by(user_account_id=self.session.user_pk, jid=jid) .one_or_none() ) if stored is None: stored = Room( user_account_id=self.session.user_pk, jid=jid, legacy_id=legacy_id, ) return await self.__update_if_needed(stored) def by_jid_only_if_exists(self, jid: JID) -> LegacyMUCType | None: with self.xmpp.store.session(expire_on_commit=False) as orm: stored = ( orm.query(Room).filter_by(user=self.session.user, jid=jid).one_or_none() ) if stored is not None and stored.updated: return self.from_store(stored) return None @overload async def by_legacy_id(self, legacy_id: str) -> "LegacyMUCType": ... @overload async def by_legacy_id( self, legacy_id: str, create: Literal[False] ) -> "LegacyMUCType | None": ... @overload async def by_legacy_id( self, legacy_id: str, create: Literal[True] ) -> "LegacyMUCType": ... async def by_legacy_id( self, legacy_id: str, create: bool = False ) -> LegacyMUCType | None: async with self.lock(("legacy_id", legacy_id)): local = await self.legacy_id_to_jid_local_part(legacy_id) jid = JID(f"{local}@{self.xmpp.boundjid}") if self.get_lock(("bare", jid.bare)): self.session.log.debug("Already updating %s via by_jid()", jid) if create: return await self.by_jid(jid) else: return self.by_jid_only_if_exists(jid) with self.xmpp.store.session() as orm: stored = ( orm.query(Room) .filter_by( user_account_id=self.session.user_pk, legacy_id=str(legacy_id), ) .one_or_none() ) if stored is None: stored = Room( user_account_id=self.session.user_pk, jid=jid, legacy_id=str(legacy_id), ) return await self.__update_if_needed(stored) async def __update_if_needed(self, stored: Room) -> LegacyMUCType: muc = self.from_store(stored) if muc.stored.updated: return muc with muc.updating_info(): try: await muc.update_info() except NotImplementedError: pass except XMPPError: raise except Exception as e: raise XMPPError("internal-server-error", str(e)) muc.archive.room = muc.stored if self.ready.done() and muc.stored.space_id: with self.orm() as orm: orm.add(muc.stored) self.xmpp.pubsub.broadcast_space(self.session, muc.stored.space) return muc async def fill(self) -> None: """ Establish a user's known groups. This has to be overridden in plugins with group support and at the minimum, this should ``await self.by_legacy_id(group_id)`` for all the groups a user is part of. Slidge internals will call this on successful :meth:`BaseSession.login` """ if self.xmpp.GROUPS: raise NotImplementedError( "The plugin advertised support for groups but" " LegacyBookmarks.fill() was not overridden." ) async def remove( self, muc: AnyMUC, reason: str = "You left this group from the official client.", kick: bool = True, ) -> None: """ Delete everything about a specific group. This should be called when the user leaves the group from the official app. :param muc: The MUC to remove. :param reason: Optionally, a reason why this group was removed. :param kick: Whether the user should be kicked from this group. Set this to False in case you do this somewhere else in your code, eg, on receiving the confirmation that the group was deleted. """ if kick: user_participant = await muc.get_user_participant() user_participant.kick(reason) with self.xmpp.store.session() as orm: orm.add(muc.stored) orm.refresh(muc.stored) orm.delete(muc.stored) orm.commit() async def update_space_if_needed(self, space: Space) -> Space: async with self.lock(("space", space.legacy_id)): with self.orm() as orm: orm.add(space) orm.refresh(space) if space.updated: return space meta: SpaceMetadata = await self.fetch_space_metadata(space.legacy_id) return await self.__update_space_metadata(space, meta) async def __update_space_metadata(self, space: Space, meta: SpaceMetadata) -> Space: creator = ( await self.__get_stored_contact(meta.creator_legacy_id) if meta.creator_legacy_id else None ) owners: list[Contact] = [] for legacy_id in set(meta.owner_legacy_ids): if legacy_id == meta.creator_legacy_id: # We don't want to fetch any contact twice here to avoid: # Can't attach instance ; another instance [...] is already present in this session. continue owner = await self.__get_stored_contact(legacy_id) if owner is not None: owners.append(owner) if ( creator is not None and meta.creator_legacy_id and meta.creator_legacy_id in meta.owner_legacy_ids ): owners.append(creator) with self.orm(expire_on_commit=False) as orm: if creator is not None: creator = orm.merge(creator) owners = [orm.merge(owner) for owner in owners] space = orm.merge(space) space.name = meta.name or space.legacy_id space.creator = creator space.owners = owners space.description = meta.description space.member_count = meta.member_count space.updated = True orm.commit() return space async def __get_stored_contact(self, legacy_id_str: str) -> Contact | None: try: contact: LegacyContact = await self.session.contacts.by_legacy_id( legacy_id_str ) except Exception as e: self.log.warning("Could not get contact: %r", e) return None return contact.stored @abc.abstractmethod async def fetch_space_metadata(self, legacy_id: str) -> SpaceMetadata: """ Fetch metadata associated to a space. This is called once per slidge runtime. It should return metadata associated to the space identified by its ``legacy_id``. If there are updates to this metata, they should be communicated to slidge by calling :func:`LegacyBookmarks.update_space_metadata`. :param legacy_id: Identifier of the space. :return: Metadata associated to the space. """ raise NotImplementedError async def update_spaces_info(self) -> None: with self.orm() as orm: spaces = self.session.xmpp.store.spaces.get_unupdated( orm, self.session.user_pk ) for space in spaces: await self.update_space_if_needed(space) async def space_legacy_id_to_node(self, legacy_id: str) -> str: return str(legacy_id) async def space_node_to_legacy_id(self, node: str) -> str: return node async def update_space_metadata( self, legacy_id: str, metadata: SpaceMetadata, ) -> None: """ Updates metadata associated to a space. :param legacy_id: Identifier of the space. :param name: Metadata associated to this space. """ with self.orm(expire_on_commit=False) as orm: space = self.session.xmpp.store.spaces.add_or_get( orm, self.session.user_pk, str(legacy_id), ) space = await self.__update_space_metadata(space, metadata) self.xmpp.pubsub.broadcast_space_metadata( self.session, space, await self.space_legacy_id_to_node(legacy_id) ) slidge/slidge/group/participant.py000066400000000000000000000511061522535037600176330ustar00rootroot00000000000000import logging import string import uuid import warnings from copy import copy from datetime import datetime from typing import TYPE_CHECKING, Any, Generic, Literal from xml.etree import ElementTree as ET import sqlalchemy as sa from slixmpp import JID, InvalidJID, Message, Presence from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo from slixmpp.plugins.xep_0045.stanza import MUCAdminItem from slixmpp.plugins.xep_0492.stanza import Never from slixmpp.types import MessageTypes, OptJid from sqlalchemy.orm.exc import DetachedInstanceError from ..core.mixins import ChatterDiscoMixin, MessageMixin, PresenceMixin from ..core.mixins.db import DBMixin from ..db.models import Participant from ..util import SubclassableOnce, strip_illegal_chars from ..util.types import ( AnyMUC, CachedPresence, Hat, LegacyContactType, MessageOrPresenceTypeVar, MucAffiliation, MucRole, ) if TYPE_CHECKING: from slidge.command.base import ContactCommand def strip_non_printable(nickname: str) -> str: new = ( "".join(x for x in nickname if x in string.printable) + f"-slidge-{hash(nickname)}" ) warnings.warn(f"Could not use {nickname} as a nickname, using {new}") return new class LegacyParticipant( Generic[LegacyContactType], PresenceMixin, MessageMixin, ChatterDiscoMixin, DBMixin, SubclassableOnce, ): """ A legacy participant of a legacy group chat. """ is_participant: Literal[True] = True mtype: MessageTypes = "groupchat" _can_send_carbon = False USE_STANZA_ID = True STRIP_SHORT_DELAY = False stored: Participant contact: LegacyContactType | None def __init__( self, muc: AnyMUC, stored: Participant, is_system: bool = False, contact: LegacyContactType | None = None, ) -> None: self.muc = muc self.session = muc.session self.xmpp = muc.session.xmpp self.is_system = is_system if contact is None and stored.contact is not None: contact = self.session.contacts.from_store(stored=stored.contact) if contact is not None and stored.contact is None: stored.contact = contact.stored self.stored = stored self.contact = contact super().__init__() if stored.resource is None: self.__update_resource(stored.nickname) self.log = logging.getLogger(f"{self.user_jid.bare}:{self.jid}") def __eq__(self, other: object) -> bool: return isinstance(other, LegacyParticipant) and self.jid == other.jid @property def is_user(self) -> bool: try: return self.stored.is_user except DetachedInstanceError: self.merge() return self.stored.is_user @is_user.setter def is_user(self, is_user: bool) -> None: with self.xmpp.store.session(expire_on_commit=True) as orm: orm.add(self.stored) self.stored.is_user = is_user orm.commit() @property def jid(self) -> JID: jid = JID(self.muc.jid) if self.stored.resource: jid.resource = self.stored.resource return jid @jid.setter def jid(self, x: JID) -> None: # FIXME: without this, mypy yields # "Cannot override writeable attribute with read-only property" # But it does not happen for LegacyContact. WTF? raise RuntimeError @property def commands(self) -> dict[str, "type[ContactCommand[Any]]"]: # type:ignore[override] if self.contact is None: return {} else: return self.contact.commands def __should_commit(self) -> bool: if self.is_system: return False if self.muc.get_lock("fill participants"): return False return not self.muc.get_lock("fill history") def commit(self) -> None: if not self.__should_commit(): return super().commit() @property def user_jid(self) -> JID: return self.session.user_jid def __repr__(self) -> str: return f"" @property def _presence_sent(self) -> bool: # we track if we already sent a presence for this participant. # if we didn't, we send it before the first message. # this way, event in plugins that don't map "user has joined" events, # we send a "join"-presence from the participant before the first message return self.stored.presence_sent @_presence_sent.setter def _presence_sent(self, val: bool) -> None: if self._presence_sent == val: return self.stored.presence_sent = val if not self.__should_commit(): return with self.xmpp.store.session() as orm: orm.execute( sa.update(Participant) .where(Participant.id == self.stored.id) .values(presence_sent=val) ) orm.commit() @property def nickname_no_illegal(self) -> str: return self.stored.nickname_no_illegal @property def affiliation(self) -> MucAffiliation: return self.stored.affiliation @affiliation.setter def affiliation(self, affiliation: MucAffiliation) -> None: if self.affiliation == affiliation: return was = self.stored.affiliation self.stored.affiliation = affiliation if not self.muc.participants_filled: return self.commit() if self.cached_presence is None or self.cached_presence.ptype == "unavailable": self.muc.send_affiliation_change(self, was) self.send_last_presence(force=True, no_cache_online=True) @property def role(self) -> MucRole: return self.stored.role @role.setter def role(self, role: MucRole) -> None: if self.role == role: return self.stored.role = role if not self.muc.participants_filled: return self.commit() if not self._presence_sent: return self.send_last_presence(force=True, no_cache_online=True) @property def hats(self) -> list[Hat]: return [Hat(*h) for h in self.stored.hats] if self.stored.hats else [] def set_hats(self, hats: list[Hat]) -> None: if self.hats == hats: return self.stored.hats = hats if not self.muc.participants_filled: return self.commit() if not self._presence_sent: return self.send_last_presence(force=True, no_cache_online=True) def __update_resource(self, unescaped_nickname: str | None) -> None: if not unescaped_nickname: self.stored.resource = "" if self.is_system: self.stored.nickname_no_illegal = "" else: warnings.warn( "Only the system participant is allowed to not have a nickname" ) nickname = f"unnamed-{uuid.uuid4()}" self.stored.resource = self.stored.nickname_no_illegal = nickname return self.stored.nickname_no_illegal, jid = escape_nickname( self.muc.jid, unescaped_nickname, ) self.stored.resource = jid.resource def send_configuration_change(self, codes: tuple[int, ...]) -> None: if not self.is_system: raise RuntimeError("This is only possible for the system participant") msg = self._make_message() msg["muc"]["status_codes"] = codes self._send(msg) @property def nickname(self) -> str: return self.stored.nickname @nickname.setter def nickname(self, new_nickname: str) -> None: old = self.nickname if new_nickname == old: return if self.muc.stored.id is not None: with self.xmpp.store.session() as orm: if not self.xmpp.store.rooms.nick_available( orm, self.muc.stored.id, new_nickname ): if self.contact is None: new_nickname = f"{new_nickname} ({self.occupant_id})" else: new_nickname = f"{new_nickname} ({self.contact.legacy_id})" cache = getattr(self, "_last_presence", None) if cache: last_seen = cache.last_seen kwargs = cache.presence_kwargs else: last_seen = None kwargs = {} kwargs["status_codes"] = {303} p = self._make_presence(ptype="unavailable", last_seen=last_seen, **kwargs) # in this order so pfrom=old resource and we actually use the escaped nick # in the muc/item/nick element self.__update_resource(new_nickname) p["muc"]["item"]["nick"] = self.jid.resource self._send(p) self.stored.nickname = new_nickname self.commit() kwargs["status_codes"] = set() p = self._make_presence(ptype="available", last_seen=last_seen, **kwargs) self._send(p) def _make_presence( # type:ignore[no-untyped-def] self, *, last_seen: datetime | None = None, status_codes: set[int] | None = None, user_full_jid: JID | None = None, **presence_kwargs, # noqa type:ignore[no-untyped-def] ) -> Presence: p = super()._make_presence(last_seen=last_seen, **presence_kwargs) p["muc"]["affiliation"] = self.affiliation p["muc"]["role"] = self.role if self.hats: p["hats"].add_hats(self.hats) codes = status_codes or set() if self.is_user: codes.add(110) if not self.muc.is_anonymous and not self.is_system: if self.is_user: if user_full_jid: p["muc"]["jid"] = user_full_jid else: jid = JID(self.user_jid) try: jid.resource = next(iter(self.muc.get_user_resources())) except StopIteration: jid.resource = "pseudo-resource" p["muc"]["jid"] = self.user_jid codes.add(100) elif self.contact: p["muc"]["jid"] = self.contact.jid if a := self.contact.get_avatar(): p["vcard_temp_update"]["photo"] = a.id else: warnings.warn( f"Private group but no 1:1 JID associated to '{self}'", ) if self.is_user and (hash_ := self.session.user.avatar_hash): p["vcard_temp_update"]["photo"] = hash_ p["muc"]["status_codes"] = codes return p @property def DISCO_NAME(self) -> str: return self.nickname @DISCO_NAME.setter def DISCO_NAME(self, _: str) -> Never: raise RuntimeError def __send_presence_if_needed( self, stanza: Message | Presence, full_jid: JID, archive_only: bool ) -> None: if ( archive_only or self.is_system or self.is_user or self._presence_sent or stanza["subject"] ): return if isinstance(stanza, Message): if "muc" in stanza: return self.send_initial_presence(full_jid) @property def occupant_id(self) -> str: return self.stored.occupant_id def _send( self, stanza: MessageOrPresenceTypeVar, full_jid: JID | None = None, archive_only: bool = False, legacy_msg_id: str | None = None, initial_presence: bool = False, **send_kwargs: Any, # noqa:ANN401 ) -> MessageOrPresenceTypeVar: if stanza.get_from().resource: stanza["occupant-id"]["id"] = self.occupant_id else: stanza["occupant-id"]["id"] = "room" self.__add_nick_element(stanza) if not self.is_user and isinstance(stanza, Presence): if stanza["type"] == "unavailable" and not self._presence_sent: return stanza if initial_presence: self._presence_sent = True else: self._presence_sent = True if full_jid: stanza["to"] = full_jid self.__send_presence_if_needed(stanza, full_jid, archive_only) if self.is_user: assert stanza.stream is not None stanza.stream.send(stanza, use_filters=False) else: stanza.send() else: if hasattr(self.muc, "archive") and isinstance(stanza, Message): self.muc.archive.add(stanza, self, archive_only, legacy_msg_id) if archive_only: return stanza for user_full_jid in self.muc.user_full_jids(): stanza = copy(stanza) stanza["to"] = user_full_jid self.__send_presence_if_needed(stanza, user_full_jid, archive_only) stanza.send() return stanza def mucadmin_item(self) -> MUCAdminItem: item = MUCAdminItem() item["nick"] = self.nickname item["affiliation"] = self.affiliation item["role"] = self.role if not self.muc.is_anonymous: if self.is_user: item["jid"] = self.user_jid.bare elif self.contact: item["jid"] = self.contact.jid.bare else: warnings.warn( ( f"Private group but no contact JID associated to {self.jid} in" f" {self}" ), ) return item def __add_nick_element(self, stanza: Presence | Message) -> None: if (nick := self.nickname_no_illegal) != self.jid.resource: n = self.xmpp.plugin["xep_0172"].stanza.UserNick() n["nick"] = nick stanza.append(n) def _get_last_presence(self) -> CachedPresence | None: own = super()._get_last_presence() if own is None and self.contact: return self.contact._get_last_presence() return own def send_initial_presence( self, full_jid: JID, nick_change: bool = False, presence_id: str | None = None, mav_until: str | None = None, ) -> None: """ Called when the user joins a MUC, as a mechanism to indicate to the joining XMPP client the list of "participants". Can be called this to trigger a "participant has joined the group" event. :param full_jid: Set this to only send to a specific user XMPP resource. :param nick_change: Used when the user joins and the MUC renames them (code 210) :param presence_id: set the presence ID. used internally by slidge """ # MUC status codes: https://xmpp.org/extensions/xep-0045.html#registrar-statuscodes codes = set() if nick_change: codes.add(210) if self.is_user: # the "initial presence" of the user has to be vanilla, as it is # a crucial part of the MUC join sequence for XMPP clients. kwargs = {} else: cache = self._get_last_presence() self.log.debug("Join muc, initial presence: %s", cache) if cache: ptype = cache.ptype if ptype == "unavailable": return kwargs = dict( last_seen=cache.last_seen, pstatus=cache.pstatus, pshow=cache.pshow ) else: kwargs = {} p = self._make_presence( status_codes=codes, user_full_jid=full_jid, **kwargs, # type:ignore ) if presence_id: p["id"] = presence_id if self.is_user and mav_until is not None: p["muc"]["mav"]["until"] = mav_until self._send(p, full_jid, initial_presence=True) def leave(self) -> None: """ Call this when the participant leaves the room """ self.muc.remove_participant(self) def kick(self, reason: str | None = None) -> None: """ Call this when the participant is kicked from the room """ self.muc.remove_participant(self, kick=True, reason=reason) def ban(self, reason: str | None = None) -> None: """ Call this when the participant is banned from the room """ self.muc.remove_participant(self, ban=True, reason=reason) async def get_disco_info( self, jid: OptJid = None, node: str | None = None ) -> DiscoInfo: if self.contact is not None: return await self.contact.get_disco_info() return await super().get_disco_info() def moderate(self, legacy_msg_id: str, reason: str | None = None) -> None: for i in self._legacy_to_xmpp(legacy_msg_id): m = self.muc.get_system_participant()._make_message() m["retract"]["id"] = i if self.is_system: m["retract"].enable("moderated") else: m["retract"]["moderated"]["by"] = self.jid m["retract"]["moderated"]["occupant-id"]["id"] = self.occupant_id if reason: m["retract"]["reason"] = reason self._send(m) def set_room_subject( self, subject: str, full_jid: JID | None = None, when: datetime | None = None, update_muc: bool = True, ) -> None: if update_muc: self.muc._subject = subject # type: ignore self.muc.subject_setter = self.nickname self.muc.subject_date = when msg = self._make_message() if when is not None: msg["delay"].set_stamp(when) msg["delay"]["from"] = self.muc.jid if subject: msg["subject"] = subject else: # may be simplified if slixmpp lets it do it more easily some day msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject")) self._send(msg, full_jid) def set_thread_subject( self, thread: str, subject: str | None, when: datetime | None = None, ) -> None: msg = self._make_message() msg["thread"] = str(thread) if when is not None: msg["delay"].set_stamp(when) msg["delay"]["from"] = self.muc.jid if subject: msg["subject"] = subject else: # may be simplified if slixmpp lets it do it more easily some day msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject")) self._send(msg) async def on_set_affiliation( self, affiliation: MucAffiliation, reason: str | None, nickname: str | None, ) -> None: """ Triggered when the user requests changing the affiliation of a contact for this group. Examples: promotion them to moderator, ban (affiliation=outcast). :param contact: The contact whose affiliation change is requested :param affiliation: The new affiliation :param reason: A reason for this affiliation change :param nickname: """ raise NotImplementedError async def on_kick(self, reason: str | None) -> None: """ Triggered when the user requests changing the role of a contact to "none" for this group. Action commonly known as "kick". :param contact: Contact to be kicked :param reason: A reason for this kick """ raise NotImplementedError async def on_invitation(self, reason: str | None) -> None: """ Triggered when the user invites this :term:`Contact ` to a legacy MUC via :xep:`0249`. The default implementation calls :meth:`LegacyMUC.on_set_affiliation` with the 'member' affiliation. Override if you want to customize this behaviour. :param muc: The group :param reason: Optionally, a reason """ # part = await self.muc.get_participant_by_contact(self) await self.on_set_affiliation("member", reason, None) def escape_nickname(muc_jid: JID, nickname: str) -> tuple[str, JID]: nickname = nickname_no_illegal = strip_illegal_chars(nickname).replace("\n", " | ") jid = JID(muc_jid) try: jid.resource = nickname except InvalidJID: nickname = nickname.encode("punycode").decode() try: jid.resource = nickname except InvalidJID: # at this point there still might be control chars jid.resource = strip_non_printable(nickname) return nickname_no_illegal, jid log = logging.getLogger(__name__) slidge/slidge/group/room.py000066400000000000000000001773051522535037600163030ustar00rootroot00000000000000import hashlib import json import logging import re import string import uuid import warnings from asyncio import Lock from collections.abc import AsyncIterator, Iterable, Iterator from contextlib import asynccontextmanager from copy import copy from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeAlias, overload import sqlalchemy as sa from slixmpp import JID, Iq, Message, Presence from slixmpp.exceptions import IqError, IqTimeout, XMPPError from slixmpp.plugins.xep_0004.stanza.form import Form from slixmpp.plugins.xep_0045.stanza import MUCUserItem from slixmpp.plugins.xep_0060.stanza import Item from slixmpp.plugins.xep_0082 import parse as str_to_datetime from slixmpp.plugins.xep_0469.stanza import NS as PINNING_NS from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS from slixmpp.plugins.xep_0492.stanza import WhenLiteral from slixmpp.xmlstream import ET from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm.exc import DetachedInstanceError from ..contact.contact import LegacyContact from ..contact.roster import ContactIsUser from ..core.mixins.avatar import AvatarMixin from ..core.mixins.disco import ChatterDiscoMixin from ..core.mixins.recipient import RecipientMixin from ..db.models import Participant, Room from ..util.archive_msg import HistoryMessage from ..util.jid_escaping import unescape_node from ..util.types import ( AnyParticipant, AnySession, HoleBound, LegacyParticipantType, Mention, MucAffiliation, MucType, ) from ..util.util import SubclassableOnce, timeit from .archive import MessageArchive from .participant import LegacyParticipant, escape_nickname if TYPE_CHECKING: from ..command.base import MUCCommand from ..db.avatar import CachedAvatar ADMIN_NS = "http://jabber.org/protocol/muc#admin" SubjectSetterType: TypeAlias = "str | None | LegacyContact | AnyParticipant" class LegacyMUC( Generic[LegacyParticipantType], AvatarMixin, ChatterDiscoMixin, RecipientMixin, SubclassableOnce, ): """ A room, a.k.a. a Multi-User Chat. MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks` on the user's :py:class:`slidge.core.session.BaseSession`. """ max_history_fetch = 100 is_group: Literal[True] = True DISCO_TYPE = "text" DISCO_CATEGORY = "conference" STABLE_ARCHIVE = False """ Because legacy events like reactions, editions, etc. don't all map to a stanza with a proper legacy ID, slidge usually cannot guarantee the stability of the archive across restarts. Set this to True if you know what you're doing, but realistically, this can't be set to True until archive is permanently stored on disk by slidge. This is just a flag on archive responses that most clients ignore anyway. """ """ Set this to true if the fill_participants() / fill_participants() design does not fit the legacy API, ie, no lazy loading of the participant list and history. """ HAS_DESCRIPTION = True """ Set this to false if the legacy network does not allow setting a description for the group. In this case the description field will not be present in the room configuration form. """ HAS_SUBJECT = True """ Set this to false if the legacy network does not allow setting a subject (sometimes also called topic) for the group. In this case, as a subject is recommended by :xep:`0045` ("SHALL"), the description (or the group name as ultimate fallback) will be used as the room subject. By setting this to false, an error will be returned when the :term:`User` tries to set the room subject. """ archive: MessageArchive session: AnySession stored: Room commands: ClassVar[dict[str, "type[MUCCommand]"]] = {} # type:ignore[type-arg] commands_chat: ClassVar[dict[str, "type[MUCCommand]"]] = {} # type:ignore[type-arg] _participant_cls: type[LegacyParticipantType] is_participant: Literal[False] = False def __init__(self, session: AnySession, stored: Room) -> None: self.session = session self.xmpp = session.xmpp self.stored = stored self._set_logger() super().__init__() self.archive = MessageArchive(stored, self.xmpp.store) def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]: """ Return XMPP msg ids sent in this group up to a given XMPP msg id. Plugins have no reason to use this, but it is used by slidge core for legacy networks that need to mark *all* messages as read (most XMPP clients only send a read marker for the latest message). This has side effects: all messages up to the horizon XMPP id will be marked as read in the DB. If the horizon XMPP id is not found, all messages of this MUC will be marked as read. :param horizon_xmpp_id: The latest message :return: A list of XMPP ids if horizon_xmpp_id was not found """ with self.xmpp.store.session() as orm: assert self.stored.id is not None ids = self.xmpp.store.mam.pop_unread_up_to( orm, self.stored.id, horizon_xmpp_id ) orm.commit() return ids def participant_from_store( self, stored: Participant, contact: LegacyContact | None = None ) -> LegacyParticipantType: if contact is None and stored.contact is not None: contact = self.session.contacts.from_store(stored.contact) return self._participant_cls(self, stored=stored, contact=contact) @property def jid(self) -> JID: return self.stored.jid @jid.setter def jid(self, x: JID) -> None: # FIXME: without this, mypy yields # "Cannot override writeable attribute with read-only property" # But it does not happen for LegacyContact. WTF? raise RuntimeError @property def legacy_id(self) -> str: return self.stored.legacy_id @property def space_legacy_id(self) -> str: return self.stored.space.legacy_id @space_legacy_id.setter def space_legacy_id(self, legacy_id: str) -> None: if ( self.stored and self.stored.space and self.stored.space.legacy_id == legacy_id ): return with self.orm(expire_on_commit=False) as orm: space = self.xmpp.store.spaces.add_or_get(orm, self.user_pk, str(legacy_id)) self.stored.space = space if self._updating_info: with orm.no_autoflush: self.stored = orm.merge(self.stored) return orm.add(self.stored) orm.commit() def orm( self, **kwargs: Any, # noqa:ANN401 ) -> OrmSession: return self.xmpp.store.session(**kwargs) @property def type(self) -> MucType: return self.stored.muc_type @type.setter def type(self, type_: MucType) -> None: if self.type == type_: return self.update_stored_attribute(muc_type=type_) @property def n_participants(self) -> int | None: return self.stored.n_participants @n_participants.setter def n_participants(self, n_participants: int | None) -> None: if self.stored.n_participants == n_participants: return self.update_stored_attribute(n_participants=n_participants) @property def user_jid(self) -> JID: return self.session.user_jid def _set_logger(self) -> None: self.log = logging.getLogger(f"{self.user_jid}:muc:{self}") def __repr__(self) -> str: return f"" @property def subject_date(self) -> datetime | None: if self.stored.subject_date is None: return None return self.stored.subject_date.replace(tzinfo=UTC) @subject_date.setter def subject_date(self, when: datetime | None) -> None: if self.subject_date == when: return self.update_stored_attribute(subject_date=when) def __send_configuration_change(self, codes: tuple[int, ...]) -> None: part = self.get_system_participant() part.send_configuration_change(codes) @property def user_nick(self) -> str: return ( self.stored.user_nick or self.session.bookmarks.user_nick or self.user_jid.node ) @user_nick.setter def user_nick(self, nick: str) -> None: if nick == self.user_nick: return self.update_stored_attribute(user_nick=nick) def add_user_resource(self, resource: str) -> None: stored_set = self.get_user_resources() if resource in stored_set: return stored_set.add(resource) self.update_stored_attribute( user_resources=(json.dumps(list(stored_set)) if stored_set else None) ) def get_user_resources(self) -> set[str]: stored_str = self.stored.user_resources if stored_str is None: return set() return set(json.loads(stored_str)) def remove_user_resource(self, resource: str) -> None: stored_set = self.get_user_resources() if resource not in stored_set: return stored_set.remove(resource) self.update_stored_attribute( user_resources=(json.dumps(list(stored_set)) if stored_set else None) ) @asynccontextmanager async def lock(self, id_: str) -> AsyncIterator[None]: async with self.session.lock((self.legacy_id, id_)): yield def get_lock(self, id_: str) -> Lock | None: return self.session.get_lock((self.legacy_id, id_)) async def __fill_participants(self) -> None: if self.participants_filled: return async with self.lock("fill participants"): with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(self.stored) with orm.no_autoflush: orm.refresh(self.stored, ["participants_filled"]) if self.participants_filled: return parts: list[Participant] = [] resources = set[str]() # During fill_participants(), self.get_participant*() methods may # return a participant with a conflicting nick/resource. user_found = False async for participant in self.fill_participants(): if participant.is_user: user_found = True if participant.stored.resource in resources: self.log.debug( "Participant '%s' was yielded more than once by fill_participants(), ignoring", participant.stored.resource, ) continue parts.append(participant.stored) resources.add(participant.stored.resource) if not user_found: participant = await self.get_user_participant() if participant.stored.resource in resources: for p in parts: if p.resource == participant.jid.resource: p.is_user = True else: parts.append(participant.stored) resources.add(participant.stored.resource) with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(self.stored) # because self.fill_participants() is async, self.stored may be stale at # this point, and the only thing we want to update is the participant list # and the participant_filled attribute. with orm.no_autoflush: orm.refresh(self.stored, ["participants"]) for part in parts: orm.merge(part) self.stored.participants_filled = True orm.commit() async def get_participants( self, affiliation: MucAffiliation | None = None ) -> AsyncIterator[LegacyParticipantType]: await self.__fill_participants() with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm: self.stored = orm.merge(self.stored) db_participants = self.stored.participants for db_participant in db_participants: if affiliation is not None and db_participant.affiliation != affiliation: continue yield self.participant_from_store(db_participant) async def __fill_history(self) -> None: async with self.lock("fill history"): with self.xmpp.store.session(expire_on_commit=False) as orm: orm.add(self.stored) with orm.no_autoflush: orm.refresh(self.stored, ["history_filled"]) if self.stored.history_filled: self.log.debug("History has already been fetched.") return log.debug("Fetching history for %s", self) try: before, after = self.archive.get_hole_bounds() if before is not None: before = before._replace(id=before.id) if after is not None: after = after._replace(id=after.id) await self.backfill(before, after) except NotImplementedError: return except Exception as e: self.log.exception("Could not backfill", exc_info=e) self.stored.history_filled = True self.commit() def _get_disco_name(self) -> str | None: return self.name @property def name(self) -> str | None: return self.stored.name @name.setter def name(self, n: str | None) -> None: if self.name == n: return self.update_stored_attribute(name=n) self._set_logger() self.__send_configuration_change((104,)) @property def description(self) -> str: return self.stored.description or "" @description.setter def description(self, d: str) -> None: if self.description == d: return self.update_stored_attribute(description=d) self.__send_configuration_change((104,)) def on_presence_unavailable(self, p: Presence) -> None: pto = p.get_to() if pto.bare != self.jid.bare: return pfrom = p.get_from() if pfrom.bare != self.user_jid.bare: return if (resource := pfrom.resource) in self.get_user_resources(): if pto.resource != self.user_nick: self.log.debug( "Received 'leave group' request but with wrong nickname. %s", p ) self.remove_user_resource(resource) else: self.log.debug( "Received 'leave group' request but resource was not listed. %s", p ) async def update_info(self) -> None: """ Fetch information about this group from the legacy network This is awaited on MUC instantiation, and should be overridden to update the attributes of the group chat, like title, subject, number of participants etc. To take advantage of the slidge avatar cache, you can check the .avatar property to retrieve the "legacy file ID" of the cached avatar. If there is no change, you should not call :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or attempt to modify the :attr:.avatar property. """ raise NotImplementedError async def backfill( self, after: HoleBound | None = None, before: HoleBound | None = None, ) -> None: """ Override this if the legacy network provide server-side group archives. In it, send history messages using ``self.get_participant(xxx).send_xxxx``, with the ``archive_only=True`` kwarg. This is only called once per slidge run for a given group. :param after: Fetch messages after this one. If ``None``, slidge's local archive was empty before start-up, ie, no history was ever fetched for this room since the user registered. It's up to gateway implementations to decide how far to fetch messages before the user registered. If not ``None``, slidge has some messages in this archive, and the gateway shall try to fetch history up to (and excluding) this message to avoid "holes" in the history of this group. :param before: Fetch messages before this one. If ``None``, the gateway shall fetch all messages up to the most recent one. If not ``None``, slidge has already archived some live messages it received during its lifetime, and there is no need to query the legacy network for any message after (and including) this one. """ raise NotImplementedError async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]: """ This method should yield the list of all members of this group. Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(), of self.get_user_participant(), and update their affiliation, hats, etc. before yielding them. """ return yield @property def subject(self) -> str: return self.stored.subject or "" @subject.setter def subject(self, s: str) -> None: if s == self.subject: return self.update_stored_attribute(subject=s) self.__get_subject_setter_participant().set_room_subject( s, None, self.subject_date, False ) @property def is_anonymous(self) -> bool: return self.type == MucType.CHANNEL @property def subject_setter(self) -> str | None: return self.stored.subject_setter @subject_setter.setter def subject_setter(self, subject_setter: SubjectSetterType) -> None: if isinstance(subject_setter, LegacyContact): subject_setter = subject_setter.name elif isinstance(subject_setter, LegacyParticipant): subject_setter = subject_setter.nickname if subject_setter == self.subject_setter: return assert isinstance(subject_setter, str | None) self.update_stored_attribute(subject_setter=subject_setter) def __get_subject_setter_participant(self) -> AnyParticipant: if self.subject_setter is None: return self.get_system_participant() return self._participant_cls( self, Participant(nickname=self.subject_setter, occupant_id="subject-setter"), ) def features(self) -> list[str]: features = [ "http://jabber.org/protocol/muc", "http://jabber.org/protocol/muc#stable_id", "http://jabber.org/protocol/muc#self-ping-optimization", "urn:xmpp:mam:2", "urn:xmpp:mam:2#extended", "urn:xmpp:sid:0", "muc_persistent", "vcard-temp", "urn:xmpp:ping", "urn:xmpp:occupant-id:0", "jabber:iq:register", "http://jabber.org/protocol/commands", "urn:xmpp:muc:affiliations:1", self.xmpp.plugin["xep_0425"].stanza.NS, ] if self.type == MucType.GROUP: features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"]) elif self.type == MucType.CHANNEL: features.extend(["muc_open", "muc_semianonymous", "muc_public"]) elif self.type == MucType.CHANNEL_NON_ANONYMOUS: features.extend(["muc_open", "muc_nonanonymous", "muc_public"]) try: # oh boy, this sucks has_space = self.stored.space is not None except DetachedInstanceError: self.refresh() has_space = self.stored.space is not None if has_space: features.append("urn:xmpp:spaces:0") return features async def extended_features(self) -> list[Form]: is_group = self.type == MucType.GROUP form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") form.add_field( "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo" ) form.add_field("muc#roomconfig_persistentroom", "boolean", value=True) form.add_field("muc#roomconfig_changesubject", "boolean", value=False) form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch)) form.add_field("muc#roominfo_subjectmod", "boolean", value=False) if self.stored.id is not None and self.participants_filled: with self.xmpp.store.session() as orm: n = orm.scalar( sa.select(sa.func.count(Participant.id)).filter_by( room_id=self.stored.id ) ) else: n = self.n_participants if n is not None: form.add_field("muc#roominfo_occupants", value=str(n)) if d := self.description: form.add_field("muc#roominfo_description", value=d) if s := self.subject: form.add_field("muc#roominfo_subject", value=s) if name := self.name: form.add_field("muc#roomconfig_roomname", value=name) if self._set_avatar_task is not None: await self._set_avatar_task avatar = self.get_avatar() if avatar and (h := avatar.id): form.add_field( "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h ) form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h]) form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group) form.add_field( "muc#roomconfig_whois", "list-single", value="moderators" if self.is_anonymous else "anyone", ) form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group) form.add_field("muc#roomconfig_allowpm", "boolean", value=False) r = [form] if reaction_form := await self.restricted_emoji_extended_feature(): r.append(reaction_form) if self.stored.space is not None: node = await self.session.bookmarks.space_legacy_id_to_node( self.stored.space.legacy_id ) iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}" form.add_field("muc#roominfo_pubsub", value=iri) space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result") space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0") space_form.add_field("parent", label="Space parent", value=iri) return r def shutdown(self) -> None: _, user_jid = escape_nickname(self.jid, self.user_nick) for user_full_jid in self.user_full_jids(): presence = self.xmpp.make_presence( pfrom=user_jid, pto=user_full_jid, ptype="unavailable" ) presence["muc"]["affiliation"] = "none" presence["muc"]["role"] = "none" presence["muc"]["status_codes"] = {110, 332} presence.send() def user_full_jids(self) -> Iterable[JID]: for r in self.get_user_resources(): j = JID(self.user_jid) j.resource = r yield j @property def user_muc_jid(self) -> JID: _, user_muc_jid = escape_nickname(self.jid, self.user_nick) return user_muc_jid async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str: msg.set_from(self.user_muc_jid) if legacy_msg_id: msg["stanza_id"]["id"] = legacy_msg_id else: msg["stanza_id"]["id"] = str(uuid.uuid4()) msg["stanza_id"]["by"] = self.jid user_part = await self.get_user_participant() msg["occupant-id"]["id"] = user_part.stored.occupant_id self.archive.add(msg, user_part) for user_full_jid in self.user_full_jids(): self.log.debug("Echoing to %s", user_full_jid) msg = copy(msg) msg.set_to(user_full_jid) msg.send() return msg["stanza_id"]["id"] # type:ignore[no-any-return] def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None: self.__send_configuration_change((104,)) self._send_room_presence() def _send_room_presence(self, user_full_jid: JID | None = None) -> None: tos = self.user_full_jids() if user_full_jid is None else [user_full_jid] for to in tos: p = self.xmpp.make_presence(pfrom=self.jid, pto=to) if (avatar := self.get_avatar()) and (h := avatar.id): p["vcard_temp_update"]["photo"] = h else: p["vcard_temp_update"]["photo"] = "" p.send() @timeit async def join(self, join_presence: Presence) -> None: user_full_jid = join_presence.get_from() requested_nickname = join_presence.get_to().resource client_resource = user_full_jid.resource if client_resource in self.get_user_resources(): self.log.debug("Received join from a resource that is already joined.") if not requested_nickname or not client_resource: raise XMPPError("jid-malformed", by=self.jid) self.add_user_resource(client_resource) self.log.debug( "Resource %s of %s wants to join room %s with nickname %s", client_resource, self.user_jid, self.legacy_id, requested_nickname, ) user_nick = self.user_nick user_participant = None await self.__fill_participants() if "mav" in join_presence["muc_join"]: mav_until = await self.__get_mav() self.log.debug("client uses MUC affiliation versioning") if join_presence["muc_join"]["mav"]["since"] != mav_until: self.log.debug( "client mav: %s vs our mav: %s", join_presence["muc_join"]["mav"]["since"], mav_until, ) await self.__send_mav(user_full_jid, mav_until) else: mav_until = None async for participant in self.get_participants(): if participant.is_user: user_participant = participant continue participant.send_initial_presence(full_jid=user_full_jid) if user_participant is None: user_participant = await self.get_user_participant() with self.xmpp.store.session() as orm: orm.add(self.stored) with orm.no_autoflush: orm.refresh(self.stored, ["participants"]) if not user_participant.is_user: self.log.warning("is_user flag not set on user_participant") user_participant.is_user = True user_participant.send_initial_presence( user_full_jid, presence_id=join_presence["id"], nick_change=user_nick != requested_nickname, mav_until=mav_until, ) history_params = join_presence["muc_join"]["history"] maxchars = int_or_none(history_params["maxchars"]) maxstanzas = int_or_none(history_params["maxstanzas"]) seconds = int_or_none(history_params["seconds"]) try: since = self.xmpp.plugin["xep_0082"].parse(history_params["since"]) except ValueError: since = None if seconds is not None: since = datetime.now() - timedelta(seconds=seconds) if equals_zero(maxchars) or equals_zero(maxstanzas): log.debug("Joining client does not want any old-school MUC history-on-join") else: self.log.debug("Old school history fill") await self.__fill_history() await self.__old_school_history( user_full_jid, maxchars=maxchars, maxstanzas=maxstanzas, since=since, ) if self.HAS_SUBJECT: subject = self.subject or "" else: subject = self.description or self.name or "" self.__get_subject_setter_participant().set_room_subject( subject, user_full_jid, self.subject_date, ) if t := self._set_avatar_task: await t self._send_room_presence(user_full_jid) async def __get_mav(self) -> str: data = self.__get_mav_data() return self.__compute_mav_ver(data) def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]: data: list[tuple[str, MucAffiliation]] = [] with self.orm(expire_on_commit=False) as orm: self.stored = orm.merge(self.stored) for part in self.stored.participants: if not (part.is_user or part.contact): continue if part.affiliation == "none": continue data.append((self.__get_part_mav_id(part), part.affiliation)) return data def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str: return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute] def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str: self.log.debug("MAV data: %s", data) affs = [] for id_, aff in data: if aff == "none": continue affs.append(f"{id_}\0{aff}".encode()) affs.sort() return hashlib.sha256(b"\0".join(affs)).hexdigest() async def __send_mav(self, full_jid: JID, until: str) -> None: msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid) msg["muc"]["mav"]["until"] = until with self.orm(expire_on_commit=False) as orm: self.stored = orm.merge(self.stored) for part in self.stored.participants: if not (part.is_user or part.contact): continue item = MUCUserItem() item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr] item["affiliation"] = part.affiliation msg["muc"].append(item) msg.send() async def get_user_participant( self, *, fill_first: bool = False, store: bool = True, occupant_id: str | None = None, ) -> "LegacyParticipantType": """ Get the participant representing the gateway user :param kwargs: additional parameters for the :class:`.Participant` construction (optional) :return: """ p = await self.get_participant( self.user_nick, is_user=True, fill_first=fill_first, store=store, occupant_id=occupant_id, ) self.__store_participant(p) return p def __store_participant(self, p: "LegacyParticipantType") -> None: if self.get_lock("fill participants"): return try: p.commit() except IntegrityError as e: log.debug("Could not store participant: %r", e) self.stored = p.stored.room @overload async def get_participant(self, nickname: str) -> "LegacyParticipantType": ... @overload async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ... @overload async def get_participant( self, *, occupant_id: str, create: Literal[False] ) -> "LegacyParticipantType | None": ... @overload async def get_participant( self, *, occupant_id: str, create: Literal[True] ) -> "LegacyParticipantType": ... @overload async def get_participant( self, nickname: str, *, occupant_id: str ) -> "LegacyParticipantType": ... @overload async def get_participant( self, nickname: str, *, create: Literal[False] ) -> "LegacyParticipantType | None": ... @overload async def get_participant( self, nickname: str, *, create: Literal[True] ) -> "LegacyParticipantType": ... @overload async def get_participant( self, nickname: str, *, create: Literal[True], is_user: bool, fill_first: bool, store: bool, ) -> "LegacyParticipantType": ... @overload async def get_participant( self, nickname: str, *, create: Literal[False], is_user: bool, fill_first: bool, store: bool, ) -> "LegacyParticipantType | None": ... @overload async def get_participant( self, nickname: str, *, create: bool, fill_first: bool, ) -> "LegacyParticipantType | None": ... @overload async def get_participant( self, nickname: str, *, is_user: Literal[True], fill_first: bool, store: bool, occupant_id: str | None = None, ) -> "LegacyParticipantType": ... async def get_participant( self, nickname: str | None = None, *, create: bool = True, is_user: bool = False, fill_first: bool = False, store: bool = True, occupant_id: str | None = None, ) -> "LegacyParticipantType | None": """ Get a participant by their nickname. In non-anonymous groups, you probably want to use :meth:`.LegacyMUC.get_participant_by_contact` instead. :param nickname: Nickname of the participant (used as resource part in the MUC) :param create: By default, a participant is created if necessary. Set this to False to return None if participant was not created before. :param is_user: Whether this participant is the slidge user. :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first (internal use by slidge, plugins should not need that) :param store: persistently store the user in the list of MUC participants :param occupant_id: optionally, specify the unique ID for this participant, cf xep:`0421` :return: A participant of this room. """ if not any((nickname, occupant_id)): raise TypeError("You must specify either a nickname or an occupant ID") if fill_first: await self.__fill_participants() if self.stored.id is not None: with self.xmpp.store.session(expire_on_commit=False) as orm: if occupant_id is not None: stored = ( orm.query(Participant) .filter( Participant.room == self.stored, Participant.occupant_id == occupant_id, ) .one_or_none() ) elif nickname is not None: stored = ( orm.query(Participant) .filter( Participant.room == self.stored, (Participant.nickname == nickname) | (Participant.resource == nickname), ) .one_or_none() ) else: raise RuntimeError("NEVER") if stored is not None: if occupant_id and occupant_id != stored.occupant_id: warnings.warn( f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}", ) part = self.participant_from_store(stored) if occupant_id and nickname and nickname != stored.nickname: stored.nickname = nickname orm.add(stored) orm.commit() return part if not create: return None if occupant_id is None: occupant_id = "slidge-user" if is_user else str(uuid.uuid4()) if nickname is None: nickname = occupant_id with self.xmpp.store.session() as orm: if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname): nickname = f"{nickname} ({occupant_id})" if is_user: self.user_nick = nickname p = self._participant_cls( self, Participant( room=self.stored, nickname=nickname or occupant_id, is_user=is_user, occupant_id=occupant_id, ), ) if store: self.__store_participant(p) self.send_affiliation_change(p) return p def send_affiliation_change( self, part: "LegacyParticipantType", was: MucAffiliation = "none" ) -> None: # internal use by slidge if not self.participants_filled: return if self.get_lock("fill participants"): return if self.get_lock("fill history"): return if part.contact is None: return if part.is_system: return if was == part.affiliation: return system_part = self.get_system_participant() msg = system_part._make_message(mtype="normal") data = self.__get_mav_data() since_data = [ (id_, was if id_ == self.__get_part_mav_id(part) else aff) for id_, aff in data ] if part.affiliation == "none": since_data.append((self.__get_part_mav_id(part), was)) msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since_data) msg["muc"]["mav"]["until"] = self.__compute_mav_ver(data) item = MUCUserItem() item["affiliation"] = part.affiliation item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare msg["muc"].append(item) system_part._send(msg) def get_system_participant(self) -> "LegacyParticipantType": """ Get a pseudo-participant, representing the room itself Can be useful for events that cannot be mapped to a participant, e.g. anonymous moderation events, or announces from the legacy service :return: """ return self._participant_cls( self, Participant(occupant_id="room"), is_system=True ) @overload async def get_participant_by_contact( self, c: "LegacyContact" ) -> "LegacyParticipantType": ... @overload async def get_participant_by_contact( self, c: "LegacyContact", *, occupant_id: str | None = None ) -> "LegacyParticipantType": ... @overload async def get_participant_by_contact( self, c: "LegacyContact", *, create: Literal[False], occupant_id: str | None, ) -> "LegacyParticipantType | None": ... @overload async def get_participant_by_contact( self, c: "LegacyContact", *, create: Literal[True], occupant_id: str | None, ) -> "LegacyParticipantType": ... async def get_participant_by_contact( self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None ) -> "LegacyParticipantType | None": """ Get a non-anonymous participant. This is what should be used in non-anonymous groups ideally, to ensure that the Contact jid is associated to this participant :param c: The :class:`.LegacyContact` instance corresponding to this contact :param create: Creates the participant if it does not exist. :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for this participant. :return: """ await self.session.contacts.ready if self.stored.id is not None: with self.xmpp.store.session() as orm: self.stored = orm.merge(self.stored) stored = ( orm.query(Participant) .filter_by(contact=c.stored, room=self.stored) .one_or_none() ) if stored is None: if occupant_id is not None: stored = ( orm.query(Participant) .filter_by( occupant_id=occupant_id, room=self.stored, contact_id=None, ) .one_or_none() ) if stored is not None: self.log.debug( "Updating the contact of a previously anonymous participant" ) stored.contact_id = c.stored.id orm.add(stored) orm.commit() return self.participant_from_store(stored=stored, contact=c) if not create: return None else: if occupant_id and stored.occupant_id != occupant_id: warnings.warn( f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}", ) return self.participant_from_store(stored=stored, contact=c) nickname = c.name or unescape_node(c.jid.node) if self.stored.id is None: nick_available = True else: with self.xmpp.store.session() as orm: nick_available = self.xmpp.store.rooms.nick_available( orm, self.stored.id, nickname ) if not nick_available: self.log.debug("Nickname conflict") nickname = f"{nickname} ({c.jid.node})" p = self._participant_cls( self, Participant( nickname=nickname, room=self.stored, occupant_id=occupant_id or str(c.jid), ), contact=c, ) self.__store_participant(p) # FIXME: this is not great but given the current design, # during participants fill and history backfill we do not # want to send presence, because we might :update affiliation # and role afterwards. # We need a refactor of the MUC class… later™ if ( self.participants_filled and not self.get_lock("fill participants") and not self.get_lock("fill history") ): p.send_last_presence(force=True, no_cache_online=True) self.send_affiliation_change(p) return p @overload async def get_participant_by_legacy_id( self, legacy_id: str ) -> "LegacyParticipantType": ... @overload async def get_participant_by_legacy_id( self, legacy_id: str, *, occupant_id: str | None, create: Literal[True], ) -> "LegacyParticipantType": ... @overload async def get_participant_by_legacy_id( self, legacy_id: str, *, occupant_id: str | None, ) -> "LegacyParticipantType": ... @overload async def get_participant_by_legacy_id( self, legacy_id: str, *, occupant_id: str | None, create: Literal[False], ) -> "LegacyParticipantType | None": ... async def get_participant_by_legacy_id( self, legacy_id: str, *, occupant_id: str | None = None, create: bool = True, ) -> "LegacyParticipantType": try: c = await self.session.contacts.by_legacy_id(legacy_id) except ContactIsUser: return await self.get_user_participant(occupant_id=occupant_id) return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return] c, create=create, occupant_id=occupant_id ) def remove_participant( self, p: "LegacyParticipantType", kick: bool = False, ban: bool = False, reason: str | None = None, ) -> None: """ Call this when a participant leaves the room :param p: The participant :param kick: Whether the participant left because they were kicked :param ban: Whether the participant left because they were banned :param reason: Optionally, a reason why the participant was removed. """ self.log.debug("Removing participant: %s", p) if kick and ban: raise TypeError("Either kick or ban") if kick: codes = {307} elif ban: codes = {301} else: codes = None was = p.stored.affiliation p.stored.affiliation = "outcast" if ban else "none" p.stored.role = "none" presence = p._make_presence(ptype="unavailable", status_codes=codes) self.send_affiliation_change(p, was) if reason: presence["muc"].set_item_attr("reason", reason) p._send(presence) with self.orm() as orm: self.xmpp.store.participants.delete(orm, p.stored.id) orm.commit() def rename_participant(self, old_nickname: str, new_nickname: str) -> None: with self.xmpp.store.session() as orm: stored = ( orm.query(Participant) .filter_by(room=self.stored, nickname=old_nickname) .one_or_none() ) if stored is None: self.log.debug("Tried to rename a participant that we didn't know") return p = self.participant_from_store(stored) if p.nickname == old_nickname: p.nickname = new_nickname async def __old_school_history( self, full_jid: JID, maxchars: int | None = None, maxstanzas: int | None = None, seconds: int | None = None, since: datetime | None = None, ) -> None: """ Old-style history join (internal slidge use) :param full_jid: :param maxchars: :param maxstanzas: :param seconds: :param since: :return: """ if since is None: if seconds is None: start_date = datetime.now(tz=UTC) - timedelta(days=1) else: start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds) else: start_date = since or datetime.now(tz=UTC) - timedelta(days=1) for h_msg in self.archive.get_all( start_date=start_date, end_date=None, last_page_n=maxstanzas ): msg = h_msg.stanza_component_ns msg["delay"]["stamp"] = h_msg.when msg["delay"]["from"] = self.jid msg.set_to(full_jid) self.xmpp.send(msg, False) async def send_mam(self, iq: Iq) -> None: await self.__fill_history() form_values = iq["mam"]["form"].get_values() start_date = str_to_datetime_or_none(form_values.get("start")) end_date = str_to_datetime_or_none(form_values.get("end")) after_id = form_values.get("after-id") before_id = form_values.get("before-id") sender = form_values.get("with") ids = form_values.get("ids") or () if max_str := iq["mam"]["rsm"]["max"]: try: max_results = int(max_str) except ValueError: max_results = None else: max_results = None after_id_rsm = iq["mam"]["rsm"]["after"] after_id = after_id_rsm or after_id before_rsm = iq["mam"]["rsm"]["before"] if before_rsm is not None and max_results is not None: last_page_n = max_results # - before_rsm is True means the empty element , which means # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards # - before_rsm == "an ID" means an ID if before_rsm is not True: before_id = before_rsm else: last_page_n = None first = None last = None count = 0 it = self.archive.get_all( start_date, end_date, before_id, after_id, ids, last_page_n, sender, bool(iq["mam"]["flip_page"]), ) for history_msg in it: last = xmpp_id = history_msg.id if first is None: first = xmpp_id wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from()) wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"] wrapper_msg["mam_result"]["id"] = xmpp_id wrapper_msg["mam_result"].append(history_msg.forwarded()) wrapper_msg.send() count += 1 if max_results and count == max_results: break if max_results: try: next(it) except StopIteration: complete = True else: complete = False else: complete = True reply = iq.reply() if not self.STABLE_ARCHIVE: reply["mam_fin"]["stable"] = "false" if complete: reply["mam_fin"]["complete"] = "true" reply["mam_fin"]["rsm"]["first"] = first reply["mam_fin"]["rsm"]["last"] = last reply["mam_fin"]["rsm"]["count"] = str(count) reply.send() async def send_mam_metadata(self, iq: Iq) -> None: await self.__fill_history() await self.archive.send_metadata(iq) async def kick_resource(self, r: str) -> None: """ Kick a XMPP client of the user. (slidge internal use) :param r: The resource to kick """ pto = JID(self.user_jid) pto.resource = r p = self.xmpp.make_presence( pfrom=(await self.get_user_participant()).jid, pto=pto ) p["type"] = "unavailable" p["muc"]["affiliation"] = "none" p["muc"]["role"] = "none" p["muc"]["status_codes"] = {110, 333} p.send() async def __get_bookmark(self) -> Item | None: item = Item() item["id"] = self.jid iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid) iq["pubsub"]["items"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS iq["pubsub"]["items"].append(item) try: ans = await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq) if len(ans["pubsub"]["items"]) != 1: return None # this below creates the item if it wasn't here already # (slixmpp annoying magic) item = ans["pubsub"]["items"]["item"] item["id"] = self.jid return item # type:ignore[no-any-return] except IqTimeout: warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout") return None except IqError as exc: warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}") return None except PermissionError: warnings.warn( f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks" ) return None async def add_to_bookmarks( self, auto_join: bool = True, preserve: bool = True, pin: bool | None = None, notify: WhenLiteral | None = None, ) -> None: """ Add the MUC to the user's XMPP bookmarks (:xep:`0402`) This requires that slidge has the IQ privileged set correctly on the XMPP server :param auto_join: whether XMPP clients should automatically join this MUC on startup. In theory, XMPP clients will receive a "push" notification when this is called, and they will join if they are online. :param preserve: preserve auto-join and bookmarks extensions set by the user outside slidge :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity. If set to ``None`` (default), the bookmark pinning status will be untouched. :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity. If set to ``None`` (default), the setting will be untouched. Only the "global" notification setting is supported (ie, per client type is not possible). """ existing = await self.__get_bookmark() if preserve else None user_resource = (await self.get_user_participant()).jid.resource new = Item() new["id"] = self.jid new["conference"]["nick"] = user_resource if existing is None: change = True new["conference"]["autojoin"] = auto_join else: change = existing["conference"]["nick"] != user_resource new["conference"]["autojoin"] = existing["conference"]["autojoin"] existing_extensions = ( existing is not None and "extensions" in existing["conference"] ) # preserving extensions we don't know about is a MUST if existing_extensions: assert existing is not None for el in existing["conference"]["extensions"].xml: if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None: continue if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None: continue new["conference"]["extensions"].append(el) if pin is not None: if existing_extensions: assert existing is not None existing_pin = ( existing["conference"]["extensions"].get_plugin( "pinned", check=True ) is not None ) if existing_pin != pin: change = True new["conference"]["extensions"]["pinned"] = pin if notify is not None: new["conference"]["extensions"].enable("notify") if existing_extensions: assert existing is not None existing_notify = existing["conference"]["extensions"].get_plugin( "notify", check=True ) if existing_notify is None: change = True else: if existing_notify.get_config() != notify: change = True for el in existing_notify: new["conference"]["extensions"]["notify"].append(el) new["conference"]["extensions"]["notify"].configure(notify) if change: iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid) iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS iq["pubsub"]["publish"].append(new) iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS try: await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq) except PermissionError: warnings.warn( f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user" ) # fallback by forcing invitation bookmark_add_fail = True except IqError as e: warnings.warn( f"Something went wrong while trying to set the bookmarks: {e}" ) # fallback by forcing invitation bookmark_add_fail = True else: bookmark_add_fail = False else: self.log.debug("Bookmark does not need updating.") return if bookmark_add_fail: self.session.send_gateway_invite( self, reason="This group could not be added automatically for you, most" "likely because this gateway is not configured as a privileged entity. " "Contact your administrator.", ) elif existing is None and self.session.user.preferences.get( "always_invite_when_adding_bookmarks", True ): self.session.send_gateway_invite( self, reason="The gateway is configured to always send invitations for groups.", ) async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None: """ Called when the user tries to set the avatar of the room from an XMPP client. If the set avatar operation is completed, should return a legacy image unique identifier. In this case the MUC avatar will be immediately updated on the XMPP side. If data is not None and this method returns None, then we assume that self.set_avatar() will be called elsewhere, eg triggered by a legacy room update event. :param data: image data or None if the user meant to remove the avatar :param mime: the mime type of the image. Since this is provided by the XMPP client, there is no guarantee that this is valid or correct. :return: A unique avatar identifier, which will trigger :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else. """ raise NotImplementedError async def on_set_config( self, name: str | None, description: str | None, ) -> None: """ Triggered when the user requests changing the room configuration. Only title and description can be changed at the moment. The legacy module is responsible for updating :attr:`.title` and/or :attr:`LegacyMUC.description` of this instance. If :attr:`.HAS_DESCRIPTION` is set to False, description will always be ``None``. :param name: The new name of the room. :param description: The new description of the room. """ raise NotImplementedError async def on_destroy_request(self, reason: str | None) -> None: """ Triggered when the user requests room destruction. :param reason: Optionally, a reason for the destruction """ raise NotImplementedError async def parse_mentions(self, text: str | None) -> tuple[Mention, ...]: if not text: return () with self.xmpp.store.session() as orm: await self.__fill_participants() orm.add(self.stored) participants = { p.nickname: p for p in self.stored.participants if len(p.nickname) > 1 } if len(participants) == 0: return () result = [] for match in re.finditer( "|".join( sorted( [re.escape(nick) for nick in participants], key=lambda nick: len(nick), reverse=True, ) ), text, ): span = match.span() nick = match.group() if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION: continue if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION: participant = self.participant_from_store( stored=participants[nick], ) if contact := participant.contact: result.append( Mention(contact=contact, start=span[0], end=span[1]) ) return tuple(result) async def on_set_subject(self, subject: str) -> None: """ Triggered when the user requests changing the room subject. The legacy module is responsible for updating :attr:`.subject` of this instance. :param subject: The new subject for this room. """ raise NotImplementedError async def on_set_thread_subject(self, thread: str, subject: str) -> None: """ Triggered when the user requests changing the subject of a specific thread. :param thread: Legacy identifier of the thread :param subject: The new subject for this thread. """ raise NotImplementedError async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None: """ Triggered when the user attempts to retract a message that was sent in a MUC using :xep:`0425`. If retraction is not possible, this should raise the appropriate XMPPError with a human-readable message. NB: the legacy module is responsible for calling :func:`LegacyParticipant.moderate` when this is successful, because slidge will acknowledge the moderation IQ, but will not send the moderation message from the MUC automatically. :param legacy_msg_id: The legacy ID of the message to be retracted :param reason: Optionally, a reason for the moderation, given by the user-moderator. """ raise NotImplementedError async def on_leave(self) -> None: """ Triggered when the user leaves a group via the dedicated slidge command or the :xep:`0077` ```` mechanism. This should be interpreted as definitely leaving the group. """ raise NotImplementedError @property def participants_filled(self) -> bool: try: return self.stored.participants_filled except DetachedInstanceError: with self.orm(expire_on_commit=False) as orm: orm.add(self.stored) with orm.no_autoflush: orm.refresh(self.stored, ["participants_filled"]) return self.stored.participants_filled def get_archived_messages(self, msg_id: str) -> Iterator[HistoryMessage]: """ Query the slidge archive for messages sent in this group :param msg_id: Message ID of the message in question. Can be either a legacy ID or an XMPP ID. :return: Iterator over messages. A single legacy ID can map to several messages, because of multi-attachment messages. """ with self.xmpp.store.session() as orm: for stored in self.xmpp.store.mam.get_messages( orm, self.stored.id, ids=[str(msg_id)] ): yield HistoryMessage(stored.stanza) def set_origin_id(msg: Message, origin_id: str) -> None: sub = ET.Element("{urn:xmpp:sid:0}origin-id") sub.attrib["id"] = origin_id msg.xml.append(sub) def int_or_none(x: str) -> int | None: try: return int(x) except ValueError: return None def equals_zero(x: int | None) -> bool: if x is None: return False else: return x == 0 def str_to_datetime_or_none(date: str | None) -> datetime | None: if date is None: return None try: return str_to_datetime(date) except ValueError: return None def bookmarks_form() -> Form: form = Form() form["type"] = "submit" form.add_field( "FORM_TYPE", value="http://jabber.org/protocol/pubsub#publish-options", ftype="hidden", ) form.add_field("pubsub#persist_items", value="1") form.add_field("pubsub#max_items", value="max") form.add_field("pubsub#send_last_published_item", value="never") form.add_field("pubsub#access_model", value="whitelist") return form _BOOKMARKS_OPTIONS = bookmarks_form() _WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_" log = logging.getLogger(__name__) slidge/slidge/main.py000066400000000000000000000171021522535037600151030ustar00rootroot00000000000000""" Slidge can be configured via CLI args, environment variables and/or INI files. To use env vars, use this convention: ``--home-dir`` becomes ``HOME_DIR``. Everything in ``/etc/slidge/conf.d/*`` is automatically used. To use a plugin-specific INI file, put it in another dir, and launch slidge with ``-c /path/to/plugin-specific.conf``. Use the long version of the CLI arg without the double dash prefix inside this INI file, eg ``debug=true``. An example configuration file is available at https://codeberg.org/slidge/slidge/src/branch/main/dev/confs/slidge-example.ini """ import asyncio import importlib import inspect import logging import logging.config import os import signal from pathlib import Path import configargparse import slidge from slidge.core import config from slidge.core.gateway import BaseGateway from slidge.db import SlidgeStore from slidge.db.avatar import avatar_cache from slidge.db.meta import get_engine from slidge.migration import migrate from slidge.util.conf import ConfigModule from slidge.util.types import AnyGateway class MainConfig(ConfigModule): def update_dynamic_defaults(self, args: configargparse.Namespace) -> None: # force=True is needed in case we call a logger before this is reached, # or basicConfig has no effect if args.log_config: logging.config.fileConfig(args.log_config) else: logging.basicConfig( level=args.loglevel, filename=args.log_file, force=True, format=args.log_format, ) if args.home_dir is None: args.home_dir = Path("/var/lib/slidge") / str(args.jid) if args.db_url is None: args.db_url = f"sqlite:///{args.home_dir}/slidge.sqlite" class SigTermInterrupt(Exception): pass def get_configurator(from_entrypoint: bool = False) -> MainConfig: p = configargparse.ArgumentParser( default_config_files=[ f"{p}/*" for p in os.getenv("SLIDGE_CONF_DIR", "/etc/slidge/conf.d/").split(":") ], description=__doc__, ) p.add_argument( "-c", "--config", help="Path to a INI config file.", env_var="SLIDGE_CONFIG", is_config_file=True, ) p.add_argument( "--log-config", help="Path to a INI config file to personalise logging output. Refer to " " " "for details.", ) p.add_argument( "-q", "--quiet", help="loglevel=WARNING (unused if --log-config is specified)", action="store_const", dest="loglevel", const=logging.WARNING, default=logging.INFO, env_var="SLIDGE_QUIET", ) p.add_argument( "-d", "--debug", help="loglevel=DEBUG (unused if --log-config is specified)", action="store_const", dest="loglevel", const=logging.DEBUG, env_var="SLIDGE_DEBUG", ) p.add_argument( "--version", action="version", version=f"%(prog)s {slidge.__version__}", ) configurator = MainConfig( config, p, skip_options=("legacy_module",) if from_entrypoint else () ) return configurator def get_parser() -> configargparse.ArgumentParser: return get_configurator().parser def configure(from_entrypoint: bool) -> list[str]: configurator = get_configurator(from_entrypoint) _args, unknown_argv = configurator.set_conf() if not (h := config.HOME_DIR).exists(): logging.info("Creating directory '%s'", h) os.makedirs(h) config.UPLOAD_REQUESTER = config.UPLOAD_REQUESTER or config.JID.bare return unknown_argv def handle_sigterm(_signum: int, _frame: object) -> None: logging.info("Caught SIGTERM") raise SigTermInterrupt def main(module_name: str | None = None) -> None: from_entrypoint = module_name is not None signal.signal(signal.SIGTERM, handle_sigterm) unknown_argv = configure(from_entrypoint) logging.info("Starting slidge version %s", slidge.__version__) if module_name is not None: config.LEGACY_MODULE = module_name legacy_module = importlib.import_module(config.LEGACY_MODULE) logging.debug("Legacy module: %s", dir(legacy_module)) logging.info( "Starting legacy module: '%s' version %s", config.LEGACY_MODULE, getattr(legacy_module, "__version__", "No version"), ) if plugin_config_obj := getattr( legacy_module, "config", getattr(legacy_module, "Config", None) ): # If the legacy module has default parameters that depend on dynamic defaults # of the slidge main config, it needs to be refreshed at this point, because # now the dynamic defaults are set. if inspect.ismodule(plugin_config_obj): importlib.reload(plugin_config_obj) logging.debug("Found a config object in plugin: %r", plugin_config_obj) ConfigModule.ENV_VAR_PREFIX += ( f"_{config.LEGACY_MODULE.split('.')[-1].upper()}_" ) logging.debug("Env var prefix: %s", ConfigModule.ENV_VAR_PREFIX) _, unknown_argv = ConfigModule(plugin_config_obj).set_conf(unknown_argv) if unknown_argv: logging.error( f"These config options have not been recognized and ignored: {unknown_argv}" ) migrate() gw_cls: type[AnyGateway] = BaseGateway.get_unique_subclass() # type:ignore[assignment] store = SlidgeStore( get_engine( config.DB_URL, echo=logging.getLogger().isEnabledFor(level=logging.DEBUG), ) ) BaseGateway.store = store gateway = gw_cls() avatar_cache.store = gateway.store.avatars avatar_cache.set_dir(config.HOME_DIR / "slidge_avatars_v3") gateway.add_event_handler("connection_lost", _on_connection_lost) gateway.add_event_handler("disconnected", _on_disconnected) gateway.add_event_handler("stream_error", _on_stream_error) gateway.connect() return_code = 0 try: gateway.loop.run_forever() except KeyboardInterrupt: logging.debug("Received SIGINT") except SigTermInterrupt: logging.debug("Received SIGTERM") except SystemExit as e: return_code = e.code # type: ignore logging.debug("Exit called") except Exception as e: return_code = 2 logging.exception("Exception in __main__") logging.exception(e) finally: if gateway.has_crashed: if return_code != 0: logging.warning("Return code has been set twice. Please report this.") return_code = 3 if gateway.is_connected(): logging.debug("Gateway is connected, cleaning up") gateway.del_event_handler("disconnected", _on_disconnected) gateway.loop.run_until_complete(asyncio.gather(*gateway.shutdown())) gateway.disconnect() gateway.loop.run_until_complete(gateway.disconnected) logging.info("Successful clean shut down") else: logging.debug("Gateway is not connected, no need to clean up") avatar_cache.close() gateway.loop.run_until_complete(gateway.http.close()) logging.debug("Exiting with code %s", return_code) exit(return_code) def _on_disconnected(e: BaseException) -> None: logging.error("Disconnected from the XMPP server: '%s'.", e) exit(10) def _on_stream_error(e: BaseException) -> None: logging.error("Stream error: '%s'.", e) def _on_connection_lost(*args: object) -> None: logging.error("Connection lost: '%s'", args) exit(15) slidge/slidge/migration.py000066400000000000000000000023671522535037600161570ustar00rootroot00000000000000import logging import sys import traceback from pathlib import Path from alembic import command from alembic.config import Config def get_alembic_cfg() -> Config: alembic_cfg = Config() alembic_cfg.set_section_option( "alembic", "script_location", str(Path(__file__).parent / "db" / "alembic"), ) return alembic_cfg def migrate() -> None: try: command.upgrade(get_alembic_cfg(), "head") except Exception as e: traceback.print_exception(e) print( "Something went wrong during the migration. " "This is expected if you upgrade from slidge 0.2, in this case you need to start from a fresh database." ) exit(1) def main() -> None: """ Updates the (dev) database in ./dev/slidge.sqlite and generates a revision Usage: python -m slidge.migration "Revision message blah blah blah" """ dev_db = Path(".") / "dev" / "slidge.sqlite" if dev_db.exists(): # always start from a clean state dev_db.unlink() alembic_cfg = get_alembic_cfg() command.upgrade(alembic_cfg, "head") command.revision(alembic_cfg, sys.argv[1], autogenerate=True) log = logging.getLogger(__name__) if __name__ == "__main__": main() slidge/slidge/py.typed000066400000000000000000000000001522535037600152710ustar00rootroot00000000000000slidge/slidge/slixfix/000077500000000000000000000000001522535037600152725ustar00rootroot00000000000000slidge/slidge/slixfix/__init__.py000066400000000000000000000042261522535037600174070ustar00rootroot00000000000000# This module contains patches for slixmpp; some have pending requests upstream # and should be removed on the next slixmpp release. import uuid # ruff: noqa: F401 import slixmpp.plugins import slixmpp.stanza.roster from slixmpp import Iq, Message from slixmpp.exceptions import IqError from slixmpp.plugins.xep_0050 import XEP_0050, Command from slixmpp.plugins.xep_0356.permissions import IqPermission from slixmpp.plugins.xep_0356.privilege import XEP_0356, PrivilegedIqError from slixmpp.plugins.xep_0469.stanza import NS as PINNED_NS from slixmpp.plugins.xep_0469.stanza import Pinned from slixmpp.xmlstream import StanzaBase from ..util.archive_msg import set_client_namespace from . import ( xep_0077, xep_0100, xep_0153, xep_0292, ) def set_pinned(self, val: bool) -> None: # noqa extensions = self.parent() if val: extensions.enable("pinned") else: extensions._del_sub(f"{{{PINNED_NS}}}pinned") Pinned.set_pinned = set_pinned def session_bind(self, jid) -> None: # noqa self.xmpp.plugin["xep_0030"].add_feature(Command.namespace) # awful hack to for the disco items: we need to comment this line # related issue: https://todo.sr.ht/~nicoco/slidge/131 # self.xmpp['xep_0030'].set_items(node=Command.namespace, items=tuple()) XEP_0050.session_bind = session_bind # type:ignore def reply(self, body=None, clear: bool = True): # noqa """ Overrides slixmpp's Message.reply(), since it strips to sender's resource for mtype=groupchat, and we do not want that, because when we raise an XMPPError, we actually want to preserve the resource. (this is called in RootStanza.exception() to handle XMPPErrors) """ new_message = StanzaBase.reply(self, clear) new_message["thread"] = self["thread"] new_message["parent_thread"] = self["parent_thread"] del new_message["id"] if self.stream is not None and self.stream.use_message_ids: new_message["id"] = self.stream.new_id() if body is not None: new_message["body"] = body return new_message Message.reply = reply # type: ignore slixmpp.plugins.PLUGINS.extend( [ "xep_0292_provider", ] ) slidge/slidge/slixfix/delivery_receipt.py000066400000000000000000000026301522535037600212030ustar00rootroot00000000000000""" XEP-0184 Delivery Receipts The corresponding slixmpp module is a bit too rigid, this is our implementation to selectively choose when we send delivery receipts """ from typing import Generic, TypeVar from slixmpp import JID, Message from slixmpp.types import MessageTypes GatewayType = TypeVar("GatewayType") class DeliveryReceipt(Generic[GatewayType]): xmpp: GatewayType def __init__(self, xmpp: GatewayType) -> None: self.xmpp = xmpp def ack(self, msg: Message) -> None: """ Send a XEP-0184 (delivery receipt) in response to a message, if appropriate. :param msg: """ if not self.requires_receipt(msg): return ack = self.make_ack(msg["id"], msg["to"], msg["from"].bare, msg["type"]) ack.send() def make_ack( self, msg_id: str, mfrom: JID, mto: JID, mtype: MessageTypes = "chat" ) -> Message: ack = self.xmpp.Message() ack["type"] = mtype ack["to"] = mto ack["from"] = mfrom ack["receipt"] = msg_id return ack def requires_receipt(self, msg: Message) -> bool: """ Check if a message is eligible for a delivery receipt. :param msg: :return: """ return ( msg["request_receipt"] and msg["type"] in self.xmpp.plugin["xep_0184"].ack_types and not msg["receipt"] ) slidge/slidge/slixfix/roster.py000066400000000000000000000036451522535037600171720ustar00rootroot00000000000000import logging from collections.abc import Iterable from typing import Any, Generic, TypeVar from slixmpp import JID from slixmpp.types import JidStr, OptJidStr, RosterState GatewayType = TypeVar("GatewayType") class YesSet(set): """ A pseudo-set which always test True for membership """ def __contains__(self, item: object) -> bool: log.debug("Test in") return True class RosterBackend(Generic[GatewayType]): """ A pseudo-roster for the gateway component. If a user is in the user store, this will behave as if the user is part of the roster with subscription "both", and "none" otherwise. This is rudimentary but the only sane way I could come up with so far. """ xmpp: GatewayType def __init__(self, xmpp: GatewayType) -> None: self.xmpp = xmpp def entries( self, owner: OptJidStr, db_state: dict[str, Any] | None = None ) -> Iterable[str]: return YesSet() def save( self, owner: JidStr, jid: JidStr, state: RosterState, db_state: dict[str, Any] ) -> None: pass def load( self, owner: JidStr, jid: JidStr, db_state: dict[str, Any] ) -> RosterState | None: session = self.xmpp.get_session_from_jid(JID(jid)) if session is None: return { "name": "", "groups": [], "from": False, "to": False, "pending_in": False, "pending_out": False, "whitelisted": False, "subscription": "both", } else: return { "name": "", "groups": [], "from": True, "to": True, "pending_in": False, "pending_out": False, "whitelisted": False, "subscription": "none", } log = logging.getLogger(__name__) slidge/slidge/slixfix/xep_0077/000077500000000000000000000000001522535037600165435ustar00rootroot00000000000000slidge/slidge/slixfix/xep_0077/__init__.py000066400000000000000000000005051522535037600206540ustar00rootroot00000000000000# Slixmpp: The Slick XMPP Library # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # This file is part of Slixmpp. # See the file LICENSE for copying permission. from slixmpp.plugins.base import register_plugin from .register import XEP_0077 from .stanza import Register, RegisterFeature register_plugin(XEP_0077) slidge/slidge/slixfix/xep_0077/register.py000066400000000000000000000244311522535037600207450ustar00rootroot00000000000000# Slixmpp: The Slick XMPP Library # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # This file is part of Slixmpp. # See the file LICENSE for copying permission. import logging import ssl from slixmpp.exceptions import XMPPError from slixmpp.plugins import BasePlugin from slixmpp.stanza import Iq, StreamFeatures from slixmpp.xmlstream import JID, StanzaBase, register_stanza_plugin from slixmpp.xmlstream.handler import CoroutineCallback from slixmpp.xmlstream.matcher import StanzaPath from . import stanza from .stanza import Register, RegisterFeature log = logging.getLogger(__name__) # noinspection PyPep8Naming class XEP_0077(BasePlugin): """ XEP-0077: In-Band Registration Events: :: user_register -- After successful validation and add to the user store in api["user_validate"] user_unregister -- After successful user removal in api["user_remove"] user_modify -- After successful user modify in api["user_modify"] Config: :: form_fields and form_instructions are only used if api["make_registration_form"] is not overridden; in this case form_fields MUST be None API: :: user_get(jid, node, ifrom, iq) Returns a dict-like object containing `form_fields` for this user or None user_remove(jid, node, ifrom, iq) Removes a user or raise KeyError in case the user is not found in the user store make_registration_form(self, jid, node, ifrom, iq) Returns an iq reply to a registration form request, pre-filled and with in case the requesting entity is already registered to us user_validate((self, jid, node, ifrom, registration) Add the user to the user store or raise ValueError(msg) if any problem is encountered msg is sent back to the XMPP client as an error message. user_modify(jid, node, ifrom, iq) Modify the user in the user store or raise ValueError(msg) (similarly to user_validate) """ name = "xep_0077" description = "XEP-0077: In-Band Registration (slidge)" dependencies = {"xep_0004", "xep_0066"} stanza = stanza default_config = { "create_account": True, "force_registration": False, "order": 50, "form_fields": {"username", "password"}, "form_instructions": "Enter your credentials", "enable_subscription": True, } _user_store: dict[str, dict[str, str]] def plugin_init(self) -> None: register_stanza_plugin(StreamFeatures, RegisterFeature) register_stanza_plugin(Iq, Register) if self.xmpp.is_component: self.xmpp.plugin["xep_0030"].add_feature("jabber:iq:register") self.xmpp.register_handler( CoroutineCallback( "registration", StanzaPath(f"/iq@to={self.xmpp.boundjid.bare}/register"), self._handle_registration, ) ) self._user_store = {} self.api.register(self._user_get, "user_get") self.api.register(self._user_remove, "user_remove") self.api.register(self._user_modify, "user_modify") self.api.register(self._make_registration_form, "make_registration_form") self.api.register(self._user_validate, "user_validate") else: self.xmpp.register_feature( "register", self._handle_register_feature, restart=False, order=self.order, ) register_stanza_plugin(Register, self.xmpp.plugin["xep_0004"].stanza.Form) register_stanza_plugin(Register, self.xmpp.plugin["xep_0066"].stanza.OOB) self.xmpp.add_event_handler("connected", self._force_registration) def plugin_end(self) -> None: if not self.xmpp.is_component: self.xmpp.unregister_feature("register", self.order) def _user_get(self, _jid, _node, _ifrom, iq): return self._user_store.get(iq["from"].bare) def _user_remove(self, _jid, _node, _ifrom, iq): return self._user_store.pop(iq["from"].bare) async def _make_registration_form(self, _jid, _node, _ifrom, iq: Iq): reg = iq["register"] user = await self.api["user_get"](None, None, iq["from"], iq) if user is None: user = {} else: reg["registered"] = True reg["instructions"] = self.form_instructions for field in self.form_fields: data = user.get(field, "") if data: reg[field] = data else: # Add a blank field reg.add_field(field) reply = iq.reply() reply.set_payload(reg.xml) return reply def _user_validate(self, _jid, _node, ifrom, registration) -> None: self._user_store[ifrom.bare] = { key: registration[key] for key in self.form_fields } def _user_modify(self, _jid, _node, ifrom, registration) -> None: self._user_store[ifrom.bare] = { key: registration[key] for key in self.form_fields } async def _handle_registration(self, iq: StanzaBase): if iq["type"] == "get": if not self.enable_subscription: raise XMPPError( "bad-request", text="You must use adhoc commands to register to this gateway.", ) await self._send_form(iq) elif iq["type"] == "set": form_dict = iq["register"]["form"].get_values() or iq["register"] if form_dict.get("remove"): try: await self.api["user_remove"](None, None, iq["from"], iq) except KeyError: _send_error( iq, "404", "cancel", "item-not-found", "User not found", ) else: reply = iq.reply() reply.send() self.xmpp.event("user_unregister", iq) return if not self.enable_subscription: raise XMPPError( "bad-request", text="You must use adhoc commands to register to this gateway.", ) if self.form_fields is not None: for field in self.form_fields: if not iq["register"][field]: # Incomplete Registration _send_error( iq, "406", "modify", "not-acceptable", "Please fill in all fields.", ) return user = await self.api["user_get"](None, None, iq["from"], iq) try: if user is None: await self.api["user_validate"](None, None, iq["from"], form_dict) else: await self.api["user_modify"](None, None, iq["from"], form_dict) except ValueError as e: _send_error(iq, "406", "modify", "not-acceptable", "\n".join(e.args)) return reply = iq.reply() reply.send() if user is None: self.xmpp.event("user_register", iq) else: self.xmpp.event("user_modify", iq) async def _send_form(self, iq) -> None: reply = await self.api["make_registration_form"](None, None, iq["from"], iq) reply.send() def _force_registration(self, _event) -> None: if self.force_registration: self.xmpp.add_filter("in", self._force_stream_feature) def _force_stream_feature(self, stanza_): if isinstance(stanza_, StreamFeatures): if not self.xmpp.disable_starttls: if "starttls" not in self.xmpp.features: return stanza_ elif not isinstance(self.xmpp.socket, ssl.SSLSocket): return stanza_ if "mechanisms" not in self.xmpp.features: log.debug("Forced adding in-band registration stream feature") stanza_.enable("register") self.xmpp.del_filter("in", self._force_stream_feature) return stanza_ async def _handle_register_feature(self, _features) -> bool: if "mechanisms" in self.xmpp.features: # We have already logged in with an account return False if self.create_account and self.xmpp.event_handled("register"): form = await self.get_registration() await self.xmpp.event_async("register", form) return True return False def get_registration(self, jid=None, ifrom=None, timeout=None, callback=None): iq = self.xmpp.Iq() iq["type"] = "get" iq["to"] = jid iq["from"] = ifrom iq.enable("register") return iq.send(timeout=timeout, callback=callback) def cancel_registration(self, jid=None, ifrom=None, timeout=None, callback=None): iq = self.xmpp.Iq() iq["type"] = "set" iq["to"] = jid iq["from"] = ifrom iq["register"]["remove"] = True return iq.send(timeout=timeout, callback=callback) def change_password( self, password, jid=None, ifrom=None, timeout=None, callback=None ): iq = self.xmpp.Iq() iq["type"] = "set" iq["to"] = jid iq["from"] = ifrom if self.xmpp.is_component: ifrom = JID(ifrom) iq["register"]["username"] = ifrom.user else: iq["register"]["username"] = self.xmpp.boundjid.user iq["register"]["password"] = password return iq.send(timeout=timeout, callback=callback) def _send_error(iq, code, error_type, name, text: str="") -> None: # It would be nice to raise XMPPError but the iq payload # should include the register info reply = iq.reply() reply.set_payload(iq["register"].xml) reply.error() reply["error"]["code"] = code reply["error"]["type"] = error_type reply["error"]["condition"] = name reply["error"]["text"] = text reply.send() slidge/slidge/slixfix/xep_0077/stanza.py000066400000000000000000000046471522535037600204300ustar00rootroot00000000000000# Slixmpp: The Slick XMPP Library # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # This file is part of Slixmpp. # See the file LICENSE for copying permission. from __future__ import unicode_literals from typing import ClassVar, Set from slixmpp.xmlstream import ElementBase class Register(ElementBase): namespace = "jabber:iq:register" name = "query" plugin_attrib = "register" interfaces = { "username", "password", "email", "nick", "name", "first", "last", "address", "city", "state", "zip", "phone", "url", "date", "misc", "text", "key", "registered", "remove", "instructions", "fields", } sub_interfaces = interfaces form_fields = { "username", "password", "email", "nick", "name", "first", "last", "address", "city", "state", "zip", "phone", "url", "date", "misc", "text", "key", } def get_registered(self) -> bool: present = self.xml.find("{%s}registered" % self.namespace) return present is not None def get_remove(self) -> bool: present = self.xml.find("{%s}remove" % self.namespace) return present is not None def set_registered(self, value) -> None: if value: self.add_field("registered") else: del self["registered"] def set_remove(self, value) -> None: if value: self.add_field("remove") else: del self["remove"] def add_field(self, value: str) -> None: self._set_sub_text(value, "", keep=True) def get_fields(self): fields = set() for field in self.form_fields: if self.xml.find("{%s}%s" % (self.namespace, field)) is not None: fields.add(field) return fields def set_fields(self, fields) -> None: del self["fields"] for field in fields: self._set_sub_text(field, "", keep=True) def del_fields(self) -> None: for field in self.form_fields: self._del_sub(field) class RegisterFeature(ElementBase): name = "register" namespace = "http://jabber.org/features/iq-register" plugin_attrib = name interfaces: ClassVar[Set[str]] = set() slidge/slidge/slixfix/xep_0100/000077500000000000000000000000001522535037600165265ustar00rootroot00000000000000slidge/slidge/slixfix/xep_0100/__init__.py000066400000000000000000000001531522535037600206360ustar00rootroot00000000000000from slixmpp.plugins.base import register_plugin from .gateway import XEP_0100 register_plugin(XEP_0100) slidge/slidge/slixfix/xep_0100/gateway.py000066400000000000000000000103471522535037600205460ustar00rootroot00000000000000import logging import warnings from slixmpp import JID, Iq, Message, Presence, register_stanza_plugin from slixmpp.exceptions import XMPPError from slixmpp.plugins.base import BasePlugin from slidge.core import config from . import stanza log = logging.getLogger(__name__) class XEP_0100(BasePlugin): name = "xep_0100" description = "XEP-0100: Gateway interaction (slidge)" dependencies = { "xep_0030", # Service discovery "xep_0077", # In band registration "xep_0356", # Privileged entities } default_config = { "component_name": "SliXMPP gateway", "type": "xmpp", "needs_registration": True, } def plugin_init(self) -> None: if not self.xmpp.is_component: log.error("Only components can be gateways, aborting plugin load") return self.xmpp.plugin["xep_0030"].add_identity( name=self.component_name, category="gateway", itype=self.type ) # Without that BaseXMPP sends unsub/unavailable on sub requests, and we don't want that self.xmpp.client_roster.auto_authorize = False self.xmpp.client_roster.auto_subscribe = False self.xmpp.add_event_handler("user_register", self.on_user_register) self.xmpp.add_event_handler("user_unregister", self.on_user_unregister) self.xmpp.add_event_handler( "presence_unsubscribe", self.on_presence_unsubscribe ) self.xmpp.add_event_handler("message", self.on_message) register_stanza_plugin(Iq, stanza.Gateway) def plugin_end(self) -> None: if not self.xmpp.is_component: self.xmpp.remove_event_handler("user_register", self.on_user_register) self.xmpp.remove_event_handler("user_unregister", self.on_user_unregister) self.xmpp.remove_event_handler( "presence_unsubscribe", self.on_presence_unsubscribe ) self.xmpp.remove_event_handler("message", self.on_message) async def get_user(self, stanza): return await self.xmpp.plugin["xep_0077"].api["user_get"](None, None, None, stanza) async def on_user_unregister(self, iq: Iq) -> None: self.xmpp.send_presence(pto=iq.get_from().bare, ptype="unavailable") self.xmpp.send_presence(pto=iq.get_from().bare, ptype="unsubscribe") self.xmpp.send_presence(pto=iq.get_from().bare, ptype="unsubscribed") async def on_user_register(self, iq: Iq) -> None: self.xmpp.client_roster[iq.get_from()].load() await self.add_component_to_roster(jid=iq.get_from()) async def add_component_to_roster(self, jid: JID) -> None: items = { self.xmpp.boundjid.bare: { "name": self.component_name, "subscription": "both", "groups": ["Slidge"], } } try: await self._set_roster(jid, items) except PermissionError: warnings.warn( f"Slidge does not have the privilege (XEP-0356) to manage the roster of {jid}. " "If this is a local user, consider configuring your XMPP server for that." ) self.xmpp.send_presence(ptype="subscribe", pto=jid.bare) async def _set_roster(self, jid, items) -> None: await self.xmpp["xep_0356"].set_roster(jid=jid.bare, roster_items=items) def on_presence_unsubscribe(self, p: Presence) -> None: if p.get_to() == self.xmpp.boundjid.bare: log.debug("REMOVE: Our roster: %s", self.xmpp.client_roster) self.xmpp["xep_0077"].api["user_remove"](None, None, p["from"], p) self.xmpp.event("user_unregister", p) async def on_message(self, msg: Message): if msg["type"] == "groupchat": return # groupchat messages are out of scope of XEP-0100 if msg["to"] == self.xmpp.boundjid.bare: # It may be useful to exchange direct messages with the component self.xmpp.event("gateway_message", msg) return if self.needs_registration and await self.get_user(msg) is None: raise XMPPError( "registration-required", text="You are not registered to this gateway" ) self.xmpp.event("legacy_message", msg) slidge/slidge/slixfix/xep_0100/stanza.py000066400000000000000000000003501522535037600203760ustar00rootroot00000000000000from slixmpp.xmlstream import ElementBase class Gateway(ElementBase): namespace = "jabber:iq:gateway" name = "query" plugin_attrib = "gateway" interfaces = {"desc", "prompt", "jid"} sub_interfaces = interfaces slidge/slidge/slixfix/xep_0153/000077500000000000000000000000001522535037600165365ustar00rootroot00000000000000slidge/slidge/slixfix/xep_0153/__init__.py000066400000000000000000000004331522535037600206470ustar00rootroot00000000000000# Slixmpp: The Slick XMPP Library # Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout # This file is part of Slixmpp. # See the file LICENSE for copying permission. from slixmpp.plugins.base import register_plugin from .vcard_avatar import XEP_0153 register_plugin(XEP_0153) slidge/slidge/slixfix/xep_0153/vcard_avatar.py000066400000000000000000000007221522535037600215460ustar00rootroot00000000000000from slixmpp.plugins.base import BasePlugin from slixmpp.plugins.xep_0153 import VCardTempUpdate, stanza from slixmpp.stanza import Presence from slixmpp.xmlstream import register_stanza_plugin class XEP_0153(BasePlugin): name = "xep_0153" description = "XEP-0153: vCard-Based Avatars (slidge, just for MUCs)" dependencies = {"xep_0054"} stanza = stanza def plugin_init(self) -> None: register_stanza_plugin(Presence, VCardTempUpdate) slidge/slidge/slixfix/xep_0292/000077500000000000000000000000001522535037600165425ustar00rootroot00000000000000slidge/slidge/slixfix/xep_0292/__init__.py000066400000000000000000000001421522535037600206500ustar00rootroot00000000000000from slixmpp.plugins.xep_0292 import stanza from . import vcard4 __all__ = ("stanza", "vcard4") slidge/slidge/slixfix/xep_0292/vcard4.py000066400000000000000000000005561522535037600203050ustar00rootroot00000000000000from slixmpp.plugins.base import BasePlugin, register_plugin from slixmpp.plugins.xep_0292.stanza import NS class VCard4Provider(BasePlugin): name = "xep_0292_provider" description = "VCard4 Provider" dependencies = {"xep_0030"} def plugin_init(self) -> None: self.xmpp.plugin["xep_0030"].add_feature(NS) register_plugin(VCard4Provider) slidge/slidge/util/000077500000000000000000000000001522535037600145615ustar00rootroot00000000000000slidge/slidge/util/__init__.py000066400000000000000000000004701522535037600166730ustar00rootroot00000000000000""" Miscellaneous helpful functions. """ from . import types from .util import ( SubclassableOnce, is_valid_phone_number, replace_mentions, strip_illegal_chars, ) __all__ = [ "SubclassableOnce", "is_valid_phone_number", "replace_mentions", "strip_illegal_chars", "types", ] slidge/slidge/util/archive_msg.py000066400000000000000000000031621522535037600174240ustar00rootroot00000000000000import uuid from copy import copy from datetime import UTC, datetime from xml.etree import ElementTree as ET from slixmpp import Message from slixmpp.plugins.xep_0297.stanza import Forwarded from slixmpp.xmlstream import StanzaBase from ..util.util import fix_namespaces def set_client_namespace(stanza: StanzaBase) -> None: fix_namespaces(stanza.xml, "jabber:component:accept", "jabber:client") class HistoryMessage: def __init__(self, stanza: Message | str, when: datetime | None = None) -> None: if isinstance(stanza, str): from_db = True stanza = Message(xml=ET.fromstring(stanza)) else: from_db = False self.id = stanza["stanza_id"]["id"] or uuid.uuid4().hex self.when: datetime = when or stanza["delay"]["stamp"] or datetime.now(tz=UTC) if not from_db: del stanza["delay"] del stanza["markable"] del stanza["hint"] del stanza["chat_state"] if not stanza["body"]: del stanza["body"] set_client_namespace(stanza) self.stanza: Message = stanza @property def stanza_component_ns(self) -> Message: stanza = copy(self.stanza) fix_namespaces(stanza.xml, old="jabber:client", new="jabber:component:accept") return stanza def forwarded(self) -> Forwarded: forwarded = Forwarded() forwarded["delay"]["stamp"] = self.when forwarded.append(self.stanza) return forwarded @property def occupant_id(self) -> str: return self.stanza["occupant-id"]["id"] # type:ignore[no-any-return] slidge/slidge/util/conf.py000066400000000000000000000164661522535037600160750ustar00rootroot00000000000000import logging from functools import cached_property from types import GenericAlias, UnionType from typing import Any, Union, get_args, get_origin, get_type_hints import configargparse class Option: DOC_SUFFIX = "__DOC" DYNAMIC_DEFAULT_SUFFIX = "__DYNAMIC_DEFAULT" SHORT_SUFFIX = "__SHORT" def __init__(self, parent: "ConfigModule", name: str) -> None: self.parent = parent self.config_obj = parent.config_obj self.name = name @cached_property def doc(self) -> str: return getattr(self.config_obj, self.name + self.DOC_SUFFIX) # type:ignore @cached_property def required(self) -> bool: return not hasattr( self.config_obj, self.name + self.DYNAMIC_DEFAULT_SUFFIX ) and not hasattr(self.config_obj, self.name) @cached_property def default(self) -> object: return getattr(self.config_obj, self.name, None) @cached_property def short(self) -> str | None: return getattr(self.config_obj, self.name + self.SHORT_SUFFIX, None) @cached_property def nargs(self) -> str | int | None: type_ = get_type_hints(self.config_obj).get(self.name, type(self.default)) if isinstance(type_, GenericAlias): args = get_args(type_) if args[1] is Ellipsis: return "*" else: return len(args) return None @cached_property def type(self) -> object: type_ = get_type_hints(self.config_obj).get(self.name, type(self.default)) if _is_optional(type_): type_ = get_args(type_)[0] elif isinstance(type_, GenericAlias): args = get_args(type_) type_ = args[0] return type_ @cached_property def names(self) -> list[str]: res = ["--" + self.name.lower().replace("_", "-")] if s := self.short: res.append("-" + s) return res @cached_property def kwargs(self) -> dict[str, Any]: kwargs = dict( required=self.required, help=self.doc, env_var=self.name_to_env_var(), ) t = self.type if t is bool: if self.default: kwargs["action"] = "store_false" else: kwargs["action"] = "store_true" else: kwargs["type"] = t if self.required: kwargs["required"] = True else: kwargs["default"] = self.default if n := self.nargs: kwargs["nargs"] = n return kwargs def name_to_env_var(self) -> str: return self.parent.ENV_VAR_PREFIX + self.name class ConfigModule: ENV_VAR_PREFIX = "SLIDGE_" def __init__( self, config_obj: object, parser: configargparse.ArgumentParser | None = None, skip_options: tuple[str, ...] = (), ) -> None: self.config_obj = config_obj if parser is None: parser = configargparse.ArgumentParser() self.parser = parser self.skip_options = skip_options self.add_options_to_parser(skip_options) def _list_options(self) -> set[str]: return { o for o in (set(dir(self.config_obj)) | set(get_type_hints(self.config_obj))) if o.upper() == o and not o.startswith("_") and "__" not in o and o.lower() not in self.skip_options } def set_conf( self, argv: list[str] | None = None ) -> tuple[configargparse.Namespace, list[str]]: if argv is not None: # this is ugly, but necessary because for plugin config, we used # remaining argv. # when using (a) .ini file(s), for bool options, we end-up with # remaining pseudo-argv such as --some-bool-opt=true when we really # should have just --some-bool-opt # TODO: get rid of configargparse and make this cleaner options_long = {o.name: o for o in self.options} no_explicit_bool = [] skip_next = False for a, aa in zip(argv, [*argv[1:], ""]): if skip_next: skip_next = False continue force_keep = False if "=" in a: real_name, _value = a.split("=") opt: Option | None = options_long.get( _argv_to_option_name(real_name) ) if opt and opt.type is bool: if opt.default: if _value in _TRUEISH or not _value: continue else: a = real_name force_keep = True else: if _value in _TRUEISH: a = real_name force_keep = True else: continue else: upper = _argv_to_option_name(a) opt = options_long.get(upper) if ( opt and opt.type is bool and ( not aa.startswith("-") and _argv_to_option_name(aa) not in options_long ) ): log.debug("Removing %s from argv", aa) skip_next = True if opt: if opt.type is bool: if force_keep or not opt.default: no_explicit_bool.append(a) else: no_explicit_bool.append(a) else: no_explicit_bool.append(a) log.debug("Removed boolean values from %s to %s", argv, no_explicit_bool) argv = no_explicit_bool args, rest = self.parser.parse_known_args(argv) self.update_dynamic_defaults(args) for name in self._list_options(): value = getattr(args, name.lower()) log.debug("Setting '%s' to %r", name, value) setattr(self.config_obj, name, value) return args, rest @cached_property def options(self) -> list[Option]: res = [] for opt in self._list_options(): res.append(Option(self, opt)) return res def add_options_to_parser(self, skip_options: tuple[str, ...]) -> None: skip_options = tuple(o.lower() for o in skip_options) p = self.parser for o in sorted(self.options, key=lambda x: (not x.required, x.name)): if o.name.lower() in skip_options: continue p.add_argument(*o.names, **o.kwargs) def update_dynamic_defaults(self, args: configargparse.Namespace) -> None: pass def _is_optional(t: object) -> bool: origin = get_origin(t) if origin is Union or origin is UnionType: args = get_args(t) if len(args) == 2 and type(None) in args: return True return False def _argv_to_option_name(arg: str) -> str: return arg.upper().removeprefix("--").replace("-", "_") _TRUEISH = {"true", "True", "1", "on", "enabled"} log = logging.getLogger(__name__) slidge/slidge/util/jid_escaping.py000066400000000000000000000033611522535037600175550ustar00rootroot00000000000000from slixmpp.jid import unescape_node class EscapeMixin: async def legacy_id_to_jid_username(self, legacy_id: str) -> str: """ Convert a legacy ID to a valid 'user' part of a JID. The default implementation uses :xep:`0106`. Should be overridden for cases where the str conversion of the legacy_id is not enough, e.g., if it is case-sensitive or contains forbidden characters not covered by :xep:`0106`. :param legacy_id: The identifier to convert. :return: A valid username part (or "local part") of a JID. """ return str(legacy_id).translate(ESCAPE_TABLE) async def jid_username_to_legacy_id(self, jid_username: str) -> str: """ Convert a JID user part to a legacy ID. The default implementation uses :xep:`0106`. Should be overridden in case legacy IDs are not strings, or more generally for any case where the username part of a JID is not enough to identify a contact on the legacy network. :param jid_username: User part of a JID, ie "user" in "user@example.com" :return: The string representation of an identifier on the legacy network. """ return unescape_node(jid_username) # type:ignore[no-any-return] JID_ESCAPE_SEQUENCES = { "\\20", "\\22", "\\26", "\\27", "\\2f", "\\3a", "\\3c", "\\3e", "\\40", "\\5c", } JID_UNESCAPE_TRANSFORMATIONS = { "\\20": " ", "\\22": '"', "\\26": "&", "\\27": "'", "\\2f": "/", "\\3a": ":", "\\3c": "<", "\\3e": ">", "\\40": "@", "\\5c": "\\", } ESCAPE_TABLE = "".maketrans({v: k for k, v in JID_UNESCAPE_TRANSFORMATIONS.items()}) __all__ = "ESCAPE_TABLE", "unescape_node" slidge/slidge/util/lock.py000066400000000000000000000020171522535037600160630ustar00rootroot00000000000000import asyncio import logging from collections.abc import AsyncIterator, Hashable from contextlib import asynccontextmanager class NamedLockMixin: def __init__(self, *a: object, **k: object) -> None: super().__init__(*a, **k) self.__locks = dict[Hashable, asyncio.Lock]() @asynccontextmanager async def lock(self, id_: Hashable) -> AsyncIterator[None]: log.trace("getting %s", id_) # type:ignore locks = self.__locks if not locks.get(id_): locks[id_] = asyncio.Lock() try: async with locks[id_]: log.trace("acquired %s", id_) # type:ignore yield finally: log.trace("releasing %s", id_) # type:ignore waiters = locks[id_]._waiters if not waiters: del locks[id_] log.trace("erasing %s", id_) # type:ignore def get_lock(self, id_: Hashable) -> asyncio.Lock | None: return self.__locks.get(id_) log = logging.getLogger(__name__) slidge/slidge/util/lottie.py000066400000000000000000000107761522535037600164460ustar00rootroot00000000000000""" This module implements vector animated stickers in the lottie format to webp images. """ import asyncio import logging import warnings from pathlib import Path import aiohttp try: from rlottie_python.rlottie_wrapper import LottieAnimation except ImportError: LottieAnimation = None # type: ignore[assignment,misc] from ..core import config from .types import LegacyAttachment async def from_url( url: str, sticker_id: str, http: aiohttp.ClientSession ) -> LegacyAttachment: """ Get a webp attachment from a URL. :param url: URL where the lottie sticker can be downloaded. :param sticker_id: A unique identifier for this sticker. :param http: The aiohttp.ClientSession used to download the sticker. :return: A `LegacyAttachment` with the sticker in the webp format if `config.CONVERT_STICKERS == True`, in the original lottie format otherwise. """ if not config.CONVERT_STICKERS: return _attachment(sticker_id, url=url) lottie_path = sticker_path(sticker_id).with_suffix(".json") await _download(lottie_path, url, http) webp_path = sticker_path(sticker_id).with_suffix(".webp") return await convert(lottie_path, webp_path, sticker_id) async def from_path(path: Path, sticker_id: str) -> LegacyAttachment: """ Get a webp attachment from a path. :param path: path to the lottie sticker file. :param sticker_id: A unique identifier for this sticker. :return: A `LegacyAttachment` with the sticker in the webp format if `config.CONVERT_STICKERS == True`, in the original lottie format otherwise. """ if not config.CONVERT_STICKERS: return _attachment(sticker_id, path=path) out_path = sticker_path(sticker_id).with_suffix(".webp") return await convert(path, out_path, sticker_id) def sticker_path(sticker_id: str = "") -> Path: """ Get the path where a sticker is meant be stored :param sticker_id: A unique ID for this sticker :return: A `path`. It has no suffix, so you might want to use `Path.with_suffix()` before writing into it. """ root = config.HOME_DIR / "lottie" root.mkdir(exist_ok=True) return root / sticker_id def _attachment( sticker_id: str, path: Path | None = None, url: str | None = None ) -> LegacyAttachment: content_type = "image/webp" if path is not None and path.suffix == ".webp" else None return LegacyAttachment( url=url, path=path, legacy_file_id="lottie-" + sticker_id, disposition="inline", content_type=content_type, is_sticker=True, ) async def _download(path: Path, url: str, http: aiohttp.ClientSession) -> None: async with _sticker_download_lock: if path.exists(): return with path.open("wb") as fp: try: resp = await http.get(url, chunked=True) async for chunk in resp.content: fp.write(chunk) except ValueError as e: if e.args[0] != "Chunk too big": raise # not sure why this happens but it does sometimes resp = await http.get(url) fp.write(await resp.content.read()) async def convert( input_path: Path, output_path: Path, sticker_id: str, width: int = 256, height: int = 256, ) -> LegacyAttachment: async with _sticker_conversion_lock: if not output_path.exists(): if LottieAnimation is None: warnings.warn( "Cannot convert stickers, rlottie-python is not available." ) return _attachment(sticker_id, path=input_path) log.debug("Converting sticker %s to video", output_path.stem) if input_path.suffix == ".json": animation = LottieAnimation.from_file(str(input_path)) else: animation = LottieAnimation.from_tgs(str(input_path)) animation.save_animation(str(output_path), width=width, height=height) return _attachment(sticker_id, path=output_path) async def _main() -> None: # small entrypoint to easily test that it works import sys source, destination = sys.argv[1:] config.CONVERT_STICKERS = True await convert(Path(source), Path(destination), "") _sticker_conversion_lock: asyncio.Lock = asyncio.Lock() _sticker_download_lock: asyncio.Lock = asyncio.Lock() log = logging.getLogger(__name__) __all__ = ("from_url",) if __name__ == "__main__": asyncio.run(_main()) slidge/slidge/util/test.py000066400000000000000000000236531522535037600161230ustar00rootroot00000000000000# type:ignore import os import tempfile import types from pathlib import Path from typing import ClassVar from xml.dom.minidom import parseString from slixmpp import JID, ElementBase, Iq, Message, Presence from slixmpp.stanza.error import Error from slixmpp.test import SlixTest, TestTransport from slixmpp.xmlstream import highlight, tostring from sqlalchemy import create_engine, delete from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyMUC, LegacyParticipant, LegacyRoster, ) from ..command import Command from ..core import config from ..core.session import _sessions from ..db import SlidgeStore from ..db.avatar import avatar_cache from ..db.meta import Base from ..db.models import Contact, GatewayUser from ..util import SubclassableOnce class SlixTestPlus(SlixTest): def setUp(self) -> None: super().setUp() Error.namespace = "jabber:component:accept" def next_sent(self, timeout: float = 0.05) -> Message | Iq | Presence | None: self.wait_for_send_queue() sent = self.xmpp.socket.next_sent(timeout=timeout) if sent is None: return None xml = self.parse_xml(sent) self.fix_namespaces(xml, "jabber:component:accept") sent = self.xmpp._build_stanza(xml, "jabber:component:accept") return sent class SlidgeTest(SlixTestPlus): plugin: types.ModuleType | dict class Config: jid = "aim.shakespeare.lit" secret = "test" server = "shakespeare.lit" port = 5222 upload_service = "upload.test" home_dir = Path(tempfile.mkdtemp()) user_jid_validator = ".*" admins: ClassVar[list[str]] = [] upload_requester = None ignore_delay_threshold = 300 @classmethod def setUpClass(cls) -> None: for k, v in vars(cls.Config).items(): setattr(config, k.upper(), v) if hasattr(cls, "plugin"): subclasses = SubclassableOnce._SubclassableOnce__subclasses subclasses.update( { BaseGateway: find_subclass(cls.plugin, BaseGateway), BaseSession: find_subclass(cls.plugin, BaseSession), LegacyRoster: find_subclass(cls.plugin, LegacyRoster, base_ok=True), LegacyContact: find_subclass( cls.plugin, LegacyContact, base_ok=True ), LegacyMUC: find_subclass(cls.plugin, LegacyMUC, base_ok=True), LegacyBookmarks: find_subclass( cls.plugin, LegacyBookmarks, base_ok=True ), } ) for subclass in subclasses.values(): if subclass is not None: subclass.__abstractmethods__ = set() def setUp(self) -> None: # workaround for duplicate output of sql alchemy's log, cf # https://stackoverflow.com/a/76498428/5902284 from sqlalchemy import log as sqlalchemy_log from sqlalchemy.pool import StaticPool sqlalchemy_log._add_default_handler = lambda x: None db_url = os.getenv("SLIDGETEST_DB_URL", "sqlite+pysqlite:///:memory:") engine = self.db_engine = create_engine( db_url, poolclass=StaticPool if db_url.startswith("postgresql+") else None ) Base.metadata.create_all(engine) BaseGateway.store = SlidgeStore(engine) BaseGateway._test_mode = True self.xmpp = BaseGateway.get_self_or_unique_subclass()() self.xmpp.TEST_MODE = True avatar_cache.store = self.xmpp.store.avatars avatar_cache.set_dir(Path(tempfile.mkdtemp())) self.xmpp._always_send_everything = True engine.echo = True self.xmpp.connection_made(TestTransport(self.xmpp)) self.xmpp.session_bind_event.set() # Remove unique ID prefix to make it easier to test self.xmpp._id_prefix = "" self.xmpp.default_lang = None self.xmpp.peer_default_lang = None def new_id() -> str: self.xmpp._id += 1 return str(self.xmpp._id) self.xmpp._id = 0 self.xmpp.new_id = new_id # Must have the stream header ready for xmpp.process() to work. header = self.xmpp.stream_header self.xmpp.data_received(header) self.wait_for_send_queue() self.xmpp.socket.next_sent() self.xmpp.socket.next_sent() # Some plugins require messages to have ID values. Set # this to True in tests related to those plugins. self.xmpp.use_message_ids = False self.xmpp.use_presence_ids = False Error.namespace = "jabber:component:accept" def _add_slidge_user( self, jid: str | JID = "romeo@shakespeare.lit", legacy_module_data: dict | None = None, preferences: dict | None = None, ) -> None: with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID(jid), legacy_module_data=legacy_module_data or {}, preferences=preferences or {"sync_avatar": False, "sync_presence": False}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register(Iq(sfrom=JID(jid))) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza if self.xmpp.GROUPS: stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert stanza["status"].lower() def user_session(self, jid: str | JID = "romeo@shakespeare.lit") -> BaseSession: return BaseSession.get_self_or_unique_subclass().from_jid(JID(jid)) def get_joined_muc( self, legacy_id: str | int, user_jid: str | JID = "romeo@shakespeare.lit", resource: str = "gajim", ) -> LegacyMUC: muc: LegacyMUC = self.run_coro( self.user_session(user_jid).bookmarks.by_legacy_id(legacy_id) ) muc.add_user_resource(resource) return muc def tearDown(self) -> None: self.db_engine.echo = False super().tearDown() Base.metadata.drop_all(self.xmpp.store._engine) self.db_engine.dispose() _sessions.clear() def setup_logged_session(self, n_contacts: int = 0) -> None: with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() with self.xmpp.store.session() as session: session.execute(delete(Contact)) session.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register( Iq(sfrom="romeo@montague.lit/gajim") ) ) welcome = self.next_sent() assert welcome["body"], welcome stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza if BaseGateway.get_self_or_unique_subclass().GROUPS: stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza self.romeo: BaseSession = BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) self.juliet: LegacyContact = self.run_coro( self.romeo.contacts.by_legacy_id("juliet") ) self.room: LegacyMUC = self.run_coro(self.romeo.bookmarks.by_legacy_id("room")) self.first_witch: LegacyParticipant = self.run_coro( self.room.get_participant("firstwitch") ) self.send( # language=XML """ """ ) @classmethod def tearDownClass(cls) -> None: reset_subclasses() def format_stanza(stanza: ElementBase) -> str: return highlight( "\n".join(parseString(tostring(stanza.xml)).toprettyxml().split("\n")[1:]) ) def find_subclass(o, parent, base_ok: bool = False): # noqa try: vals = vars(o).values() except TypeError: vals = o.values() for x in vals: try: if issubclass(x, parent) and x is not parent: return x except TypeError: pass else: if base_ok: return parent else: raise RuntimeError def reset_subclasses() -> None: """ Reset registered subclasses between test classes. Needed because these classes are meant to only be subclassed once and raise exceptions otherwise. """ BaseSession.reset_subclass() BaseGateway.reset_subclass() LegacyRoster.reset_subclass() LegacyContact.reset_subclass() LegacyMUC.reset_subclass() LegacyBookmarks.reset_subclass() LegacyParticipant.reset_subclass() # reset_commands() def reset_commands() -> None: Command.subclasses = [ c for c in Command.subclasses if str(c).startswith(" None: if all( x is None for x in (self.path, self.stream, self.data, self.url, self.aio_stream) ): raise TypeError("There is not data in this attachment", self) if isinstance(self.path, str): self.path = Path(self.path) if self.is_sticker: if self.disposition == "attachment": warnings.warn( "Sticker declared as 'attachment' disposition, changing it to 'inline'" ) self.disposition = "inline" def format_for_user(self) -> str: if self.name: name = self.name elif self.path: name = self.path.name # type:ignore[union-attr] elif self.url: name = self.url else: name = "" if self.caption: name = f"{name}: {self.caption}" if name else self.caption return name def __str__(self) -> str: attrs = ", ".join( f"{f.name}={getattr(self, f.name)!r}" for f in fields(self) if getattr(self, f.name) is not None and f.name != "data" ) if self.data is not None: data_str = f"data=<{len(self.data)} bytes>" to_join = (attrs, data_str) if attrs else (data_str,) attrs = ", ".join(to_join) return f"Attachment({attrs})" class MucType(IntEnum): """ The type of group, private, public, anonymous or not. """ GROUP = 0 """ A private group, members-only and non-anonymous, eg a family group. """ CHANNEL = 1 """ A public group, aka an anonymous channel. """ CHANNEL_NON_ANONYMOUS = 2 """ A public group where participants' legacy IDs are visible to everybody. """ PseudoPresenceShow = PresenceShows | Literal[""] MessageOrPresenceTypeVar = TypeVar("MessageOrPresenceTypeVar", bound=Message | Presence) class LinkPreview(NamedTuple): """ Embedded metadata from :xep:`0511`. See _. """ about: str """ URL of the link. """ title: str | None """ Title of the linked page. """ description: str | None """ A description of the page. """ url: str | None """ The canonical URL of the link. """ image: str | Path | bytes | None """ An image representing the link. If it is a string, it should represent a URL to an image. """ type: str | None """ Type of the link destination. """ site_name: str | None """ Name of the web site. """ @property def is_empty(self) -> bool: return not any(x for x in self) class Mention(NamedTuple): contact: "LegacyContact" start: int end: int class Hat(NamedTuple): uri: str title: str hue: float | None = None class UserPreferences(TypedDict): sync_avatar: bool sync_presence: bool class MamMetadata(NamedTuple): id: str sent_on: datetime class HoleBound(NamedTuple): id: str timestamp: datetime class CachedPresence(NamedTuple): last_seen: datetime | None = None ptype: PresenceTypes | None = None pstatus: str | None = None pshow: PresenceShows | None = None class Avatar(NamedTuple): path: Path | None = None unique_id: str | None = None url: str | None = None data: bytes | None = None class SpaceMetadata(NamedTuple): creator_legacy_id: str | None = None name: str | None = None description: str | None = None member_count: int | None = None owner_legacy_ids: Iterable[str] = [] @dataclass class Reply: msg_id: str to: "LegacyContact | LegacyParticipant[Any] | None" fallback: str | None = None @cached_property def fallback_no_quote_mark(self) -> str | None: """ Return multi-line text without leading quote marks (i.e. the ">" character). """ if not self.fallback: return None return re.sub(_STRIP_QUOTE_RE, "", self.fallback).strip() _STRIP_QUOTE_RE = re.compile(r"^>\s*", flags=re.MULTILINE) @dataclass class XMPPAttachment: url: str is_sticker: bool = False cid: str | None = None content_type: str | None = None @contextlib.asynccontextmanager async def get(self) -> AsyncIterator[aiohttp.ClientResponse]: async with ( aiohttp.ClientSession() as session, session.get(self.url) as response, ): yield response @dataclass class _AbstractXMPPMessage: body: str | None = None link_previews: tuple[LinkPreview, ...] = () attachments: tuple[XMPPAttachment, ...] = () mentions: tuple[Mention, ...] = () replace: str | None = None reply: Reply | None = None thread: str | None = None class XMPPAttachmentMessage(_AbstractXMPPMessage): body: str | None attachments: tuple[XMPPAttachment, *tuple[XMPPAttachment, ...]] class XMPPTextMessage(_AbstractXMPPMessage): body: str attachments: tuple[()] XMPPMessage = XMPPAttachmentMessage | XMPPTextMessage @dataclass class Sticker: path: Path content_type: str | None hashes: dict[str, str] fallback: str | None = None reply: Reply | None = None thread: str | None = None slidge/slidge/util/util.py000066400000000000000000000242451522535037600161170ustar00rootroot00000000000000import logging import mimetypes import re from collections.abc import Callable, Collection, Coroutine from functools import wraps from pathlib import Path from time import time from typing import ( Any, ClassVar, Concatenate, NamedTuple, ParamSpec, Protocol, TypeVar, ) from xml.etree import ElementTree as ET try: import emoji except ImportError: EMOJI_LIB_AVAILABLE = False else: EMOJI_LIB_AVAILABLE = True from slixmpp.types import ExtPresenceShows, ResourceDict from .types import Mention try: import magic except ImportError as e: magic = None # type:ignore logging.warning( ( "Libmagic is not available: %s. " "It's OK if you don't use fix-filename-suffix-mime-type." ), e, ) def fix_suffix( path: Path, mime_type: str | None, file_name: str | None ) -> tuple[str, str]: guessed = magic.from_file(path, mime=True) if guessed == mime_type: log.debug("Magic and given MIME match") else: log.debug("Magic (%s) and given MIME (%s) differ", guessed, mime_type) mime_type = guessed valid_suffix_list = mimetypes.guess_all_extensions(mime_type, strict=False) name = Path(file_name) if file_name else Path(path.name) suffix = name.suffix if suffix in valid_suffix_list: log.debug("Suffix %s is in %s", suffix, valid_suffix_list) return str(name), guessed valid_suffix = mimetypes.guess_extension(mime_type.split(";")[0], strict=False) if valid_suffix is None: log.debug("No valid suffix found") return str(name), guessed log.debug("Changing suffix of %s to %s", file_name or path.name, valid_suffix) return str(name.with_suffix(valid_suffix)), guessed class SubclassableOnce: # To allow importing everything, including plugins, during tests TEST_MODE: bool = False __subclasses: ClassVar[ dict[type["SubclassableOnce"], type["SubclassableOnce"] | None] ] = {} def __init_subclass__(cls, **kwargs: object) -> None: if SubclassableOnce not in cls.__bases__: base = SubclassableOnce.__find_direct_child(cls) existing = SubclassableOnce.__subclasses.get(base) if existing is not None and not SubclassableOnce.TEST_MODE: raise RuntimeError("This class must be subclassed once at most!") cls.__subclasses[base] = cls super().__init_subclass__(**kwargs) @staticmethod def __find_direct_child(cls: type["SubclassableOnce"]) -> type["SubclassableOnce"]: for base in cls.__bases__: if issubclass(base, SubclassableOnce): return base else: raise RuntimeError("wut") @classmethod def get_self_or_unique_subclass(cls) -> "type[SubclassableOnce]": try: return cls.get_unique_subclass() except AttributeError: return cls @classmethod def get_unique_subclass(cls) -> "type[SubclassableOnce]": existing = SubclassableOnce.__subclasses.get(cls) if existing is None: raise AttributeError("Could not find any subclass", cls) return existing @classmethod def reset_subclass(cls) -> None: log.debug("Resetting subclass of %s", cls) cls.__subclasses[cls] = None def is_valid_phone_number(phone: str | None) -> bool: if phone is None: return False match = re.match(r"\+\d.*", phone) if match is None: return False return match[0] == phone def strip_illegal_chars(s: str, repl: str = "") -> str: return ILLEGAL_XML_CHARS_RE.sub(repl, s) # from https://stackoverflow.com/a/64570125/5902284 and Link Mauve ILLEGAL = [ (0x00, 0x08), (0x0B, 0x0C), (0x0E, 0x1F), (0x7F, 0x84), (0x86, 0x9F), (0xFDD0, 0xFDDF), (0xFFFE, 0xFFFF), (0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF), (0x3FFFE, 0x3FFFF), (0x4FFFE, 0x4FFFF), (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF), (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF), (0x9FFFE, 0x9FFFF), (0xAFFFE, 0xAFFFF), (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF), (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF), (0xFFFFE, 0xFFFFF), (0x10FFFE, 0x10FFFF), ] ILLEGAL_RANGES = [rf"{chr(low)}-{chr(high)}" for (low, high) in ILLEGAL] XML_ILLEGAL_CHARACTER_REGEX = "[" + "".join(ILLEGAL_RANGES) + "]" ILLEGAL_XML_CHARS_RE = re.compile(XML_ILLEGAL_CHARACTER_REGEX) # from https://stackoverflow.com/a/35804945/5902284 def addLoggingLevel( levelName: str = "TRACE", levelNum: int = logging.DEBUG - 5, methodName: str | None = None, ) -> None: """ Comprehensively adds a new logging level to the `logging` module and the currently configured logging class. `levelName` becomes an attribute of the `logging` module with the value `levelNum`. `methodName` becomes a convenience method for both `logging` itself and the class returned by `logging.getLoggerClass()` (usually just `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is used. To avoid accidental clobberings of existing attributes, this method will raise an `AttributeError` if the level name is already an attribute of the `logging` module or if the method name is already present Example ------- >>> addLoggingLevel('TRACE', logging.DEBUG - 5) >>> logging.getLogger(__name__).setLevel("TRACE") >>> logging.getLogger(__name__).trace('that worked') >>> logging.trace('so did this') >>> logging.TRACE 5 """ if not methodName: methodName = levelName.lower() if hasattr(logging, levelName): log.debug(f"{levelName} already defined in logging module") return if hasattr(logging, methodName): log.debug(f"{methodName} already defined in logging module") return if hasattr(logging.getLoggerClass(), methodName): log.debug(f"{methodName} already defined in logger class") return # This method was inspired by the answers to Stack Overflow post # http://stackoverflow.com/q/2183233/2988730, especially # http://stackoverflow.com/a/13638084/2988730 def logForLevel(self, message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa if self.isEnabledFor(levelNum): self._log(levelNum, message, args, **kwargs) def logToRoot(message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa logging.log(levelNum, message, *args, **kwargs) logging.addLevelName(levelNum, levelName) setattr(logging, levelName, levelNum) setattr(logging.getLoggerClass(), methodName, logForLevel) setattr(logging, methodName, logToRoot) class SlidgeLogger(logging.Logger): def trace(self) -> None: pass log = logging.getLogger(__name__) def merge_resources(resources: dict[str, ResourceDict]) -> ResourceDict | None: if len(resources) == 0: return None if len(resources) == 1: return next(iter(resources.values())) by_priority = sorted(resources.values(), key=lambda r: r["priority"], reverse=True) if any(r["show"] == "" for r in resources.values()): # if a client is "available", we're "available" show: ExtPresenceShows = "" else: for r in by_priority: if r["show"]: show = r["show"] break else: raise RuntimeError() # if there are different statuses, we use the highest priority one, # but we ignore resources without status, even with high priority status = "" for r in by_priority: if r["status"]: status = r["status"] break return { "show": show, "status": status, "priority": 0, } _EMOJI_VARIATION_SELECTOR = "\ufe0f" def remove_emoji_variation_selector_16(emoji: str) -> str: # this is required for compatibility with dino, and maybe other future clients? return emoji.removesuffix(_EMOJI_VARIATION_SELECTOR) NamedTupleT = TypeVar("NamedTupleT", bound=NamedTuple) def dict_to_named_tuple(data: dict[str, Any], cls: type[NamedTupleT]) -> NamedTupleT: return cls(*(data.get(f) for f in cls._fields)) # type:ignore[arg-type] def replace_mentions( text: str, mentions: Collection[Mention] | None, mapping: Callable[[Mention], str], ) -> str: if not mentions: return text cursor = 0 pieces = [] for mention in mentions: try: new_text = mapping(mention) except Exception as exc: log.debug("Attempting slidge <= 0.3.3 compatibility: %s", exc) new_text = mapping(mention.contact) # type:ignore pieces.extend([text[cursor : mention.start], new_text]) cursor = mention.end pieces.append(text[cursor:]) return "".join(pieces) class HasLogger(Protocol): log: logging.Logger P = ParamSpec("P") T = TypeVar("T") Self = TypeVar("Self", bound=HasLogger) TimeItWrapped = Callable[Concatenate[Self, P], Coroutine[Any, Any, T]] def timeit(func: TimeItWrapped[Self, P, T]) -> TimeItWrapped[Self, P, T]: @wraps(func) async def wrapped(self: Self, /, *args: P.args, **kwargs: P.kwargs) -> T: start = time() r = await func(self, *args, **kwargs) self.log.debug("%s took %s ms", func.__name__, round((time() - start) * 1000)) return r return wrapped def strip_leading_emoji(text: str) -> str: if not EMOJI_LIB_AVAILABLE: return text words = text.split(" ") # is_emoji returns False for 🛷️ for obscure reasons, # purely_emoji seems better if len(words) > 1 and emoji.purely_emoji(words[0]): return " ".join(words[1:]) return text async def noop_coro() -> None: pass def add_quote_prefix(text: str) -> str: """ Return multi-line text with leading quote marks (i.e. the ">" character). """ return "\n".join(("> " + x).strip() for x in text.split("\n")).strip() def fix_namespaces( xml: ET.Element, old: str, new: str, ) -> None: """ Hack to fix namespaces between jabber:component and jabber:client Acts in-place. :param xml: :param old: :param new: """ xml.tag = xml.tag.replace(f"{{{old}}}", f"{{{new}}}") for child in xml: fix_namespaces(child, old, new) slidge/superduper/000077500000000000000000000000001522535037600145335ustar00rootroot00000000000000slidge/superduper/__init__.py000066400000000000000000000002211522535037600166370ustar00rootroot00000000000000""" An example legacy module for slidge. """ from . import contact, gateway, group, session __all__ = "contact", "gateway", "group", "session" slidge/superduper/__main__.py000066400000000000000000000000701522535037600166220ustar00rootroot00000000000000from slidge import entrypoint entrypoint("superduper") slidge/superduper/contact.py000066400000000000000000000025721522535037600165460ustar00rootroot00000000000000from collections.abc import AsyncIterator from slixmpp.exceptions import XMPPError from slidge.contact import LegacyContact, LegacyRoster from slidge.util.types import Avatar, XMPPMessage from .session import Session class Roster(LegacyRoster["Contact"]): async def fill(self) -> AsyncIterator["Contact"]: for i in "111", "222": yield await self.by_legacy_id(i) class Contact(LegacyContact): session: "Session" @property def super_duper_id(self) -> int: try: return int(self.legacy_id) except ValueError: raise XMPPError( "bad-request", "This is not a valid username for this fake network" ) async def update_info(self) -> None: profile = await self.session.legacy_client.get_profile(self.super_duper_id) self.name = profile.nickname self.set_vcard(full_name=profile.full_name) await self.set_avatar( Avatar(path=profile.avatar, unique_id=profile.avatar_unique_id) ) if self.legacy_id != "666": self.is_friend = True self.online() async def on_message(self, message: XMPPMessage) -> str | None: if message.body: sent = await self.session.legacy_client.send_direct_msg( message.body, self.super_duper_id ) return sent.id return None slidge/superduper/gateway.py000066400000000000000000000050201522535037600165430ustar00rootroot00000000000000""" The gateway """ from slixmpp import JID from slidge import BaseGateway from slidge.command import FormField from slidge.command.register import RegistrationType from slidge.db.models import GatewayUser from .legacy_client import SuperDuperClient from .session import Session from .util import ASSETS_DIR class Gateway(BaseGateway[Session]): """ This is instantiated once by the slidge entrypoint. By customizing the class attributes, we customize the registration process, and display name of the component. """ COMPONENT_NAME = "The great legacy network (slidge)" COMPONENT_AVATAR = ASSETS_DIR / "slidge-color.png" COMPONENT_TYPE = "whatsapp" REGISTRATION_INSTRUCTIONS = ( "Register to this fake service by using 'slidger' as username, and any " "password you want. Then you will need to enter '666' as the 2FA code." ) REGISTRATION_TYPE = RegistrationType.TWO_FACTOR_CODE REGISTRATION_FIELDS = [ FormField(var="username", label="User name", required=True), FormField(var="password", label="Password", required=True, private=True), ] GROUPS = True SPACES = True MARK_ALL_MESSAGES = True LEGACY_CONTACT_ID_TYPE = int async def validate( self, user_jid: JID, registration_form: dict[str, str | None], ) -> None: """ This function receives the values of the form defined in :attr:`REGISTRATION_FIELDS`. Here, since we set :attr:`REGISTRATION_TYPE` to "2FA", if this method does not raise any exception, the wannabe user will be prompted for their 2FA code. :param user_jid: :param registration_form: :return: """ username = registration_form["username"] password = registration_form["password"] assert username is not None and password is not None await SuperDuperClient.send_2fa(username, password) async def validate_two_factor_code(self, user: GatewayUser, code: str) -> None: """ This function receives the 2FA code entered by the aspiring user. It should raise something if the 2FA does not permit logging in to the legacy service. :param user: :param code: """ username = user.legacy_module_data["username"] password = user.legacy_module_data["password"] assert isinstance(username, str) and isinstance(password, str) await SuperDuperClient.validate_2fa( username, password, code, ) slidge/superduper/group.py000066400000000000000000000057661522535037600162570ustar00rootroot00000000000000""" Handling groups """ import uuid from collections.abc import AsyncIterator from datetime import datetime, timedelta from typing import TYPE_CHECKING from slixmpp.exceptions import XMPPError from slidge.group.bookmarks import LegacyBookmarks from slidge.group.participant import LegacyParticipant from slidge.group.room import LegacyMUC from slidge.util.types import ( Avatar, Hat, HoleBound, MucType, SpaceMetadata, XMPPMessage, ) if TYPE_CHECKING: from .session import Session from .contact import Contact class Participant(LegacyParticipant[Contact]): pass class Bookmarks(LegacyBookmarks["MUC"]): async def fill(self) -> None: for i in "aaa", "bbb": muc = await self.by_legacy_id(i) await muc.add_to_bookmarks() async def fetch_space_metadata(self, legacy_id: str) -> SpaceMetadata: if legacy_id == "555": return SpaceMetadata( name="The cool super space", creator_legacy_id="111", owner_legacy_ids=["111", "666"], ) raise XMPPError("item-not-found", f"This space is not a space: {legacy_id}") class MUC(LegacyMUC[Participant]): session: "Session" type = MucType.GROUP async def update_info(self) -> None: info = await self.session.legacy_client.get_group_info(self.legacy_id) self.space_legacy_id = "555" self.name = info.name await self.set_avatar(Avatar(path=info.avatar, unique_id=info.avatar_unique_id)) async def fill_participants(self) -> AsyncIterator["Participant"]: # in a real case, this would probably call something like # self.session.legacy_client.fetch_group_members(self.legacy_id) for i in "0", "111", "222": part = await self.get_participant_by_legacy_id(i) if i == "111": part.role = "moderator" part.affiliation = "owner" part.set_hats([Hat("test", "test"), Hat("prout", "prout")]) yield part me = await self.get_user_participant() me.role = "moderator" me.affiliation = "owner" yield me async def backfill( self, after: HoleBound | None = None, before: HoleBound | None = None, ) -> None: # in a real case, this would probably call something like # self.session.legacy_client.fetch_group_history(self.legacy_id) for i in range(10): part = await self.get_participant_by_legacy_id("0") part.send_text( f"History message #{i}", when=datetime.now() - timedelta(hours=i), legacy_msg_id=f"{i}--{uuid.uuid4().hex}", archive_only=True, ) async def on_message(self, message: XMPPMessage) -> str | None: if message.body: return ( await self.session.legacy_client.send_group_msg( message.body, self.legacy_id ) ).id return None slidge/superduper/legacy_client.py000066400000000000000000000126001522535037600177060ustar00rootroot00000000000000import asyncio import random import uuid from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from slixmpp.exceptions import XMPPError from slidge.util.types import Hat, MessageReference from .util import ASSETS_DIR, later if TYPE_CHECKING: from .session import Session @dataclass class DirectMessage: sender: int text: str id: str reply_to: str | None = None @dataclass class GroupMessage: sender: int group_id: str text: str id: str reply_to: str | None = None @dataclass class Profile: nickname: str full_name: str avatar: Path avatar_unique_id: str @dataclass class GroupInfo: name: str avatar: Path | None = None avatar_unique_id: str | None = None class SuperDuperClient: @staticmethod async def send_2fa(username: str, _password: str) -> None: if username != "slidger": raise XMPPError("not-authorized", "Use 'slidger' as the username or GTFO") @staticmethod async def validate_2fa(_username: str, _password: str, code: str) -> None: if code != "666": raise XMPPError("not-authorized", "Wrong code! It's 666.") def __init__(self, session: "Session") -> None: self.session = session self.user_id = -1 self.__task = asyncio.create_task(self.__incoming_messages()) async def __incoming_messages(self) -> None: while True: await self.on_contact_message( DirectMessage(666, "Hey devil!", id=uuid.uuid4().hex) ) await self.on_contact_message( DirectMessage(111, "Hey!", id=uuid.uuid4().hex) ) await asyncio.sleep(300) await self.on_contact_message( DirectMessage(222, "Ho!", id=uuid.uuid4().hex) ) await asyncio.sleep(300) async def login(self) -> None: pass async def send_direct_msg(self, text: str, contact_id: int) -> DirectMessage: i = uuid.uuid4().hex later( self.on_contact_message( DirectMessage( id=uuid.uuid4().hex, text="A reply", sender=contact_id, reply_to=i ) ) ) return DirectMessage(text=text, id=i, sender=self.user_id) async def send_group_msg(self, text: str, group_id: str) -> GroupMessage: i = uuid.uuid4().hex later( self.on_group_message( GroupMessage( id=uuid.uuid4().hex, text="A reply", group_id=group_id, sender=666, reply_to=i, ) ) ) return GroupMessage(text=text, id=i, sender=self.user_id, group_id=group_id) @staticmethod async def get_profile(user_id: int) -> Profile: return _PROFILES[user_id] @staticmethod async def get_group_info(group_id: str) -> GroupInfo: return _GROUPS[group_id] async def on_contact_message(self, msg: DirectMessage) -> None: contact = await self.session.contacts.by_legacy_id(str(msg.sender)) contact.send_text( msg.text, msg.id, reply_to=MessageReference(msg.reply_to, author="user") if msg.reply_to else None, ) async def on_group_message(self, msg: GroupMessage) -> None: muc = await self.session.bookmarks.by_legacy_id(msg.group_id) participant = await muc.get_participant_by_legacy_id(0) participant.send_text( msg.text, msg.id, reply_to=MessageReference(msg.reply_to, author="user") if msg.reply_to else None, ) await asyncio.sleep(1) participant.send_text( msg.text + " (correction)", msg.id, reply_to=MessageReference(msg.reply_to, author="user") if msg.reply_to else None, correction=True, ) user = await muc.get_user_participant() if random.random() < 0.5: user.set_hats([("prout", "prout")]) participant.set_hats([]) else: user.set_hats([]) participant.set_hats( [ Hat("12", "gloup"), Hat( str(random.randint(0, 256)), "gloup" + str(random.randint(0, 256)), ), ] ) _PROFILES = { 111: Profile( nickname="Baba", avatar=ASSETS_DIR / "slidge-mono-black.png", full_name="Ba Ba", avatar_unique_id="baba-uid", ), 222: Profile( nickname="Bibi", avatar=ASSETS_DIR / "slidge-mono-white.png", full_name="Bi Bi", avatar_unique_id="bibi-uid", ), 666: Profile( nickname="The devil", avatar=ASSETS_DIR / "5x5.png", full_name="Lucy Fer", avatar_unique_id="devil-uid", ), 000: Profile( nickname="🎉 The joker 🎉", avatar=ASSETS_DIR / "5x5.png", full_name="A guy with emojis in his nick", avatar_unique_id="devil-uid", ), } _GROUPS = { "aaa": GroupInfo( name="The groupchat A", avatar=ASSETS_DIR / "slidge-color-small.png", avatar_unique_id="slidge-color-small", ), "bbb": GroupInfo( name="The groupchat B", ), } slidge/superduper/session.py000066400000000000000000000011131522535037600165640ustar00rootroot00000000000000""" User actions """ import typing from slidge.core.session import BaseSession from slidge.db.models import GatewayUser from .legacy_client import SuperDuperClient if typing.TYPE_CHECKING: from .contact import Contact # noqa:F401 class Session(BaseSession["Contact"]): def __init__(self, user: GatewayUser) -> None: super().__init__(user) self.legacy_client = SuperDuperClient(self) self.contacts.user_legacy_id = str(self.legacy_client.user_id) async def login(self) -> str: await self.legacy_client.login() return "Success!" slidge/superduper/util.py000066400000000000000000000007641522535037600160710ustar00rootroot00000000000000import asyncio from collections.abc import Awaitable from pathlib import Path from typing import Any ASSETS_DIR = Path(__file__).parent / "assets" if not ASSETS_DIR.exists(): ASSETS_DIR = Path(__file__).parent.parent / "dev" / "assets" if not ASSETS_DIR.exists(): raise FileNotFoundError def later(awaitable: Awaitable[Any]) -> None: asyncio.create_task(_later(awaitable)) async def _later(awaitable: Awaitable[Any]) -> None: await asyncio.sleep(0.5) await awaitable slidge/tests/000077500000000000000000000000001522535037600134775ustar00rootroot00000000000000slidge/tests/conftest.py000066400000000000000000000102471522535037600157020ustar00rootroot00000000000000import asyncio import hashlib import io import unittest from base64 import b64encode from collections.abc import Callable from contextlib import asynccontextmanager, contextmanager from http import HTTPStatus from pathlib import Path from unittest.mock import patch import pytest from PIL import Image from slidge.util import SubclassableOnce SubclassableOnce.TEST_MODE = True @pytest.fixture def MockRE(): class MockRE: @staticmethod def match(*a, **kw): return True return MockRE @pytest.fixture(scope="session") def avatar_path() -> Path: return Path(__file__).parent.parent / "dev" / "assets" / "5x5.png" @pytest.fixture(scope="session") def avatar2_path() -> Path: return Path(__file__).parent.parent / "dev" / "assets" / "slidge-color-small.png" @pytest.fixture(scope="class") def avatar(request, avatar_path, avatar2_path): img = Image.open(avatar_path) with io.BytesIO() as f: img.save(f, format="PNG") img_bytes = f.getvalue() class MockResponse: def __init__(self, status, headers=True): if headers: self.headers = {"etag": "etag", "last-modified": "last"} else: self.headers = {} self.status = status @staticmethod async def read(): return img_bytes def raise_for_status(self): pass @asynccontextmanager async def mock_get(url, headers=None): if url == "SLOW": await asyncio.sleep(1) else: assert url in ("https://AVATAR_URL", "AVATAR_URL_NO_HEADERS", "AVATAR_URL") if headers and ( headers.get("If-None-Match") == "etag" or headers.get("If-Modified-Since") == "last" ): yield MockResponse(HTTPStatus.NOT_MODIFIED) else: yield MockResponse(HTTPStatus.OK, headers=url == "AVATAR_URL") def mock_get_bound(self, url, headers=None): return mock_get(url, headers) @contextmanager def switch_avatar(_self): nonlocal img_bytes backup = img_bytes with io.BytesIO() as f: Image.open(avatar2_path).save(f, format="PNG") img_bytes = f.getvalue() yield img_bytes = backup request.cls.avatar_path = avatar_path request.cls.avatar_image = img request.cls.avatar_bytes = img_bytes request.cls.avatar_sha1 = hashlib.sha1(img_bytes).hexdigest() request.cls.avatar_sha256 = hashlib.sha256(img_bytes).hexdigest() request.cls.avatar_url = "AVATAR_URL" request.cls.avatar_url_no_headers = "AVATAR_URL_NO_HEADERS" request.cls.switch_avatar = switch_avatar request.cls.avatar_base64 = b64encode(img_bytes).decode("utf-8") with io.BytesIO() as fp: Image.open(avatar_path).save(fp, format="JPEG") request.cls.avatar_jpeg_base64 = b64encode(fp.getvalue()).decode("utf-8") request.cls.avatar_original_sha1 = hashlib.sha1( avatar_path.read_bytes() ).hexdigest() with ( patch("slidge.db.avatar.avatar_cache.http", create=True) as mock, patch("aiohttp.ClientSession.get", mock_get_bound), ): mock.get = mock_get mock.head = mock_get yield request # just to have typings for the fixture which pycharm does not understand class AvatarFixtureMixin: avatar_path: Path avatar_image: Image.Image avatar_bytes: bytes avatar_sha1: str avatar_sha256: str avatar_original_sha1: str avatar_url: str avatar_url_no_headers: str avatar_base64: str avatar_jpeg_base64: str switch_avatar: Callable class MockUUID4: def __init__(self, i: int) -> None: self.i = i self.hex = str(i) def __repr__(self): return self.i.__repr__() class UUIDFixtureMixin(unittest.TestCase): def _next_uuid(self): self._uuid_counter += 1 return self._uuid_counter def setUp(self): super().setUp() self._uuid_counter = 0 self._uuid_patch = unittest.mock.patch( "uuid.uuid4", side_effect=self._next_uuid ) self._uuid_patch.start() def tearDown(self) -> None: super().tearDown() self._uuid_patch.stop() slidge/tests/test_adhoc/000077500000000000000000000000001522535037600156145ustar00rootroot00000000000000slidge/tests/test_adhoc/test_access.py000066400000000000000000000207021522535037600204670ustar00rootroot00000000000000import pytest from slixmpp import ComponentXMPP from slixmpp.plugins.xep_0050.adhoc import XEP_0050 import slidge.command.adhoc import slidge.command.base from slidge.command import Command, CommandAccess from slidge.command.adhoc import AdhocProvider from slidge.command.base import Confirmation from slidge.util.test import SlixTestPlus class MockSession: def __init__(self, jid): self.logged = "logged" in jid.user @pytest.fixture(autouse=True) def mock(monkeypatch, MockRE): monkeypatch.setattr( slidge.command.base, "is_admin", lambda j: j.user.startswith("admin") ) monkeypatch.setattr(Command, "_get_session", lambda s, j: MockSession(j)) monkeypatch.setattr(XEP_0050, "new_session", lambda _: "session-id") monkeypatch.setattr( ComponentXMPP, "jid_validator", MockRE, raising=False, ) monkeypatch.setattr( ComponentXMPP, "get_session_from_stanza", lambda s, j: MockSession(j.get_from()), raising=False, ) monkeypatch.setattr( ComponentXMPP, "get_session_from_jid", lambda s, j: MockSession(j), raising=False, ) class Command1(Command): NAME = "Command number one" NODE = "command1" ACCESS = CommandAccess.ADMIN_ONLY class Command2(Command1): NAME = "Command number two" NODE = "command2" ACCESS = CommandAccess.ADMIN_ONLY class Command3(Command): NAME = "Command number three" NODE = "command3" CATEGORY = "category" ACCESS = CommandAccess.ADMIN_ONLY async def run(self, _session, _ifrom): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): pass class Command4(Command): NAME = "Command number four" NODE = "command4" CATEGORY = "category" ACCESS = CommandAccess.ADMIN_ONLY async def run(self, _session, _ifrom): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): return "OK" class TestCommandsDisco(SlixTestPlus): def setUp(self): self.stream_start( mode="component", plugins=["xep_0050"], jid="slidge.whatever.ass", server="whatever.ass", ) self.adhoc = AdhocProvider(self.xmpp) self.adhoc.register(Command1(self.xmpp)) self.adhoc.register(Command2(self.xmpp)) self.adhoc.register(Command3(self.xmpp)) self.adhoc.register(Command4(self.xmpp)) super().setUp() def test_disco_admin(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) def test_non_existing_command(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, use_values=False, ) def test_category(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ category """, use_values=False, ) self.recv( # language=XML f""" command3 """ ) self.send( # language=XML """ Confirm? 1 """ ) slidge/tests/test_adhoc/test_confirmation.py000066400000000000000000000226641522535037600217270ustar00rootroot00000000000000import pytest import slixmpp.test.slixtest from slixmpp import ComponentXMPP from slixmpp.plugins.xep_0050.adhoc import XEP_0050 import slidge.command.adhoc from slidge.command import Command, Confirmation from slidge.command.adhoc import AdhocProvider from slidge.util.test import SlixTestPlus class MockSession: def __init__(self, jid): self.jid = jid self.logged = "logged" in jid.user @pytest.fixture(autouse=True) def mock(monkeypatch, MockRE): monkeypatch.setattr( slidge.command.base, "is_admin", lambda j: j.user.startswith("admin") ) monkeypatch.setattr(Command, "_get_session", lambda s, j: MockSession(j)) monkeypatch.setattr( slixmpp.test.slixtest.ComponentXMPP, "get_session_from_stanza", lambda self, stanza: MockSession(stanza.get_from()), raising=False, ) monkeypatch.setattr(XEP_0050, "new_session", lambda _: "session-id") monkeypatch.setattr( ComponentXMPP, "jid_validator", MockRE, raising=False, ) class CommandAdmin(Command): NAME = "Command number one" NODE = "command1" async def run(self, _session, _ifrom): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): pass class CommandAdminConfirmFail(CommandAdmin): NAME = "Command number two" NODE = "command2" async def run_admin(self): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): raise RuntimeError("Ploup") class TestCommandsConfirmation(SlixTestPlus): def setUp(self): super().setUp() self.stream_start( mode="component", plugins=["xep_0050"], jid="slidge.whatever.ass", server="whatever.ass", ) self.adhoc = AdhocProvider(self.xmpp) self.adhoc.register(CommandAdmin(self.xmpp)) self.adhoc.register(CommandAdminConfirmFail(self.xmpp)) def test_confirmation_cancel(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ Confirm? 1 """ ) self.recv( # language=XML f""" """ ) self.recv( # language=XML """ """ ) def test_confirmation_do_it(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ Confirm? 1 """ ) self.recv( # language=XML f""" 1 """ ) self.send( # language=XML """ It worked! """ ) def test_confirmation_fail(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ Confirm? 1 """ ) self.recv( # language=XML f""" 1 """ ) self.send( # language=XML """ Ploup """, use_values=False, ) slidge/tests/test_adhoc/test_form.py000066400000000000000000000437531522535037600202040ustar00rootroot00000000000000import pytest from slixmpp import JID, ComponentXMPP from slixmpp.plugins.xep_0050.adhoc import XEP_0050 import slidge.command.adhoc from slidge.command import Command, Form from slidge.command.adhoc import AdhocProvider from slidge.command.base import FormField from slidge.util.test import SlixTestPlus class MockSession: def __init__(self, jid): # self.jid = jid self.logged = True @pytest.fixture(autouse=True) def mock(monkeypatch, MockRE): monkeypatch.setattr( slidge.command.base, "is_admin", lambda j: j.user.startswith("admin") ) monkeypatch.setattr(Command, "_get_session", lambda s, j: MockSession(j)) monkeypatch.setattr(XEP_0050, "new_session", lambda _: "session-id") monkeypatch.setattr( ComponentXMPP, "jid_validator", MockRE, raising=False, ) class Command1(Command): NAME = "Command number one" NODE = "command1" async def run(self, session, ifrom): return Form( title="A title", instructions="Some instructions", fields=[ FormField( "jid", type="jid-single", label="Enter a JID", value="user@host", required=True, ), FormField( "option", type="list-single", options=[ {"label": "Option 1", "value": "option1"}, {"label": "Option 2", "value": "option2"}, ], ), ], handler=self.finish, handler_kwargs={"arg1": "argument 1"}, ) @staticmethod async def finish(form_values, _session, ifrom, arg1): if form_values["jid"] == "bad@bad": raise RuntimeError("IT'S BAD, WE'RE FUCKED") assert isinstance(form_values["jid"], JID) return f"all good mate, {arg1}" class Command2(Command): NAME = "Command number two" NODE = "command2" async def run(self, session, ifrom): return Form( title="A title", instructions="Some instructions", fields=[ FormField( "str", type="list-multi", options=[ {"label": "Option 1", "value": "option1"}, {"label": "Option 2", "value": "option2"}, ], ), ], handler=self.finish, handler_kwargs={"arg1": "argument 1"}, ) @staticmethod async def finish(form_values, _session, ifrom, arg1): assert isinstance(form_values["str"], list) assert all(isinstance(f, str) for f in form_values["str"]) return f"all good mate, {arg1}" class TestCommandsResults(SlixTestPlus): def setUp(self): super().setUp() self.stream_start( mode="component", plugins=["xep_0050"], jid="slidge.whatever.ass", server="whatever.ass", ) self.adhoc = AdhocProvider(self.xmpp) self.adhoc.register(Command1(self.xmpp)) self.adhoc.register(Command2(self.xmpp)) def test_form_ok(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A title Some instructions user@host """ ) self.recv( # language=XML f""" value@value option1 """ ) self.send( # language=XML """ all good mate, argument 1 """ ) def test_form_bad_option(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A title Some instructions user@host """ ) self.recv( # language=XML f""" value@value option3 """ ) self.send( # language=XML """ Not a valid option: 'option3' """, use_values=False, ) def test_form_exc(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A title Some instructions user@host """ ) self.recv( # language=XML f""" bad@bad """ ) self.send( # language=XML """ IT'S BAD, WE'RE FUCKED """, use_values=False, ) def test_form_bad_jid(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A title Some instructions user@host """ ) self.recv( # language=XML f""" bad@bad@bad """ ) self.send( # language=XML """ Not a valid JID: 'bad@bad@bad' """, use_values=False, ) def test_multi(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A title Some instructions """ ) self.recv( # language=XML f""" option1 option2 """ ) self.send( # language=XML """ all good mate, argument 1 """ ) slidge/tests/test_adhoc/test_reported.py000066400000000000000000000071011522535037600210500ustar00rootroot00000000000000import pytest from slixmpp import JID, ComponentXMPP from slixmpp.plugins.xep_0050.adhoc import XEP_0050 from slixmpp.test import SlixTest import slidge.command.adhoc from slidge.command import Command, TableResult from slidge.command.adhoc import AdhocProvider from slidge.command.base import FormField class MockSession: def __init__(self, jid): self.logged = True @pytest.fixture(autouse=True) def mock(monkeypatch, MockRE): monkeypatch.setattr( slidge.command.base, "is_admin", lambda j: j.user.startswith("admin") ) monkeypatch.setattr(Command, "_get_session", lambda s, j: MockSession(j)) monkeypatch.setattr( ComponentXMPP, "jid_validator", MockRE, raising=False, ) monkeypatch.setattr(XEP_0050, "new_session", lambda _: "session-id") class Command1(Command): NAME = "Command number one" NODE = "command1" async def run(self, _session, _ifrom): return TableResult( description="A description", fields=[ FormField("name", label="JID"), FormField("jid", type="jid-single", label="JID"), ], items=[ {"jid": JID("test@test"), "name": "Some dude"}, {"jid": "test2@test", "name": "Some dude2"}, ], ) class TestCommandsResults(SlixTest): def setUp(self): self.stream_start( mode="component", plugins=["xep_0050"], jid="slidge.whatever.ass", server="whatever.ass", ) self.adhoc = AdhocProvider(self.xmpp) self.adhoc.register(Command1(self.xmpp)) def test_table_result(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ A description Some dude2 test2@test Some dude test@test """, use_values=False, ) slidge/tests/test_affiliation_versioning.py000066400000000000000000000162671522535037600216540ustar00rootroot00000000000000import datetime from conftest import UUIDFixtureMixin from slixmpp import Message, Presence from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyParticipant, LegacyRoster, ) from slidge.group import LegacyMUC from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway[Session]): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True class Roster(LegacyRoster[LegacyContact]): async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if legacy_id.startswith("room-"): raise XMPPError("item-not-found", "This is a MUC JID") return legacy_id class MUC(LegacyMUC[LegacyParticipant]): async def update_info(self): self.subject = "a subject" self.subject_setter = "occ1" self.subject_date = datetime.datetime(2000, 1, 1) async def fill_participants(self): for i in range(1, 3): yield await self.get_participant_by_legacy_id(f"occ{i}") class Bookmarks(LegacyBookmarks[MUC]): async def fill(self) -> None: pass class TestAffiliationVersioning(UUIDFixtureMixin, SlidgeTest): plugin = globals() AFF_VER_BASE = "a58097ba3a0ae6791478dac31ae201e0ed2aa06c4aed47fe50ac3b5dc0ea41ce" AFF_VER_WITH_OCC3 = ( "de90fe800d186bfe471e78643829ee5f44b5fdc9bdf55338eacff7a41fb2ed3b" ) AFF_VER_WITHOUT_OCC2 = ( "55e1628fe7e71daf123613fa594acd86006ec5857533abcfc44b44a73af8f0ef" ) def setUp(self) -> None: super().setUp() self._add_slidge_user() def test_join_no_ver(self) -> None: self.__recv_join() self.__send_all_affiations() self.__send_presences() self.__send_subject() self.__send_vcard() def test_join_no_diff(self) -> None: self.__recv_join(since=self.AFF_VER_BASE) self.__send_presences() self.__send_subject() self.__send_vcard() def test_join_unknown_ver(self) -> None: self.__recv_join(since="unkown") self.__send_all_affiations() self.__send_presences() self.__send_subject() self.__send_vcard() def test_live_new_member(self) -> None: self.test_join_no_ver() muc: LegacyMUC = self.run_coro( self.user_session().bookmarks.by_legacy_id("room-whatever") ) occ3 = self.run_coro(muc.get_participant_by_legacy_id("occ3")) presence = self.next_sent() assert isinstance(presence, Presence) assert presence.get_from() == occ3.jid self.send( # language=XML f""" """, use_values=False, ) def test_live_revoke(self) -> None: self.test_join_no_ver() muc: MUC = self.run_coro( self.user_session().bookmarks.by_legacy_id("room-whatever") ) occ2: LegacyParticipant = self.run_coro( muc.get_participant_by_legacy_id("occ2") ) occ2.leave() self.send( # language=XML f""" """, use_values=False, ) presence = self.next_sent() assert isinstance(presence, Presence) assert presence.get_from() == occ2.jid def __recv_join(self, since: str = "") -> None: if since: mav = f"" else: mav = "" self.recv( # language=XML f""" {mav} """ ) def __send_all_affiations(self) -> None: self.send( # language=XML f""" """, use_values=False, ) def __send_presences(self) -> None: for i in range(1, 3): pres = self.next_sent() assert isinstance(pres, Presence) assert pres.get_from().resource == f"occ{i}", pres self.send( # language=XML f""" """, use_values=False, ) def __send_subject(self) -> None: subject = self.next_sent() assert isinstance(subject, Message) assert "subject" in subject, subject def __send_vcard(self) -> None: _vcard = self.next_sent() assert self.next_sent() is None slidge/tests/test_attachment.py000066400000000000000000000764411522535037600172540ustar00rootroot00000000000000import asyncio import shutil import tempfile import unittest import unittest.mock from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress from datetime import datetime from pathlib import Path from unittest.mock import patch import pytest from conftest import AvatarFixtureMixin from slixmpp.plugins.xep_0356.permissions import MessagePermission from test_shakespeare import Base as Shakespeare from slidge.core import config from slidge.group import LegacyParticipant from slidge.util.types import LegacyAttachment, MessageReference class MockUUID: def __repr__(self) -> str: return "uuid" @property def hex(self) -> str: return str(self) @pytest.fixture(scope="function") def attachment(request, avatar_path): @asynccontextmanager async def mock_request(self, url, *a, **k): if "fail" in url: raise RuntimeError("IT FAILED") yield MockResponse class MockContent: @staticmethod async def iter_chunked(size): yield avatar_path.read_bytes() class MockResponse: status = 200 content = MockContent() @staticmethod def raise_for_status(): pass @staticmethod async def read(): return avatar_path.read_bytes() async def stream() -> AsyncIterator[bytes]: data = avatar_path.read_bytes() yield data[:50] await asyncio.sleep(0.001) yield data[50:] with ( patch("aiohttp.ClientSession.get", mock_request), patch("aiohttp.ClientSession.put", mock_request), patch("aiohttp.ClientSession.head", mock_request), patch("slidge.core.mixins.attachment.uuid4", return_value=MockUUID()), patch("uuid.uuid4", return_value=MockUUID()), ): request.cls.avatar_asynciterator = stream() # request.cls.get = get # request.cls.put = put # request.cls.head = head yield @pytest.mark.usefixtures("avatar") @pytest.mark.usefixtures("attachment") class Base(Shakespeare, AvatarFixtureMixin): def _assert_body(self, text="body", i=None): if i: self.send( # language=XML f""" {text} """, use_values=False, ) else: self.send( # language=XML f""" {text} """, use_values=False, ) def _assert_file( self, url="http://url", disposition: str | None = None, local_path: Path | None = None, is_sticker: bool = False, date: bool = True, hash_: bool = True, thumbnail: bool = True, ): if date: when = ( datetime.fromtimestamp((local_path or self.avatar_path).stat().st_mtime) .isoformat() .replace("+00:00", "Z") ) when = f"{when}" else: when = "" if hash_: h = 'NdpqDQuHlshve2c0iU25l2KI4cjpoyzaTk3a/CdbjPQ=' else: h = "" if thumbnail: thumb = """ """ else: thumb = "" el = f"disposition='{disposition}'" if disposition else "" sticker = "" if is_sticker else "" self.send( # language=XML f""" {sticker} image/png 5x5.png 547 {when} {h} {thumb} image/png 5x5.png 547 {when} {h} {thumb} {url} {url} """, use_values=False, ) class TestBodyOnly(Base): def test_no_file_no_body(self): self.run_coro(self.juliet.send_files([])) assert self.next_sent() is None def test_just_body(self): self.run_coro(self.juliet.send_files([], body="body")) self._assert_body() self.run_coro(self.juliet.send_files([], body="body", body_first=True)) self._assert_body() self.run_coro(self.juliet.send_files([], body="body", legacy_msg_id="12")) self._assert_body(i=12) class TestAttachmentUpload(Base): def __test_basic( self, attachment: LegacyAttachment, slot_request: bool = True, hash_: bool = True, thumbnail: bool = True, ) -> None: """ Basic test that file is uploaded. """ task = self.xmpp.loop.create_task(self.juliet.send_files([attachment])) self.run_coro(asyncio.sleep(0.01)) # x = self.next_sent() if slot_request: self.send( # language=XML """ """, use_values=False, ) self.recv( # language=XML """
whatever-value
""" ) self.run_coro(task) self._assert_file( disposition=attachment.disposition, is_sticker=attachment.is_sticker, date=attachment.path is not None, hash_=hash_, thumbnail=thumbnail, ) def _test_reuse(self, attachment: LegacyAttachment) -> None: """ Basic test the no new file is uploaded when the same attachment is used twice. """ self.__test_basic(attachment) self.__test_basic(attachment, slot_request=False) def test_path(self): self.__test_basic(LegacyAttachment(path=self.avatar_path)) def test_sticker(self): self.__test_basic(LegacyAttachment(path=self.avatar_path, is_sticker=True)) def test_disposition(self): self.__test_basic( LegacyAttachment(path=self.avatar_path, disposition="attachment") ) def test_path_and_id(self): self._test_reuse(LegacyAttachment(path=self.avatar_path, legacy_file_id="1235")) def test_bytes(self): self.__test_basic( LegacyAttachment(data=self.avatar_path.read_bytes(), name="5x5.png"), ) def test_bytes_and_id(self): self._test_reuse( LegacyAttachment( data=self.avatar_path.read_bytes(), legacy_file_id="123", name="5x5.png", ), ) def test_async_iterator(self): self.__test_basic( LegacyAttachment( aio_stream=self.avatar_asynciterator, name="5x5.png", ), ) def test_async_iterator_with_size(self): self.__test_basic( LegacyAttachment( aio_stream=self.avatar_asynciterator, content_type="image/png", name="5x5.png", size=547, ), thumbnail=False, hash_=False, ) def test_async_iterator_with_id(self): self._test_reuse( LegacyAttachment( aio_stream=self.avatar_asynciterator, legacy_file_id="123", name="5x5.png", ), ) def test_race_condition(self): async def stream(t): await asyncio.sleep(t) for _ in range(10): yield b"e" with patch( "slidge.core.mixins.attachment.AttachmentMixin._AttachmentMixin__upload", return_value="http://url", ) as upload: self.run_coro( asyncio.gather( self.juliet.send_file( LegacyAttachment(aio_stream=stream(0.5), legacy_file_id="123") ), self.juliet.send_file( LegacyAttachment(aio_stream=stream(0.5), legacy_file_id="123") ), ) ) upload.assert_awaited_once() msg1 = self.next_sent() msg2 = self.next_sent() assert msg1["body"] == msg2["body"] == "http://url" assert self.next_sent() is None def test_fail(self): self.run_coro( self.juliet.send_files( [LegacyAttachment(name="chaton.jpg", url="fail", caption="trop mimi")] ) ) self.send( # language=XML """ /me tried to send a file (chaton.jpg: trop mimi), but something went wrong: IT FAILED. """ ) class TestAttachmentNoUpload(Base): @classmethod def setUpClass(cls): super().setUpClass() config.NO_UPLOAD_URL_PREFIX = "https://url" @classmethod def tearDownClass(cls): super().tearDownClass() config.NO_UPLOAD_PATH = None config.NO_UPLOAD_URL_PREFIX = None def setUp(self): super().setUp() config.NO_UPLOAD_PATH = tempfile.TemporaryDirectory().name def tearDown(self): super().tearDown() with suppress(FileNotFoundError): shutil.rmtree(config.NO_UPLOAD_PATH) def __test_basic( self, attachment: LegacyAttachment, url: str, local_path: Path | None = None ): """ Basic test that file is copied. """ self.run_coro(self.juliet.send_files([attachment])) self._assert_file(url=url, local_path=attachment.path) def __test_reuse(self, attachment: LegacyAttachment, url: str): """ Basic test the no new file is copied when the same attachment is used twice. """ self.run_coro(self.juliet.send_files([attachment])) self._assert_file(url=url) self.run_coro(self.juliet.send_files([attachment])) self._assert_file(url=url) def test_path(self): self.__test_basic( LegacyAttachment(path=self.avatar_path), "https://url/uuid/uuid/5x5.png" ) def test_path_and_id(self): self.__test_reuse( LegacyAttachment(path=self.avatar_path, legacy_file_id="1234"), "https://url/1234/uuid/5x5.png", ) def test_multi(self): self.xmpp.use_message_ids = True self.run_coro( self.juliet.send_files( [ LegacyAttachment(path=self.avatar_path), LegacyAttachment(path=self.avatar_path, caption="CAPTION"), ], legacy_msg_id="6666", body="BODY", ) ) xmpp_ids = [] for _ in range(2): att = self.next_sent() xmpp_ids.append(att.get_id()) caption = self.next_sent() assert caption["body"] == "CAPTION" xmpp_ids.append(caption.get_id()) body = self.next_sent() assert body["body"] == "BODY" xmpp_ids.append(body.get_id()) assert self.next_sent() is None assert len(set(xmpp_ids)) == len(xmpp_ids) self.juliet.react("6666", "♥") for _ in range(len(xmpp_ids)): reaction = self.next_sent() assert reaction["reactions"]["id"] in xmpp_ids self.recv( # language=XML """ """ ) for i in xmpp_ids: with patch("test_shakespeare.ContactShakespeare.on_react") as mock: self.recv( # language=XML f""" 👋 🐢 """ ) for j in [k for k in xmpp_ids if k != i]: reac = self.next_sent() assert reac["privilege"]["forwarded"]["message"]["reactions"]["id"] == j mock.assert_awaited_once() # assert mock.call_args[0][0].jid == self.juliet.jid assert mock.call_args[0][0] == "6666" assert mock.call_args[0][1] == ["👋", "🐢"] assert mock.call_args[1] == dict(thread=None) self.xmpp.use_message_ids = False self.xmpp.LEGACY_MSG_ID_TYPE = True def test_multiatt_mark_all_messages_body_caption(self): self.xmpp.MARK_ALL_MESSAGES = True self.run_coro( self.juliet.send_files( [ LegacyAttachment(path=self.avatar_path), ], legacy_msg_id="legacy-real-id", ) ) assert self.next_sent() is not None self.run_coro( self.juliet.send_files( [ LegacyAttachment(path=self.avatar_path), ], legacy_msg_id="legacy-real-id", ) ) assert self.next_sent() is not None self.xmpp.MARK_ALL_MESSAGES = False def test_multi_from_user(self): self.xmpp.plugin["xep_0356"].granted_privileges[ "montague.lit" ].message = MessagePermission.OUTGOING self.run_coro( self.juliet.send_files( [], body="test", legacy_msg_id="test-msg-id", carbon=True ) ) self.send( # language=XML """ test """, # use_values=False, ) self.juliet.react(legacy_msg_id="test-msg-id", emojis=["🏥"]) self.send( # language=XML """ 🏥 """, use_values=False, ) def test_multi_from_user_muc(self): session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) muc.add_user_resource("gajim") user_part = self.run_coro(muc.get_user_participant()) self.run_coro( user_part.send_files([], body="test", legacy_msg_id="test-msg-id") ) self.send( # language=XML """ test """, use_values=False, ) with unittest.mock.patch("uuid.uuid4") as uuid: uuid.return_value = "prout" user_part.react(legacy_msg_id="test-msg-id", emojis=["🏥"]) self.send( # language=XML """ 🏥 """, use_values=False, ) def test_multi_moderation(self): session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) muc.add_user_resource("gajim") part = muc.get_system_participant() self.run_coro( part.send_files( [ LegacyAttachment(path=self.avatar_path), LegacyAttachment(path=self.avatar_path, caption="CAPTION"), ], legacy_msg_id="the-real-msg-id", body="BODY", ) ) stanza_ids = [] while (stanza := self.next_sent()) is not None: stanza_ids.append(stanza["stanza_id"]["id"]) assert len(stanza_ids) == 4 # 2 attachments, the caption and the body assert "the-real-msg-id" in stanza_ids part.moderate("the-real-msg-id") moderated_ids = [] while (stanza := self.next_sent()) is not None: moderated_ids.append(stanza["retract"]["id"]) assert set(stanza_ids) == set(moderated_ids) def test_multi_retraction(self): session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) muc.add_user_resource("gajim") part = muc.get_system_participant() self.run_coro( part.send_files( [ LegacyAttachment(path=self.avatar_path), LegacyAttachment(path=self.avatar_path, caption="CAPTION"), ], legacy_msg_id="the-real-msg-id", body="BODY", ) ) stanza_ids = [] while (stanza := self.next_sent()) is not None: stanza_ids.append(stanza["stanza_id"]["id"]) assert len(stanza_ids) == 4 # 2 attachments, the caption and the body assert "the-real-msg-id" in stanza_ids part.retract("the-real-msg-id") retracted_ids = [] while (stanza := self.next_sent()) is not None: retracted_ids.append(stanza["retract"]["id"]) assert set(stanza_ids) == set(retracted_ids) def test_multi_reply(self): session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) muc.add_user_resource("gajim") part = muc.get_system_participant() self.run_coro( part.send_files( [ LegacyAttachment(path=self.avatar_path), LegacyAttachment(path=self.avatar_path, caption="CAPTION"), ], legacy_msg_id="42", body="BODY", ) ) stanza_ids = [] while (stanza := self.next_sent()) is not None: stanza_ids.append(stanza["stanza_id"]["id"]) assert len(stanza_ids) == 4 # 2 attachments, the caption and the body for stanza_id in stanza_ids: with unittest.mock.patch( "slidge.group.LegacyMUC.on_message", return_value="xxx" ) as on_message: self.recv( # language=XML f""" Great idea! """ ) on_message.assert_awaited_once() assert on_message.call_args[0][0].reply.msg_id == "42" def test_multi_react(self): session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) muc.add_user_resource("gajim") part: LegacyParticipant = muc.get_system_participant() self.run_coro( part.send_files( [ LegacyAttachment(path=self.avatar_path), LegacyAttachment(path=self.avatar_path, caption="CAPTION"), ], legacy_msg_id="42", body="BODY", ) ) stanza_ids = [] while (stanza := self.next_sent()) is not None: stanza_ids.append(stanza["stanza_id"]["id"]) part.react(legacy_msg_id="42", emojis=["♥"]) reacted_stanzas_ids = [] while (stanza := self.next_sent()) is not None: reacted_stanzas_ids.append(stanza["reactions"]["id"]) assert set(stanza_ids) == set(reacted_stanzas_ids) def test_reply_with_attachment(self): self.run_coro( self.juliet.send_files( [ LegacyAttachment(path=self.avatar_path), ], reply_to=MessageReference("some_msg_id", body="a body"), ) ) when = ( datetime.fromtimestamp(self.avatar_path.stat().st_mtime) .isoformat() .replace("+00:00", "Z") ) self.send( # language=XML f""" https://url/uuid/uuid/5x5.png image/png 5x5.png 547 {when} NdpqDQuHlshve2c0iU25l2KI4cjpoyzaTk3a/CdbjPQ= image/png 5x5.png 547 {when} NdpqDQuHlshve2c0iU25l2KI4cjpoyzaTk3a/CdbjPQ= https://url/uuid/uuid/5x5.png """, use_values=False, ) def test_url(self): tmp = tempfile.mkdtemp() with unittest.mock.patch("tempfile.mkdtemp", return_value=tmp): self.__test_basic( LegacyAttachment(url=self.avatar_url, name="5x5.png"), url="https://url/uuid/uuid/5x5.png", ) shutil.rmtree(tmp) class TestAttachmentOriginalUrl(Base): @classmethod def setUpClass(cls): super().setUpClass() config.USE_ATTACHMENT_ORIGINAL_URLS = True @classmethod def tearDownClass(cls): super().tearDownClass() config.USE_ATTACHMENT_ORIGINAL_URLS = False def __check(self, url, name=None, content_type=None, disposition=None): disposition = f"disposition='{disposition}'" if disposition else "" # file_sharing = "{name}
" if name else "" content_type = ( f"{content_type}" if content_type else "" ) self.send( # language=XML f""" {content_type} {name} {content_type} {name} {url} {url} """ ) def __send_file(self, url, name=None, content_type=None, disposition=None): self.run_coro( self.juliet.send_file( LegacyAttachment( url=url, name=name, content_type=content_type, disposition=disposition, ) ) ) def test_url_original(self): self.__send_file("prout", name="5x5.png", disposition="inline") self.__check( "prout", name="5x5.png", content_type="image/png", disposition="inline" ) self.__send_file("prout", name="5x5.png", content_type="GNAGNA") self.__check("prout", name="5x5.png", content_type="GNAGNA") self.__send_file("prout", disposition="inline") self.__check("prout", disposition="inline") self.__send_file("prout", name="5x5.png") self.__check("prout", name="5x5.png", content_type="image/png") self.__send_file("prout.png") self.__check("prout.png", content_type="image/png") def test_no_metadata(self): self.__send_file("prout") self.__check("prout") slidge/tests/test_avatar.py000066400000000000000000000450731522535037600163770ustar00rootroot00000000000000import unittest.mock import pytest from conftest import AvatarFixtureMixin from test_shakespeare import Base as BaseNoMUC from slidge import LegacyMUC, MucType from slidge.util.types import Avatar @pytest.mark.usefixtures("avatar") class TestContactAvatar(BaseNoMUC, AvatarFixtureMixin): def setUp(self): super().setUp() self.juliet.is_friend = True self.juliet.added_to_roster = True def __assert_not_found(self): juliet = self.juliet self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def __assert_publish(self, rewritten=False): h = self.avatar_sha1 if rewritten else self.avatar_original_sha1 length = ( len(self.avatar_bytes) if rewritten else len(self.avatar_path.read_bytes()) ) self.send( # language=XML f""" """ ) assert self.next_sent() is None def __assert_publish_empty(self): self.send( # language=XML """ """, use_values=False, ) assert self.next_sent() is None def test_avatar_path_no_id(self): juliet = self.juliet assert juliet.avatar is None juliet.avatar = None self.run_coro(juliet._set_avatar_task) assert self.next_sent() is None self.__assert_not_found() juliet.avatar = Avatar(path=self.avatar_path) self.run_coro(juliet._set_avatar_task) self.__assert_publish() juliet.avatar = Avatar(path=self.avatar_path) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is None self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path))) assert self.next_sent() is None self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path))) assert self.next_sent() is None juliet.avatar = Avatar(path=self.avatar_path) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is None juliet.avatar = None self.run_coro(juliet._set_avatar_task) self.__assert_publish_empty() self.run_coro(juliet.set_avatar(None)) assert self.next_sent() is None self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path))) self.__assert_publish() juliet.avatar = None self.run_coro(juliet._set_avatar_task) self.__assert_publish_empty() def test_avatar_path_with_id(self): juliet = self.juliet assert juliet.avatar is None self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path, unique_id="123"))) self.__assert_publish(rewritten=False) assert juliet.avatar and (juliet.avatar.unique_id == "123") self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path, unique_id="123"))) assert self.next_sent() is None assert juliet.avatar.unique_id == "123" self.run_coro(juliet.set_avatar(Avatar(path=self.avatar_path, unique_id="123"))) assert self.next_sent() is None assert juliet.avatar.unique_id == "123" self.run_coro(juliet.set_avatar(None)) self.__assert_publish_empty() assert juliet.avatar is None def test_same_avatar_with_different_legacy_ids(self): self.run_coro( self.juliet.set_avatar(Avatar(path=self.avatar_path, unique_id="123")) ) self.__assert_publish(rewritten=False) assert self.juliet.avatar.unique_id == "123" assert self.juliet.get_avatar().id == self.avatar_original_sha1 self.run_coro( self.juliet.set_avatar(Avatar(path=self.avatar_path, unique_id="456")) ) self.__assert_publish( rewritten=False ) # FIXME: ideally, we should not publish here… assert self.juliet.avatar.unique_id == "456" assert self.juliet.get_avatar().id == self.avatar_original_sha1 def test_avatar_with_url(self): juliet = self.juliet assert juliet.avatar is None juliet.avatar = Avatar(url=self.avatar_url) self.run_coro(juliet._set_avatar_task) self.__assert_publish(rewritten=True) juliet.avatar = Avatar(url=self.avatar_url) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is None def test_avatar_with_url_and_unique_id(self): juliet = self.juliet assert juliet.avatar is None self.run_coro( juliet.set_avatar(Avatar(url=self.avatar_url, unique_id="someid")) ) self.__assert_publish(rewritten=True) self.run_coro( juliet.set_avatar(Avatar(url=self.avatar_url, unique_id="someid")) ) assert self.next_sent() is None def test_url_no_headers_remote_has_not_changed(self): juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url_no_headers) self.run_coro(juliet._set_avatar_task) self.__assert_publish(rewritten=True) juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url_no_headers) self.run_coro(juliet._set_avatar_task) # TODO: no reason to broadcast the avatar here, but we rely on HTTP headers # to not re-publish. This is probably in the realm of micro-optimisation # and does not matter much though… self.__assert_publish(rewritten=True) def test_url_no_headers_remote_has_changed(self): juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url_no_headers) self.run_coro(juliet._set_avatar_task) self.__assert_publish(rewritten=True) with self.switch_avatar(): juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url_no_headers) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is not None juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url_no_headers) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is not None class MUC(LegacyMUC): type = MucType.GROUP user_nick = "romeo" async def update_info(self): return class BaseMUC(BaseNoMUC): plugin = BaseNoMUC.plugin | {"MUC": MUC} def setUp(self): self.patch = unittest.mock.patch("uuid.uuid4", return_value="uuid4") self.patch.start() super().setUp() def tearDown(self): super().tearDown() self.patch.stop() def _assert_send_room_avatar(self, empty=False, url=False): if empty: photo = "" else: photo = f"{self.avatar_sha1 if url else self.avatar_original_sha1}" self.send( # language=XML f""" {photo} """, use_values=not empty, ) def romeo_joins(self, muc: MUC): session = self.get_romeo_session() self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) subject_msg = self.next_sent() assert subject_msg.xml.find(f"{{{subject_msg.namespace}}}subject") is not None # assert self.next_sent()["from"] == "room@aim.shakespeare.lit" def get_muc(self, joined=True) -> MUC: session = self.get_romeo_session() muc = self.run_coro(session.bookmarks.by_legacy_id("room")) if joined: self.romeo_joins(muc) return self.run_coro(session.bookmarks.by_legacy_id("room")) @pytest.mark.usefixtures("avatar") class TestParticipantAvatar(BaseMUC, AvatarFixtureMixin): def romeo_joins(self, muc: MUC): super().romeo_joins(muc) self._assert_send_room_avatar(empty=True) def _assert_juliet_presence_no_avatar(self): self.send( # language=XML """ """ ) def _assert_juliet_presence_avatar(self, sha=None, url=False): self.send( # language=XML f""" {self.avatar_sha1 if url else self.avatar_original_sha1} """ ) def test_romeo_join_empty_room_then_juliet_joins_then_set_avatar(self): self.get_muc(joined=True) session = self.get_romeo_session() session.contacts.ready.set_result(True) muc = self.get_muc(joined=False) self.run_coro(muc.get_participant_by_contact(self.juliet)) self._assert_juliet_presence_no_avatar() _aff_msg = self.next_sent() assert self.next_sent() is None juliet = self.juliet juliet.avatar = Avatar(path=self.avatar_path) # no broadcast of the contact avatar because not added to roster, # only the participant self.run_coro(juliet._set_avatar_task) self._assert_juliet_presence_avatar() assert self.next_sent() is None self.juliet.avatar = Avatar(path=self.avatar_path) assert self.next_sent() is None juliet = self.juliet juliet.avatar = None self.run_coro(juliet._set_avatar_task) self._assert_juliet_presence_no_avatar() assert self.next_sent() is None def test_romeo_join_empty_room_then_juliet_joins_then_set_avatar_with_url(self): self.get_muc(joined=True) session = self.get_romeo_session() session.contacts.ready.set_result(True) juliet = self.juliet self.run_coro(self.get_muc(joined=False).get_participant_by_contact(juliet)) self._assert_juliet_presence_no_avatar() _aff_msg = self.next_sent() assert self.next_sent() is None juliet = self.juliet juliet.avatar = Avatar(url=self.avatar_url) # no broadcast of the contact avatar because not added to roster, # only the participant self.run_coro(juliet._set_avatar_task) self._assert_juliet_presence_avatar(url=True) assert self.next_sent() is None juliet.avatar = Avatar(url=self.avatar_url) self.run_coro(juliet._set_avatar_task) assert self.next_sent() is None juliet.avatar = None self.run_coro(juliet._set_avatar_task) self._assert_juliet_presence_no_avatar() assert self.next_sent() is None def test_avatar_forbidden_emoji_in_participant_nickname(self): self.get_muc(joined=True) session = self.get_romeo_session() juliet = self.juliet juliet.name = "juliet🎉" juliet.avatar = Avatar(url=self.avatar_url) session.contacts.ready.set_result(True) self.run_coro(self.get_muc(False).get_participant_by_contact(juliet)) self.send( # language=XML f""" {self.avatar_sha1} juliet🎉 """ ) _aff_msg = self.next_sent() self.recv( # language=XML """ """ ) self.send( # language=XML f""" {self.avatar_base64} image/png """ ) assert self.next_sent() is None juliet = self.run_coro(self.get_muc(False).get_participant_by_contact(juliet)) juliet.send_text("prout") self.send( # language=XML """ prout juliet🎉 """ ) @pytest.mark.usefixtures("avatar") class TestRoomAvatar(BaseMUC, AvatarFixtureMixin): def test_room_avatar_change_after_join(self): self.get_muc(joined=True) self._assert_send_room_avatar(empty=True) muc = self.get_muc(joined=False) muc.avatar = self.avatar_path self.run_coro(muc._set_avatar_task) self.send( # language=XML """ """, use_values=False, ) self._assert_send_room_avatar() def test_room_avatar_on_join(self): muc = self.get_muc(joined=False) muc.avatar = self.avatar_path self.run_coro(muc._set_avatar_task) self.romeo_joins(muc) self._assert_send_room_avatar() def test_room_avatar_with_url(self): muc = self.get_muc(joined=False) muc.avatar = self.avatar_url self.run_coro(muc._set_avatar_task) self.romeo_joins(muc) self._assert_send_room_avatar(url=True) def test_room_avatar_with_url_and_unique_id(self): muc = self.get_muc(joined=False) self.run_coro(muc.set_avatar(Avatar(url=self.avatar_url, unique_id="id"))) self.romeo_joins(muc) self._assert_send_room_avatar(url=True) self.run_coro(muc.set_avatar(Avatar(url=self.avatar_url, unique_id="id"))) assert self.next_sent() is None slidge/tests/test_backfill.py000066400000000000000000000103551522535037600166630ustar00rootroot00000000000000import datetime import unittest.mock import sqlalchemy as sa from slixmpp import Message from slidge import BaseGateway, BaseSession from slidge.db.models import ArchivedMessage from slidge.util.archive_msg import HistoryMessage from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" class TestBackfill(SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() self.setup_logged_session() def tearDown(self): with self.xmpp.store.session() as orm: orm.execute(sa.delete(ArchivedMessage)) super().tearDown() def test_empty_archive(self): with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_with(None, None) def test_live_no_id_before_backfill(self): self.first_witch.send_text("BODY 1") self.first_witch.send_text("BODY 2") self.first_witch.send_text( "BODY 3", when=datetime.datetime.now(tz=datetime.UTC) ) with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_with(None, None) def test_live_with_id_before_backfill(self): now = datetime.datetime.now(tz=datetime.UTC) self.first_witch.send_text("BODY 2", "222", when=now) self.first_witch.send_text( "BODY 1", "111", when=now - datetime.timedelta(hours=1) ) self.first_witch.send_text( "BODY 3", "333", when=now + datetime.timedelta(hours=1) ) with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_once() after, before = backfill.call_args[0] assert before.id == "111" assert before.timestamp == now - datetime.timedelta(hours=1) assert after is None def _add_back_filled_msg(self, legacy_id=None, when=None): with self.xmpp.store.session() as orm: self.xmpp.store.mam.add_message( orm, self.room.stored.id, HistoryMessage(Message(), when), archive_only=True, legacy_msg_id=legacy_id, ) orm.commit() def test_pre_backfilled_no_id(self): self._add_back_filled_msg() with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_with(None, None) def test_pre_backfilled_with_id(self): self._add_back_filled_msg(None) self._add_back_filled_msg("111") self._add_back_filled_msg("222") self._add_back_filled_msg(None) with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_once() after, before = backfill.call_args[0] assert before is None assert after is not None assert after.id == "222" def test_pre_backfilled_with_id_and_live(self): now = datetime.datetime.now(tz=datetime.UTC) self._add_back_filled_msg(None, now - datetime.timedelta(days=5)) self._add_back_filled_msg("111", now - datetime.timedelta(days=4)) self._add_back_filled_msg("222", now - datetime.timedelta(days=3)) self._add_back_filled_msg(None, now - datetime.timedelta(days=2)) self.first_witch.send_text("BODY1", None) self.first_witch.send_text("BODY2", "555") self.first_witch.send_text("BODY3", None) self.first_witch.send_text("BODY5", "666") self.first_witch.send_text("BODY6", None) self.first_witch.send_text("BODY7", None) with unittest.mock.patch("slidge.group.LegacyMUC.backfill") as backfill: self.run_coro(self.room._LegacyMUC__fill_history()) backfill.assert_awaited_once() after, before = backfill.call_args[0] assert before.id == "555" assert after.id == "222" slidge/tests/test_chat_commands.py000066400000000000000000000177671522535037600177320ustar00rootroot00000000000000import pytest import slixmpp.test.slixtest from slixmpp import JID import slidge.command.chat_command from slidge.command import Command, Confirmation from slidge.slixfix.delivery_receipt import DeliveryReceipt from slidge.util.test import SlixTestPlus class MockSession: def __init__(self, jid): self.logged = "logged" in jid.user @pytest.fixture(autouse=True) def mock(monkeypatch, MockRE): monkeypatch.setattr(Command, "_get_session", lambda s, j: MockSession(j)) monkeypatch.setattr( slixmpp.test.slixtest.ComponentXMPP, "get_session_from_stanza", lambda self, stanza: MockSession(stanza.get_from()), raising=False, ) monkeypatch.setattr( slixmpp.test.slixtest.ComponentXMPP, "jid_validator", MockRE, raising=False, ) monkeypatch.setattr( slixmpp.test.slixtest.ComponentXMPP, "get_session_from_jid", lambda s, j: MockSession(j), raising=False, ) class CommandAdmin(Command): NAME = "Command number one" CHAT_COMMAND = "command1" test_results = [] async def run(self, _session, _ifrom): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): self.test_results.append("yup") class CommandAdminConfirmFail(CommandAdmin): NAME = "Command number two" CHAT_COMMAND = "command2" async def run_admin(self): return Confirmation( prompt="Confirm?", handler=self.finish, success="It worked!" ) async def finish(self, _session, _ifrom): raise RuntimeError("Ploup") class TestChatCommands(SlixTestPlus): def setUp(self): self.stream_start( mode="component", plugins=["xep_0050"], jid="slidge.whatever.ass", server="whatever.ass", ) self.commands = slidge.command.chat_command.ChatCommandProvider(self.xmpp) self.commands.register(CommandAdmin(self.xmpp)) self.commands.register(CommandAdminConfirmFail(self.xmpp)) self.xmpp.delivery_receipt = DeliveryReceipt(self.xmpp) super().setUp() def test_non_existing(self): self.recv( # language=XML f""" non-existing """ ) t = self.commands.UNKNOWN.format("non-existing") self.send( # language=XML f""" {t} """ ) self.send( # language=XML f""" {t} """, use_values=False, ) def test_other_destination(self): self.recv( # language=XML f""" help """ ) assert self.next_sent() is None def test_command_help(self): self.recv( # language=XML f""" help """ ) self.send( # language=XML """ Available commands:\ncommand1 -- Command number one\ncommand2 -- Command number two """ ) def test_input(self): fut = self.run_coro( self.commands.input(JID("user@whatever.ass/x"), "blabla", blocking=False) ) self.send( # language=XML """ blabla """ ) self.recv( # language=XML """ reply """ ) assert fut.result() == "reply" def test_confirm_no(self): self.recv( # language=XML f""" command1 """ ) self.send( # language=XML f""" Confirm? """ ) self.recv( # language=XML f""" no """ ) self.send( # language=XML f""" Canceled """ ) assert len(CommandAdmin.test_results) == 0 def test_confirm_yes(self): self.recv( # language=XML f""" command1 """ ) self.send( # language=XML f""" Confirm? """ ) self.recv( # language=XML f""" yes """ ) self.send( # language=XML f""" End of command. """ ) assert CommandAdmin.test_results.pop() == "yup" assert len(CommandAdmin.test_results) == 0 slidge/tests/test_commands.py000066400000000000000000000406201522535037600167130ustar00rootroot00000000000000import unittest.mock from collections.abc import Callable, Iterator import pytest from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from slidge import ( # type:ignore[attr-defined] BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyRoster, ) from slidge.command import FormField from slidge.command.base import ( ConfirmationRecipient, ContactCommand, FormRecipient, MUCCommand, ) from slidge.group import LegacyMUC, LegacyParticipant from slidge.util.test import SlidgeTest # type:ignore[attr-defined] class Session(BaseSession["Contact | LegacyMUC[LegacyParticipant]"]): async def login(self) -> None: pass class Gateway(BaseGateway): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True class Contact(LegacyContact): pass class Roster(LegacyRoster[Contact]): async def jid_username_to_legacy_id(self, jid_username: str) -> str: if jid_username.startswith("room-"): raise XMPPError("item-not-found", "This is a MUC JID") return jid_username async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if legacy_id.startswith("room-"): raise XMPPError("item-not-found", "This is a MUC JID") return legacy_id class Bookmarks(LegacyBookmarks[LegacyMUC[LegacyParticipant]]): async def jid_username_to_legacy_id(self, jid_username: str) -> str: if not jid_username.startswith("room-"): raise XMPPError("item-not-found", "This is a contact JID") return jid_username async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if not legacy_id.startswith("room-"): raise XMPPError("item-not-found", "This is a contact JID") return legacy_id async def fill(self) -> None: pass class ContactCommand1(ContactCommand[Contact]): NAME = "contact command #1" NODE = CHAT_COMMAND = "command1" HELP = "Do stuff" @staticmethod async def run(contact: Contact, *args: str) -> str: return f"it worked 1! {contact.legacy_id}" class ContactCommand2(ContactCommand[Contact]): NAME = "contact command #2" NODE = CHAT_COMMAND = "command2" HELP = "Do some stuff" @staticmethod async def run(contact: Contact, *args: str) -> FormRecipient[Contact] | str: if args: return f"Single arg: {args[0]}" return FormRecipient[Contact]( title="a title", instructions="some instructions", fields=[FormField(var="a var", label="a label", value=contact.legacy_id)], handler=ContactCommand2.confirm, ) @staticmethod async def confirm( contact: Contact, form: dict[str, str | JID | bool] ) -> ConfirmationRecipient[Contact]: return ConfirmationRecipient( prompt=str(form["a var"]), handler=ContactCommand2.finish ) @staticmethod async def finish(contact: Contact) -> str: return "yay!" class MUCCommand1(MUCCommand[LegacyMUC[LegacyParticipant]]): NAME = "room command #1" NODE = CHAT_COMMAND = "command1" HELP = "Do stuff" @staticmethod async def run(muc: LegacyMUC[LegacyParticipant], *args: str) -> str: return f"it worked 1! {muc.legacy_id}" class MUCCommand2(MUCCommand[LegacyMUC[LegacyParticipant]]): NAME = "room command #2" NODE = CHAT_COMMAND = "command2" HELP = "Do some stuff" @staticmethod async def run( muc: LegacyMUC[LegacyParticipant], *args: str ) -> FormRecipient[LegacyMUC[LegacyParticipant]] | str: if args: return f"Single arg: {args[0]}" return FormRecipient( title="a title", instructions="some instructions", fields=[FormField(var="a var", label="a label", value=muc.legacy_id)], handler=MUCCommand2.confirm, ) @staticmethod async def confirm( muc: LegacyMUC[LegacyParticipant], form: dict[str, str | JID | bool], ) -> ConfirmationRecipient[LegacyMUC[LegacyParticipant]]: return ConfirmationRecipient( prompt=str(form["a var"]), handler=MUCCommand2.finish ) @staticmethod async def finish(muc: LegacyMUC[LegacyParticipant]) -> str: return "yay!" class _AdhocSetup(SlidgeTest): # type:ignore[misc] plugin = globals() def setUp(self) -> None: super().setUp() self._add_slidge_user() self.__patch = unittest.mock.patch( "slixmpp.plugins.xep_0050.adhoc.XEP_0050.new_session", return_value="sid" ) self.__patch.start() def tearDown(self) -> None: super().tearDown() self.__patch.stop() class _AdhocTests: _test_command_jid: str _test_command_kind: str _test_command_legacy_id: str recv: Callable[[str], None] send: Callable[[str], None] next_sent: Callable[[], Iq] def test_info(self) -> None: self.recv( # language=XML f""" """ ) iq = self.next_sent() assert "http://jabber.org/protocol/commands" in iq["disco_info"]["features"] def test_items(self) -> None: self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_command_info(self) -> None: self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_execute_basic(self) -> None: self.recv( # language=XML f""" """ ) self.send( # language=XML f""" it worked 1! {self._test_command_legacy_id} """ ) def test_execute_multistage(self) -> None: self.recv( # language=XML f""" """ ) self.send( # language=XML f""" a title some instructions {self._test_command_legacy_id} """ ) self.recv( # language=XML f""" YAYAYA """ ) self.send( # language=XML f""" YAYAYA 1 """ ) self.recv( # language=XML f""" 1 """ ) self.send( # language=XML f""" yay! """ ) class TestContactAdhoc(_AdhocSetup, _AdhocTests): _test_command_jid = "juliet@aim.shakespeare.lit/slidge" _test_command_kind = "contact" _test_command_legacy_id = "juliet" class TestMUCAdhoc(_AdhocSetup, _AdhocTests): _test_command_jid = "room-command@aim.shakespeare.lit" _test_command_kind = "room" _test_command_legacy_id = "room-command" class _ChatMixin(SlidgeTest): # type:ignore[misc] plugin = globals() def setUp(self) -> None: super().setUp() self._add_slidge_user() def _recv(self, body: str) -> None: self.recv( # language=XML f""" {body} """ ) def _send(self, body: str) -> None: self.send( # language=XML f""" {body} """ ) def _error(self, condition: str, type: str, text: str) -> None: el = f"<{condition} xmlns='urn:ietf:params:xml:ns:xmpp-stanzas' />" self.send( # language=XML f""" {el}; {text} """ ) class TestContactChat(_ChatMixin): def test_execute(self) -> None: self._recv("contact juliet command2") self._send("a title") self._send("some instructions") self._send("Default: juliet") self._send("a label? (or 'abort')") self._recv("echome") self._send("echome") self._recv("yes") self._send("yay!") def test_execute_with_args(self) -> None: self._recv("contact juliet command2 some-arg") self._send("Single arg: some-arg") def test_execute_with_invalid_contact(self) -> None: self._recv("contact room-prout command2") self._error("item-not-found", "cancel", "This is a MUC JID") def test_help(self) -> None: self._recv("contact") self._send(_HELP.format(recipient="contact")) def test_single(self) -> None: self._recv("contact bad-syntax") self._send(_HELP.format(recipient="contact")) self._error( "bad-request", "modify", "Contact commands require at least two parameters: contact_jid_username and command_name", ) class TestMUCChat(_ChatMixin): def test_execute(self) -> None: self._recv("room room-command command2") self._send("a title") self._send("some instructions") self._send("Default: room-command") self._send("a label? (or 'abort')") self._recv("echome") self._send("echome") self._recv("yes") self._send("yay!") def test_execute_with_args(self) -> None: self._recv("room room-command command2 some-arg") self._send("Single arg: some-arg") def test_execute_with_invalid_contact(self) -> None: self._recv("room juliet command2") self._error("item-not-found", "cancel", "This is a contact JID") def test_help(self) -> None: self._recv("room") self._send(_HELP.format(recipient="room")) def test_single(self) -> None: self._recv("room bad-syntax") self._send(_HELP.format(recipient="room")) self._error( "bad-request", "modify", "Contact commands require at least two parameters: room_jid_username and command_name", ) _HELP = """Available commands: command1 ({recipient} command #1): Do stuff command2 ({recipient} command #2): Do some stuff""" @pytest.fixture(scope="module", autouse=True) def _reset_commands() -> Iterator[None]: yield LegacyContact.commands.clear() LegacyMUC.commands.clear() slidge/tests/test_config.py000066400000000000000000000203461522535037600163620ustar00rootroot00000000000000import logging from pathlib import Path import pytest from slixmpp import JID from slidge import main from slidge.core import config from slidge.util.conf import ConfigModule def test_get_parser(monkeypatch): class Config: REQUIRED: str REQUIRED__DOC = "some doc" REQUIRED__SHORT = "r" REQUIRED_INT: int REQUIRED_INT__DOC = "some doc" MULTIPLE: tuple[str, ...] = () MULTIPLE__DOC = "some more doc" OPTIONAL: str | None = None OPTIONAL__DOC = "not required" SOME_BOOL = False SOME_BOOL__DOC = "a bool" monkeypatch.setattr(main, "config", Config) parser = main.get_parser() with pytest.raises(SystemExit) as e: parser.parse_known_args([]) assert e.value.args[0] == 2 # Exit code 2 args = parser.parse_args(["--required", "some_value", "--required-int", "45"]) assert args.required == "some_value" assert args.required_int == 45 args = parser.parse_args(["-r", "some_value", "--required-int", "45"]) assert args.required == "some_value" assert args.required_int == 45 assert args.multiple == tuple() args = parser.parse_args( ["-r", "some_value", "--required-int", "45", "--multiple", "a", "b"] ) assert args.required == "some_value" assert args.required_int == 45 assert args.multiple == ["a", "b"] assert args.optional is None args = parser.parse_args( [ "-r", "some_value", "--required-int", "45", "--multiple", "a", "b", "--optional", "prout", "--some-bool", ] ) assert args.required == "some_value" assert args.required_int == 45 assert args.multiple == ["a", "b"] assert args.optional == "prout" assert args.some_bool def test_bool(tmpdir, tmp_path): class Config: SOME_BOOL = False SOME_BOOL__DOC = "a bool" TRUE = True TRUE__DOC = "true by default" configurator = ConfigModule(Config) configurator.set_conf([]) assert not Config.SOME_BOOL assert Config.TRUE configurator.set_conf(["--some-bool"]) assert Config.SOME_BOOL assert Config.TRUE configurator.set_conf(["--true"]) assert not Config.SOME_BOOL assert Config.TRUE configurator.set_conf(["--true=false"]) assert not Config.SOME_BOOL assert not Config.TRUE configurator.set_conf(["--true=true"]) assert not Config.SOME_BOOL assert Config.TRUE configurator.set_conf(["--some-bool=true"]) assert Config.SOME_BOOL assert Config.TRUE configurator.set_conf(["--some-bool=false"]) assert not Config.SOME_BOOL assert Config.TRUE # for the plugin-specific conf files, we use the rest configurator.parser.add_argument("-c", is_config_file=True) class Config2: SOME_OTHER_BOOL = False SOME_OTHER_BOOL__DOC = "a bool" TRUE2 = True TRUE2__DOC = "true by default" configurator2 = ConfigModule(Config2) conf_file = tmpdir / "conf.conf" # false conf_file.write_text("some-other-bool=false", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert rest configurator2.set_conf(rest) assert not Config2.SOME_OTHER_BOOL assert Config2.TRUE2 # true conf_file.write_text("some-other-bool=true", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert rest configurator2.set_conf(rest) assert Config2.SOME_OTHER_BOOL assert Config2.TRUE2 # true conf_file.write_text("", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert not rest configurator2.set_conf(rest) assert not Config2.SOME_OTHER_BOOL assert Config2.TRUE2 conf_file.write_text("true2=true", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert rest configurator2.set_conf(rest) assert not Config2.SOME_OTHER_BOOL assert Config2.TRUE2 conf_file.write_text("true2=false", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert rest configurator2.set_conf(rest) assert not Config2.SOME_OTHER_BOOL assert not Config2.TRUE2 def test_true_by_default_file(tmpdir, tmp_path): conf_file = tmpdir / "conf.conf" class Config: TRUE = True TRUE__DOC = "true by default" configurator = ConfigModule(Config) configurator.parser.add_argument("-c", is_config_file=True) conf_file.write_text("", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert not rest assert Config.TRUE # TODO: fix these cases! # conf_file.write_text("true=true", "utf-8") # args, rest = configurator.set_conf(["-c", str(conf_file)]) # assert not rest # assert Config.TRUE # conf_file.write_text("true=false", "utf-8") # args, rest = configurator.set_conf(["-c", str(conf_file)]) # assert not rest # assert not Config.TRUE def test_false_by_default_file(tmpdir, tmp_path): conf_file = tmpdir / "conf.conf" class Config: FALSE = False FALSE__DOC = "true by default" configurator = ConfigModule(Config) configurator.parser.add_argument("-c", is_config_file=True) conf_file.write_text("", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert not rest assert not Config.FALSE conf_file.write_text("false=true", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert not rest assert Config.FALSE conf_file.write_text("false=false", "utf-8") args, rest = configurator.set_conf(["-c", str(conf_file)]) assert not rest assert not Config.FALSE def test_slidge_conf(): args, rest = main.get_parser().parse_known_args( [ "-c", str(Path(__file__).parent.parent / "dev" / "confs" / "slidge-example.ini"), "--legacy-module=slidge.plugins.dummy", "--jid=test.localhost", "--some-other", ] ) assert args.server == "localhost" assert args.admins == ["test@localhost"] assert args.secret == "secret" assert args.loglevel == logging.DEBUG assert len(rest) == 1 assert rest[0] == "--some-other" def test_set_conf(monkeypatch): monkeypatch.setenv("SLIDGE_USER_JID_VALIDATOR", "cloup") argv = [ "-c", str(Path(__file__).parent.parent / "dev" / "confs" / "slidge-example.ini"), "--legacy-module=slidge.plugins.dummy", "--jid=test.localhost", "--ignore-delay-threshold=200", ] main.get_configurator().set_conf(argv) assert config.SERVER == "localhost" assert config.ADMINS == ["test@localhost"] assert isinstance(config.ADMINS[0], JID) assert isinstance(config.JID, JID) assert config.SECRET == "secret" assert config.IGNORE_DELAY_THRESHOLD == 200 assert config.USER_JID_VALIDATOR == "cloup" def test_rest(tmp_path): class Config1: PROUT = "caca" PROUT__DOC = "?" class Config2: PROUT2: str | None = None PROUT2__DOC = "?" configurator = ConfigModule(Config1) configurator.parser.add_argument("-c", is_config_file=True) conf_path = tmp_path / "test.conf" conf_path.write_text("prout2=something") args, rest = configurator.set_conf(["-c", str(conf_path)]) configurator2 = ConfigModule(Config2) configurator2.set_conf(rest) assert Config1.PROUT == "caca" assert Config2.PROUT2 == "something" def test_unrecognized_after_boolean(): class Config: PROUT = True PROUT__DOC = "?" configurator = ConfigModule(Config) ns, rest = configurator.set_conf(["--prout", "--joujoujou"]) assert Config.PROUT assert rest == ["--joujoujou"] def test_custom_conf_dirs(monkeypatch, tmp_path_factory): dir1 = tmp_path_factory.mktemp("dir1") dir2 = tmp_path_factory.mktemp("dir2") (dir1 / "file1.ini").write_text("jid=some-jid\nlegacy-module=prout") (dir2 / "file2.ini").write_text("secret=some-secret") monkeypatch.setenv("SLIDGE_CONF_DIR", f"{dir1}:{dir2}") conf = main.get_configurator() args = conf.parser.parse_args([]) assert args.jid == "some-jid" assert args.secret == "some-secret" assert args.legacy_module == "prout" slidge/tests/test_db/000077500000000000000000000000001522535037600151235ustar00rootroot00000000000000slidge/tests/test_db/test_store.py000066400000000000000000000103031522535037600176650ustar00rootroot00000000000000import pytest import sqlalchemy as sa from slixmpp import JID from sqlalchemy import select from slidge import GatewayUser from slidge.db.meta import Base from slidge.db.models import Avatar, Contact, Participant, Room from slidge.db.store import SlidgeStore @pytest.fixture def slidge_store(tmp_path): engine = sa.create_engine("sqlite+pysqlite:///:memory:", echo=True) Base.metadata.create_all(engine) import slidge.core.config if not hasattr(slidge.core.config, "HOME_DIR"): slidge.core.config.HOME_DIR = tmp_path yield SlidgeStore(engine) def test_delete_avatar(slidge_store): with slidge_store.session() as orm: user = GatewayUser(jid=JID("x@x.com"), legacy_module_data={}) orm.add(user) orm.commit() avatar = Avatar( hash="hash", height=0, width=0, ) contact = Contact( jid=JID("xxx@xxx.com"), legacy_id="prout", user_account_id=user.id ) orm.add(contact) orm.commit() contact_pk = contact.id contact = slidge_store.contacts.get_by_pk(orm, contact_pk) contact.avatar = avatar orm.add(contact) orm.commit() with slidge_store.session() as orm: contact = slidge_store.contacts.get_by_pk(orm, contact_pk) assert contact.avatar is not None orm.delete(contact.avatar) orm.commit() with slidge_store.session() as orm: contact = slidge_store.contacts.get_by_pk(orm, contact_pk) assert contact.avatar is None def test_delete_contact_avatar_orphan(slidge_store): with slidge_store.session() as orm: user = GatewayUser(jid=JID("x@x.com"), legacy_module_data={}) orm.add(user) orm.commit() avatar = Avatar( hash="hash", height=0, width=0, ) contact = Contact( jid=JID("xxx@xxx.com"), legacy_id="prout", user_account_id=user.id, avatar=avatar, ) orm.add(contact) orm.commit() avatar_pk = avatar.id avatar = orm.get(Avatar, avatar_pk) assert avatar is not None orm.delete(contact) orm.commit() avatar = orm.get(Avatar, avatar_pk) assert avatar is None def test_unregister(slidge_store): with slidge_store.session() as orm: user = GatewayUser(jid=JID("x@x.com"), legacy_module_data={}) orm.add(user) contact = Contact(jid=JID("xxx@xxx.com"), legacy_id="prout", user=user) orm.add(contact) orm.commit() contact_pk = contact.id slidge_store.contacts.add_to_sent(orm, contact_pk, "an-id") orm.commit() orm.delete(user) orm.commit() def test_unregister_with_participants(slidge_store): with slidge_store.session() as orm: user = GatewayUser(jid=JID("x@x.com"), legacy_module_data={}) orm.add(user) orm.commit() contact = Contact(jid=JID("xxx@xxx.com"), legacy_id="prout", user=user) orm.add(contact) orm.commit() room = Room( user_account_id=user.id, legacy_id="legacy-room", jid=JID("legacy-room@something"), ) orm.add(room) orm.commit() contacts = orm.execute( select(Contact).where(Contact.user_account_id == user.id) ).scalars() assert len(list(contacts)) == 1 rooms = orm.execute( select(Room).where(Room.user_account_id == user.id) ).scalars() assert len(list(rooms)) == 1 participant = Participant( room_id=room.id, contact_id=contact.id, resource="whatever", nickname="whatever", nickname_no_illegal="whatever", occupant_id="uuid", ) orm.add(participant) orm.commit() orm.delete(user) orm.commit() rooms = orm.execute( select(Room).where(Room.user_account_id == user.id) ).scalars() assert len(list(rooms)) == 0 contacts = orm.execute( select(Contact).where(Contact.user_account_id == user.id) ).scalars() assert len(list(contacts)) == 0 slidge/tests/test_db/test_user.py000066400000000000000000000023461522535037600175170ustar00rootroot00000000000000import pytest from slixmpp import JID from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from slidge import GatewayUser from slidge.db.meta import Base from slidge.db.store import UserStore @pytest.fixture def engine(): engine = create_engine("sqlite+pysqlite:///:memory:", echo=True) Base.metadata.create_all(engine) return engine @pytest.fixture def store(engine): yield UserStore(sessionmaker(engine)) def test_user(engine, store: UserStore): with Session(engine) as orm: user1 = GatewayUser(jid=JID("test-user@test-host"), legacy_module_data={}) orm.add(user1) orm.commit() assert user1.jid == JID("test-user@test-host") user1.preferences = {"section": {"do_xxx": True}} store.update(user1) del user1 with Session(engine) as orm: user2 = orm.query(GatewayUser).filter_by(jid=JID("test-user@test-host")).one() assert user2.preferences == {"section": {"do_xxx": True}} user2.preferences["section"]["do_xxx"] = False store.update(user2) with Session(engine) as orm: user3 = orm.query(GatewayUser).filter_by(jid=JID("test-user@test-host")).one() assert not user3.preferences["section"]["do_xxx"] slidge/tests/test_feature_restriction.py000066400000000000000000000033031522535037600211670ustar00rootroot00000000000000import pytest from slidge.core.mixins.recipient import ReactionMixin @pytest.mark.asyncio async def test_no_restriction(): x = ReactionMixin() assert await x.restricted_emoji_extended_feature() is None @pytest.mark.asyncio async def test_single_reaction_any_emoji(): class X(ReactionMixin): REACTIONS_SINGLE_EMOJI = True x = X() form = await x.restricted_emoji_extended_feature() values = form.get_values() assert values["max_reactions_per_user"] == "1" assert values.get("allowlist") is None @pytest.mark.asyncio async def test_single_emoji(): class X(ReactionMixin): async def available_emojis(self, legacy_msg_id=None): return "♥" x = X() form = await x.restricted_emoji_extended_feature() values = form.get_values() assert values.get("max_reactions_per_user") is None assert values.get("allowlist") == ["♥"] @pytest.mark.asyncio async def test_two_emojis(): class X(ReactionMixin): async def available_emojis(self, legacy_msg_id=None): return "♥", "😛" x = X() form = await x.restricted_emoji_extended_feature() values = form.get_values() assert values.get("max_reactions_per_user") is None assert values.get("allowlist") == ["♥", "😛"] @pytest.mark.asyncio async def test_two_emojis_single_reaction(): class X(ReactionMixin): REACTIONS_SINGLE_EMOJI = True async def available_emojis(self, legacy_msg_id=None): return "♥", "😛" x = X() form = await x.restricted_emoji_extended_feature() values = form.get_values() assert values.get("max_reactions_per_user") == "1" assert values.get("allowlist") == ["♥", "😛"] slidge/tests/test_gateway_wide_reaction_restrictions.py000066400000000000000000000145761522535037600242720ustar00rootroot00000000000000from slixmpp import JID, Iq from slidge import BaseGateway, BaseSession, GatewayUser from slidge.contact import LegacyContact from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway): COMPONENT_NAME = "A test" class Contact(LegacyContact): REACTIONS_SINGLE_EMOJI = True async def on_react( self, legacy_msg_id: str, emojis: list[str], thread: str | None = None, ): pass class TestGatewayWideRestrictions(SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register( Iq(sfrom="romeo@montague.lit/gajim") ) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "logged in" in stanza["status"].lower(), stanza self.send( # language=XML """ """ ) @property def romeo_session(self) -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) def test_gateway_wide_restrictions(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ urn:xmpp:reactions:0:restrictions 1 domain """ ) def test_error_multi_emoji(self): # one of the other tests has a persisting side effect which is triggered only # when running the full suite. :( self.xmpp.plugin["xep_0356"].granted_privileges.clear() self.recv( # language=XML f""" 👋 🐢 """ ) self.send( # language=XML """ Maximum 1 emoji/message """ ) self.send( # language=XML """ 👋 🐢 Maximum 1 emoji/message """, use_values=False, ) slidge/tests/test_mam_archivable.py000066400000000000000000000026671522535037600200550ustar00rootroot00000000000000from slixmpp import Message, register_stanza_plugin from slixmpp.plugins.xep_0333.stanza import Displayed from slixmpp.plugins.xep_0334 import NoPermanentStore, NoStore, Store from slixmpp.plugins.xep_0424.stanza import Retract from slixmpp.test import SlixTest from slidge.group.archive import archivable class TestArchivable(SlixTest): def setUp(self): register_stanza_plugin(Message, Displayed) register_stanza_plugin(Message, Retract) register_stanza_plugin(Message, Store) register_stanza_plugin(Message, NoStore) register_stanza_plugin(Message, NoPermanentStore) def test_marker(self): msg = Message() assert not archivable(msg) msg.enable("displayed") assert archivable(msg) def test_retract(self): msg = Message() assert not archivable(msg) msg.enable("retract") assert archivable(msg) def test_hint_store(self): msg = Message() assert not archivable(msg) msg.enable("store") assert archivable(msg) def test_hint_no_store(self): msg = Message() msg["body"] = "boobobo" assert archivable(msg) msg.enable("no-store") assert not archivable(msg) def test_hint_no_permanent_store(self): msg = Message() msg["body"] = "boobobo" assert archivable(msg) msg.enable("no-permanent-store") assert not archivable(msg) slidge/tests/test_mam_correction.py000066400000000000000000000064101522535037600201120ustar00rootroot00000000000000from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyParticipant, LegacyRoster, ) from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True class Contact(LegacyContact): pass class Roster(LegacyRoster): async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if not legacy_id.startswith("room-"): raise XMPPError class Bookmarks(LegacyBookmarks): async def fill(self): pass class TestMamCorrection(SlidgeTest): plugin = globals() def setUp(self): super().setUp() self._add_slidge_user() def test_archive_correction_no_event_id(self) -> None: muc = self.get_joined_muc("room-correction") part: LegacyParticipant = self.run_coro(muc.get_participant("part")) part.send_text("a body", "a-legacy-id") self.next_sent() # part presence msg = self.next_sent() assert msg["body"] == "a body", msg assert msg["stanza_id"]["id"] == "a-legacy-id", msg part.correct("a-legacy-id", "a new body") msg = self.next_sent() assert msg["body"] == "a new body", msg correction_stanza_id = msg["stanza_id"]["id"] messages = list(muc.archive.get_all(before_id=correction_stanza_id)) assert len(messages) == 1 assert messages[0] def test_archive_correction_specify_event_id(self) -> None: muc = self.get_joined_muc("room-correction") part: LegacyParticipant = self.run_coro(muc.get_participant("part")) part.send_text("a body", "a-legacy-id") self.next_sent() # part presence msg = self.next_sent() assert msg["body"] == "a body", msg assert msg["stanza_id"]["id"] == "a-legacy-id", msg part.correct( "a-legacy-id", "a new body", correction_event_id="another-legacy-id" ) msg = self.next_sent() assert msg["body"] == "a new body", msg correction_stanza_id = msg["stanza_id"]["id"] assert correction_stanza_id == "another-legacy-id" messages = list(muc.archive.get_all(before_id=correction_stanza_id)) assert len(messages) == 1 assert messages[0] messages = list(muc.get_archived_messages(correction_stanza_id)) assert len(messages) == 1 def test_archive_correction_specify_existing_event_id(self) -> None: muc = self.get_joined_muc("room-correction") part: LegacyParticipant = self.run_coro(muc.get_participant("part")) part.send_text("a body", "a-legacy-id") self.next_sent() # part presence msg = self.next_sent() assert msg["body"] == "a body", msg assert msg["stanza_id"]["id"] == "a-legacy-id", msg part.correct("a-legacy-id", "a new body", correction_event_id="a-legacy-id") msg = self.next_sent() assert msg["body"] == "a new body", msg correction_stanza_id = msg["stanza_id"]["id"] assert correction_stanza_id == "a-legacy-id" messages = list(muc.archive.get_all(before_id=correction_stanza_id)) assert not messages slidge/tests/test_mds.py000066400000000000000000000160071522535037600156770ustar00rootroot00000000000000import unittest.mock from conftest import UUIDFixtureMixin from slixmpp import JID from slixmpp.plugins.xep_0356.permissions import IqPermission from test_muc import Base as BaseMUC class MDSMixin(UUIDFixtureMixin): def setUp(self): super().setUp() for domain in "test.com", "montague.lit": self.xmpp.plugin["xep_0356"].granted_privileges[domain].iq[ "http://jabber.org/protocol/pubsub" ] = IqPermission.BOTH self.xmpp.plugin["xep_0356"].granted_privileges[domain].iq[ "http://jabber.org/protocol/pubsub#owner" ] = IqPermission.BOTH class TestMDS(MDSMixin, BaseMUC): def test_add_to_whitelist(self): task = self.xmpp.loop.create_task( self.xmpp._BaseGateway__add_component_to_mds_whitelist(JID("test@test.com")) ) self.send( # language=XML """ """, use_values=False, ) self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) self.recv( # language=XML """ """ ) assert task.done() def test_receive_event(self): session = self.get_romeo_session() # juliet = self.juliet muc = self.get_private_muc() with unittest.mock.patch("test_muc.MUC.on_displayed") as on_displayed: self.recv( # language=XML f""" """ ) on_displayed.assert_awaited_once() assert on_displayed.call_args[0][0] == "1337" def test_send_mds(self): muc = self.get_private_muc() participant = self.run_coro(muc.get_user_participant()) participant.displayed("msg-id") self.send( # language=XML """ http://jabber.org/protocol/pubsub#publish-options true max never whitelist """, use_values=False, ) self.recv( # language=XML """ """ ) slidge/tests/test_muc.py000066400000000000000000005342241522535037600157060ustar00rootroot00000000000000import datetime import unittest.mock from base64 import b64encode from pathlib import Path import pytest import slixmpp from conftest import AvatarFixtureMixin, UUIDFixtureMixin from slixmpp import JID, Message, Presence from slixmpp.exceptions import XMPPError from slixmpp.plugins import xep_0082 import slidge.group.room from slidge import * # noqa: F403 from slidge import ( GatewayUser, LegacyBookmarks, LegacyContact, LegacyParticipant, LegacyRoster, ) from slidge.util.test import SlidgeTest from slidge.util.types import ( Hat, HoleBound, Mention, MessageReference, MucType, Reply, XMPPTextMessage, ) class Session(BaseSession): # noqa: F405 def __init__(self, user): super().__init__(user) self.bookmarks.user_nick = "thirdwitch" async def wait_for_ready(self, timeout=10): return async def login(self): pass async def logout(self): pass class Gateway(BaseGateway): # noqa: F405 COMPONENT_NAME = "SLIDGE TEST" GROUPS = True LEGACY_CONTACT_ID_TYPE = int jids = { "123": "juliet", "111": "firstwitch", "222": "secondwitch", "333": "not-in-roster", "666": "imposter", "667": "imposter2", "777": "offline-guy", "999": "weirdguy🎉", } legacy = {v: k for k, v in jids.items()} class Roster(LegacyRoster): def __init__(self, *a, **k): super().__init__(*a, **k) self.ready.set_result(True) async def jid_username_to_legacy_id(self, jid_username: str) -> int: try: return legacy[jid_username] except KeyError: raise XMPPError(text="Only juliet", condition="item-not-found") async def legacy_id_to_jid_username(self, legacy_id: int) -> str: if legacy_id == "999": return "weirdguy" try: return jids[legacy_id] except KeyError: raise XMPPError(text="Only juliet", condition="item-not-found") class Contact(LegacyContact): async def update_info(self): if self.legacy_id == "777": self.offline() if self.legacy_id in ("666", "667"): self.name = "firstwitch" return if self.legacy_id == "999": self.name = "weirdguy🎉" else: self.name = self.jid.user class Participant(LegacyParticipant): pass class MUC(slidge.LegacyMUC): session: Session def __init__(self, *a, **k): super().__init__(*a, **k) self.history = [] async def available_emojis(self, legacy_msg_id=None): if self.jid.user != "room-private-emoji-restricted": return return {"💘", "❤️", "💜"} async def backfill( self, after: HoleBound | None = None, before: HoleBound | None = None, ): for hour in range(10): sender = await self.get_participant(f"history-man-{hour}") sender.send_text( body=f"Body #{hour}", legacy_msg_id=f"{hour}", when=datetime.datetime(2000, 1, 1, hour, 0, 0, tzinfo=datetime.UTC), archive_only=True, ) async def fill_participants(self): if "private" in str(self.legacy_id): first = await self.get_participant_by_contact( await self.session.contacts.by_legacy_id("111") ) first.affiliation = "owner" first.role = "moderator" yield first second = await self.get_participant_by_contact( await self.session.contacts.by_legacy_id("222") ) second.affiliation = "admin" second.role = "moderator" yield second else: first = await self.get_participant("firstwitch") first.affiliation = "owner" first.role = "moderator" yield first second = await self.get_participant("secondwitch") second.affiliation = "admin" second.role = "moderator" yield second if "weird" in str(self.legacy_id): weird = await self.get_participant_by_legacy_id("999") weird.affiliation = "owner" weird.role = "moderator" yield weird yield await self.get_user_participant() async def update_info(self): if self.jid.user == "room-private": self.name = "Private Room" self.subject = "Private Subject" self.type = MucType.GROUP return if self.jid.user == "room-private-emoji-restricted": self.name = "Private Room" self.subject = "Private Subject" self.type = MucType.GROUP self.REACTIONS_SINGLE_EMOJI = True return if self.jid.user == "room-public": self.name = "Public Room" self.subject = "Public Subject\n\nOn several\nlines" self.type = MucType.CHANNEL return if self.jid.user == "coven": await self.set_avatar( Path(__file__).parent.parent / "dev" / "assets" / "5x5.png", ) self.name = "The coven" class Bookmarks(LegacyBookmarks): @staticmethod async def jid_local_part_to_legacy_id(local_part: str): if not local_part.startswith("room") and local_part not in ("coven", "weird"): raise XMPPError("item-not-found") else: return local_part async def by_jid(self, jid: JID): muc = await super().by_jid(jid) if not (x in jid.user for x in ["private", "public", "coven", "weird"]): raise XMPPError("item-not-found") return muc async def fill(self): await self.by_legacy_id("room-private-emoji-restricted") await self.by_legacy_id("room-private") await self.by_legacy_id("room-public") await self.by_legacy_id("coven") class Base(UUIDFixtureMixin, SlidgeTest): plugin = globals() def setUp(self): super().setUp() with self.xmpp.store.session(expire_on_commit=False) as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, ) orm.add(user) orm.commit() self.get_romeo_session().logged = True @staticmethod def get_romeo_session() -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) def get_private_muc(self, name="room-private", resources=()) -> MUC: muc = self.run_coro( self.get_romeo_session().bookmarks.by_jid( JID(f"{name}@aim.shakespeare.lit") ) ) muc.session.cancel_all_tasks() for resource in resources: muc.add_user_resource(resource) n = self.next_sent() if n: assert n["subject"] return muc def get_participant( self, nickname="firstwitch", room="room=private", resources=("gajim",), presence_sent=True, role: str | None = None, legacy_id: str | None = None, ) -> LegacyParticipant: muc = self.get_private_muc(resources=resources) if legacy_id: participant: LegacyParticipant = self.run_coro( muc.get_participant_by_legacy_id(legacy_id) ) else: participant: LegacyParticipant = self.run_coro( muc.get_participant(nickname) ) if role: participant.role = role if presence_sent: participant.stored.presence_sent = True return participant @pytest.mark.usefixtures("avatar") class TestMuc(Base): def test_disco_non_existing_room(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, use_values=False, ) def test_disco_group(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" Private Room http://jabber.org/protocol/muc#roominfo 100 0 Private Subject 1 0 1 anyone 0 0 """, ) def test_disco_room_avatar(self): self.next_sent() self.recv( # language=XML f""" """ ) self.send( # language=XML """ http://jabber.org/protocol/muc#roominfo The coven 1 0 100 0 e6f9170123620949a6821e25ea2861d22b0dff66 e6f9170123620949a6821e25ea2861d22b0dff66 0 moderators 1 0 """ ) def test_disco_group_emoji_restricted(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" Private Room http://jabber.org/protocol/muc#roominfo 100 0 Private Subject 1 0 1 anyone 0 0 urn:xmpp:reactions:0:restrictions 1 💘 ❤️ 💜 """, ) def test_disco_items(self): session = self.get_romeo_session() self.run_coro(session.bookmarks.fill()) self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_disco_channel(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" http://jabber.org/protocol/muc#roominfo 100 Public Room 0 1 Public Subject\n\nOn several\nlines 0 0 moderators 1 0 """, ) def test_disco_participant(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) def test_join_muc_no_nick(self): self.recv( # language=XM """ """ ) self.send( # language=XML """ """, use_values=False, # the error element does not appear for some reason ) def test_join_group(self): now = datetime.datetime.now(tz=datetime.UTC) muc = self.get_private_muc("room-private") participant = self.run_coro(muc.get_participant("stan")) participant.send_text("Hey", when=now, legacy_msg_id="hey-id") muc.subject_date = now self.recv( # language=XML """ """ ) presences = [self.next_sent() for _ in range(3)] expected = { "stan": ("member", "participant"), "firstwitch": ("owner", "moderator"), "secondwitch": ("admin", "moderator"), } for p in presences: assert p.get_to() == "romeo@montague.lit/gajim" assert p.get_from().bare == "room-private@aim.shakespeare.lit" nick = p.get_from().resource assert p["muc"]["item"]["affiliation"] == expected[nick][0] assert p["muc"]["item"]["role"] == expected[nick][1] self.send( # language=XML """ """, ) now_fmt = now.isoformat().replace("+00:00", "Z") self.send( # language=XML f""" Hey """, use_values=False, ) self.send( # language=XML f""" Private Subject """ ) # empty avatar assert self.next_sent()["from"] == "room-private@aim.shakespeare.lit" self.recv( # language=XML f""" """ ) self.send( # language=XML """ http://jabber.org/protocol/muc#roominfo 1 0 100 0 14 Private Subject Private Room 1 anyone 0 0 """ ) def test_join_channel(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """, ) self.send( # language=XML """ """, ) self.send( # language=XML """ """, ) def test_self_ping_disconnected(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) def test_resource_not_joined(self): self.get_romeo_session() self.recv( # language=XML """ am I here? """ ) self.send( # language=XML """ You are not connected to this chat """ ) self.send( # language=XML """ 0 """ ) assert self.next_sent() is None def test_self_ping_connected(self): self.get_private_muc(resources={"gajim"}) self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) # def test_origin_id(self): # """ # this test is broken because of slixtest magic, but the behavior is actually good # in real conditions # """ # muc = self.get_private_muc() # muc.user_resources.add("gajim") # self.recv( # """ # # body # # # """ # ) # self.send( # """ # # body # # # # """, # ) def test_msg_from_xmpp(self): muc = self.get_private_muc(resources={"gajim", "movim"}) # muc.user_resources = ["gajim", "movim"] with unittest.mock.patch( "test_muc.MUC.on_message", return_value="msg-id" ) as on_message: self.recv( # language=XML f""" BODY """ ) on_message.assert_awaited_once_with(XMPPTextMessage(body="BODY")) resources = muc.get_user_resources() tpl = f""" BODY """ # language=XML resources2 = set() for _ in range(2): msg = self.next_sent() resource = msg.get_to().resource self.check(msg, tpl.format(resource=resource), use_values=False) resources2.add(resource) assert resources == resources2 assert self.next_sent() is None def test_msg_reply_from_xmpp(self): muc = self.get_private_muc() muc.add_user_resource("gajim") fallback = "> Anna wrote:\n> We should bake a cake\n" stripped_body = "Great idea!" anna = self.run_coro(muc.get_participant("Anna")) with unittest.mock.patch( "test_muc.MUC.on_message", return_value="msg-id" ) as on_message: self.recv( # language=XML f""" {fallback}{stripped_body} """ ) on_message.assert_awaited_once_with( XMPPTextMessage( body=stripped_body, reply=Reply( msg_id="message-id1", fallback=fallback, to=anna, ), ) ) def test_msg_from_legacy(self): participant = self.get_participant() muc = participant.muc participant.send_text("the body", legacy_msg_id="XXX") self.send( # language=XML f""" the body """, use_values=False, ) def test_correct_from_legacy(self): participant = self.get_participant() participant.send_text("body", "1") msg = self.next_sent() assert msg["body"] == "body" assert msg["id"] == msg["stanza_id"]["id"] == "1" participant.correct("1", "new") msg = self.next_sent() assert msg["body"] == "new" assert msg["replace"]["id"] == "1" assert msg["id"] == "" assert msg["stanza_id"]["id"] != "" participant.correct("1", "newnew", correction_event_id="correction") msg = self.next_sent() assert msg["body"] == "newnew" assert msg["id"] == "correction" assert msg["stanza_id"]["id"] == "correction" assert msg["replace"]["id"] == "1" participant.correct("willbeconverted", "new content") msg = self.next_sent() assert msg["replace"]["id"] == "willbeconverted" assert msg["body"] == "new content" assert msg["id"] == "" assert msg["stanza_id"]["id"] != "" participant.correct( "willbeconverted", "new content", correction_event_id="correction_id", ) msg = self.next_sent() assert msg["replace"]["id"] == "willbeconverted" assert msg["body"] == "new content" assert msg["id"] == "correction_id" assert msg["stanza_id"]["id"] == "correction_id" def test_correct_from_xmpp(self): muc = self.get_private_muc() muc.add_user_resource("gajim") with unittest.mock.patch( "test_muc.MUC.on_message", return_value="id" ) as on_message: self.recv( # language=XML """ body """ ) on_message.assert_awaited_once() self.send( # language=XML """ body """, use_values=False, ) with unittest.mock.patch( "test_muc.MUC.on_message", return_value=None ) as on_message: self.recv( # language=XML """ new body """ ) self.send( # language=XML """ new body """, use_values=False, ) on_message.assert_awaited_once() assert on_message.call_args[0][0].replace == "id" def test_msg_reply_self_from_legacy(self): Session.SENT_TEXT = [] participant = self.get_participant() muc = participant.muc participant.send_text( "the body", legacy_msg_id="XXX", reply_to=MessageReference(legacy_id="REPLY-TO", author=participant), ) self.send( # language=XML f""" the body """, use_values=False, ) def test_msg_reply_to_user(self): Session.SENT_TEXT = [] participant = self.get_participant() muc = participant.muc participant.send_text( "the body", legacy_msg_id="XXX", reply_to=MessageReference(legacy_id="REPLY-TO", author="user"), ) self.send( # language=XML f""" the body """, use_values=False, ) def test_msg_reply_from_legacy(self): Session.SENT_TEXT = [] participant = self.get_participant() muc = participant.muc second_witch = self.get_participant("secondwitch") participant.send_text( "the body", legacy_msg_id="XXX", reply_to=MessageReference( author=second_witch, legacy_id="REPLY-TO", ), ) self.send( # language=XML f""" the body """, use_values=False, ) def test_msg_reply_from_legacy_fallback(self): Session.SENT_TEXT = [] participant = self.get_participant() muc = participant.muc second_witch = self.get_participant("secondwitch") participant.send_text( "the body", legacy_msg_id="XXX", reply_to=MessageReference( legacy_id="REPLY-TO", author=second_witch, body="Blabla" ), ) self.send( # language=XML f""" > secondwitch:\n> Blabla\nthe body """, use_values=False, ) def test_react_from_xmpp(self): muc = self.get_private_muc(resources=["movim"]) with unittest.mock.patch( "test_muc.MUC.on_react", return_value=None ) as on_react: self.recv( # language=XML f""" 👋 """ ) on_react.assert_awaited_once_with("SOME-ID", ["👋"], thread=None) for r in muc.get_user_resources(): self.send( # language=XML f""" 👋 """, use_values=False, ) def test_react_from_legacy(self): participant = self.get_participant() muc = participant.muc participant.react(legacy_msg_id="XXX", emojis="👋") self.send( # language=XML f""" 👋 """, use_values=False, ) def test_mam_bare_jid(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ No MAM on the component itself, use a JID with a resource """ ) self.recv( # language=XML """ """ ) self.send( # language=XML """ This is only handled for MUCs """ ) def test_mam_form_fields(self): self.get_private_muc() # muc.user_resources.add("gajim") self.recv( # language=XML """ """ ) self.send( # language=XML """ urn:xmpp:mam:2 """ ) def test_mam_all(self): self.recv( # language=XML """ """ ) for i in range(10): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 0 9 10 """ ) def test_mam_page_limit(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 2 """ ) for i in range(3, 5): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 3 4 2 """ ) def test_mam_page_after(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 2 5 """ ) for i in range(6, 8): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 6 7 2 """ ) def test_mam_page_after_last(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 70 9 """ ) self.send( # language=XML """ 0 """, use_values=False, ) def test_mam_page_after_not_found(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 2 12 """ ) self.send( # language=XML """ Message 12 not found """, use_values=False, ) def test_last_page(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 3 """ ) for i in range(7, 10): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 7 9 3 """ ) self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 3 7 """ ) for i in range(4, 7): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 4 6 3 """ ) def test_mam_flip(self): self.recv( # language=XML """ urn:xmpp:mam:2 2000-01-01T03:00:00Z 3 5 """ ) for i in range(9, 6, -1): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 9 7 3 """ ) def test_mam_flip_no_max(self): self.recv( # language=XML """ """ ) for i in range(9, -1, -1): self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 9 0 10 """ ) def test_mam_metadata(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_mam_metadata_empty(self): muc = self.get_private_muc() muc.stored.history_filled = True muc.commit() self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_mam_with(self): for i in range(10): self.recv( # language=XML f""" urn:xmpp:mam:2 room-private@aim.shakespeare.lit/history-man-{i} """ ) self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML f""" {i} {i} 1 """ ) def test_mam_specific_id(self): self.recv( # language=XML """ urn:xmpp:mam:2 2 4 """ ) for i in 2, 4: self.send( # language=XML f""" Body #{i} """ ) self.send( # language=XML """ 2 4 2 """ ) self.recv( # language=XML """ urn:xmpp:mam:2 2 14 """ ) self.send( # language=XML """ One of the requested messages IDs could not be found with the given constraints. """ ) def test_mam_from_user_carbon(self): muc = self.get_private_muc(resources=["gajim"]) now = datetime.datetime.now(tz=datetime.UTC) user_participant: Participant = self.run_coro(muc.get_user_participant()) subject_stanza = self.next_sent() if subject_stanza is not None: # inconsistent behaviour whether using the debugger or not assert subject_stanza["subject"] user_participant.send_text("blabla", "666", when=now) now_fmt = now.isoformat().replace("+00:00", "Z") self.send( # language=XML f""" blabla """, use_values=False, # necessary because the third has origin-id ) self.recv( # language=XML """ urn:xmpp:mam:2 1 """ ) self.send( # language=XML f""" blabla """ ) def test_mam_echo(self): muc = self.get_private_muc(resources=["gajim"]) with unittest.mock.patch( "test_muc.MUC.on_message", return_value="id" ) as on_message: self.recv( # language=XML """ HOY """ ) on_message.assert_awaited_once() self.send( # language=XML """ HOY """ ) archived = list(muc.archive.get_all())[-1] assert archived.id == "id" def test_get_members(self): self.get_private_muc() # muc.user_resources.add("gajim") self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_room_avatar(self): v = b64encode(self.avatar_path.read_bytes()).decode() self.run_coro(self.get_romeo_session().bookmarks.fill()) self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) self.recv( # language=XML """ """ ) self.send( # language=XML f""" image/png {v} """ ) def test_join_room_avatar(self): muc = self.get_private_muc("coven") self.recv( # language=XML """ """ ) with self.xmpp.store.session() as orm: for part in self.xmpp.store.participants.get_all(orm, muc.stored.id): if part.nickname.startswith("history-man"): continue pres = self.next_sent() assert isinstance(pres, Presence), part.nickname subject = self.next_sent() assert isinstance(subject, Message) self.send( # language=XML f""" {self.avatar_original_sha1} """, ) def test_send_to_bad_resource(self): muc = self.get_private_muc("coven") muc.add_user_resource("gajim") self.recv( # language=XML """ """ ) muc = self.run_coro( self.get_romeo_session().bookmarks.by_jid(JID("coven@aim.shakespeare.lit")) ) assert not muc.get_user_resources() self.recv( # language=XML """ """ ) muc = self.run_coro( self.get_romeo_session().bookmarks.by_jid(JID("coven@aim.shakespeare.lit")) ) assert not muc.get_user_resources() def test_recv_non_kickable_error(self): muc = self.get_private_muc("coven") muc.add_user_resource("gajim") self.recv( # language=XML """ """ ) assert muc.get_user_resources() == {"gajim"} muc.remove_user_resource("gajim") assert self.next_sent() is None def test_recv_error_non_existing_muc(self): self.recv( # language=XML """ """ ) assert self.next_sent() is None def test_moderate_by_room(self): muc = self.get_private_muc("room", ["gajim"]) p = muc.get_system_participant() p.moderate("666", "reason™") self.send( # language=XML """ reason™ """ ) def test_moderate_by_moderator(self): # muc = self.get_private_muc(resources=["gajim"]) p = self.get_participant(resources=["gajim"]) p.moderate("666", "reason™") self.send( # language=XML f""" reason™ """ ) def test_participant_avatar(self): self.test_join_group() v = b64encode(self.avatar_path.read_bytes()).decode() session = self.get_romeo_session() self.run_coro(session.bookmarks.fill()) muc = self.get_private_muc() muc.stored.participants_filled = True muc.commit() contact = self.run_coro(session.contacts.by_legacy_id("333")) contact.avatar = self.avatar_path self.run_coro(contact._set_avatar_task) self.run_coro(muc.get_participant_by_contact(contact)) pres = self.next_sent() assert pres["vcard_temp_update"]["photo"] == self.avatar_original_sha1 _aff_msg = self.next_sent() self.recv( # language=XML f""" """ ) self.send( # language=XML f""" {v} image/png """, use_values=False, ) def test_presence_propagation(self): participants_before = self.__get_participants() contact = participants_before[0].contact last_seen = datetime.datetime.now(tz=datetime.UTC) contact.is_friend = True contact.away(last_seen=last_seen, status="blabla") dt = xep_0082.format_datetime(last_seen) self.send( # language=XML f""" away blabla -- Last seen {last_seen:%A %H:%M %p GMT} """ ) self.send( # language=XML f""" away blabla -- Last seen {last_seen:%A %H:%M %p GMT} """ ) assert self.next_sent() is None def test_add_to_bookmarks(self): muc = self.get_private_muc() self.xmpp.plugin["xep_0356"].granted_privileges["montague.lit"].iq[ "http://jabber.org/protocol/pubsub" ] = "both" self.xmpp.loop.create_task(muc.add_to_bookmarks(auto_join=True, preserve=False)) import slixmpp.plugins.xep_0356.privilege o = slixmpp.plugins.xep_0356.privilege.uuid.uuid4 slixmpp.plugins.xep_0356.privilege.uuid.uuid4 = lambda: "0" self.send( # language=XML """ thirdwitch http://jabber.org/protocol/pubsub#publish-options 1 max never whitelist """, use_values=False, ) self.recv( # language=XML """ """ ) slixmpp.plugins.xep_0356.privilege.uuid.uuid4 = o def test_bookmark_extension_preservation(self): muc = self.get_private_muc() self.xmpp.plugin["xep_0356"].granted_privileges["montague.lit"].iq[ "http://jabber.org/protocol/pubsub" ] = "both" iq_counter = 0 def increment(): nonlocal iq_counter iq_counter += 1 return iq_counter - 1 patch = unittest.mock.patch( "slixmpp.plugins.xep_0356.privilege.uuid.uuid4", increment ) patch.start() base_stanza = ( # language=XML """ thirdwitch{extension} http://jabber.org/protocol/pubsub#publish-options 1 max never whitelist """ ) pinned_stanza = base_stanza.format( extension='' ) not_pinned_stanza = base_stanza.format(extension="") not_pinned_stanza_empty_extension = base_stanza.format( extension="" ) invite_stanza = ( # language=XML """ """ ) iq_confirmation_stanza = ( # language=XML """ """ ) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=False, pin=None) ) self.send(not_pinned_stanza.format(counter=0), use_values=False) self.recv(iq_confirmation_stanza.format(counter=0)) self.send(invite_stanza, use_values=False) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=False, pin=True) ) self.send(pinned_stanza.format(counter=1), use_values=False) self.recv(iq_confirmation_stanza.format(counter=1)) self.send(invite_stanza, use_values=False) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=False, pin=False) ) self.send(not_pinned_stanza_empty_extension.format(counter=2), use_values=False) self.recv(iq_confirmation_stanza.format(counter=2)) self.send(invite_stanza, use_values=False) request_bookmark_stanza = ( # language=XML """ """ ) bookmark_result_stanza = ( # language=XML """ thirdwitch{extension} """ ) extension_not_pinned = "blabla" extension_pinned = "blabla" extension_not_notify = extension_not_pinned extension_notify_never = "blabla" extension_notify_always = "blabla" extension_notify_always_mobile = "blabla" extension_notify_never_always_mobile = "blabla" c = 3 self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, pin=None) ) self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_not_pinned), use_values=False, ) assert self.next_sent() is None self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, pin=True) ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_not_pinned), use_values=False, ) c += 1 self.send( # language=XML base_stanza.format(extension=extension_pinned).format(counter=c), use_values=False, ) self.recv(iq_confirmation_stanza.format(counter=c)) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, pin=True) ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_pinned), use_values=False, ) assert self.next_sent() is None self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, pin=False) ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_pinned), use_values=False, ) c += 1 self.send( # language=XML base_stanza.format(extension=extension_not_pinned).format(counter=c), use_values=False, ) self.recv(iq_confirmation_stanza.format(counter=c)) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, pin=False) ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_not_pinned), use_values=False, ) assert self.next_sent() is None self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, notify="always") ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_not_notify), use_values=False, ) c += 1 self.send( # language=XML base_stanza.format(extension=extension_notify_always).format(counter=c), use_values=False, ) self.recv(iq_confirmation_stanza.format(counter=c)) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, notify="always") ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_notify_always), use_values=False, ) assert self.next_sent() is None self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, notify="never") ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format(counter=c, extension=extension_notify_always), use_values=False, ) c += 1 self.send( # language=XML base_stanza.format(extension=extension_notify_never).format(counter=c), use_values=False, ) self.recv(iq_confirmation_stanza.format(counter=c)) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=True, notify="never") ) c += 1 self.send(request_bookmark_stanza.format(counter=c), use_values=False) self.recv( # language=XML bookmark_result_stanza.format( counter=c, extension=extension_notify_always_mobile ), use_values=False, ) c += 1 self.send( # language=XML base_stanza.format(extension=extension_notify_never_always_mobile).format( counter=c ), use_values=False, ) self.recv(iq_confirmation_stanza.format(counter=c)) patch.stop() def test_bookmark_chat_notification_setting(self): muc = self.get_private_muc() self.xmpp.plugin["xep_0356"].granted_privileges["montague.lit"].iq[ "http://jabber.org/protocol/pubsub" ] = "both" iq_counter = 0 def increment(): nonlocal iq_counter iq_counter += 1 return iq_counter - 1 patch = unittest.mock.patch( "slixmpp.plugins.xep_0356.privilege.uuid.uuid4", increment ) patch.start() base_stanza = ( # language=XML """ thirdwitch{extension} http://jabber.org/protocol/pubsub#publish-options 1 max never whitelist """ ) never_stanza = base_stanza.format( extension="" ) always_stanza = base_stanza.format( extension="" ) invite_stanza = ( # language=XML """ """ ) iq_confirmation_stanza = ( # language=XML """ """ ) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=False, notify="never") ) self.send(never_stanza.format(counter=0), use_values=False) self.recv(iq_confirmation_stanza.format(counter=0)) self.send(invite_stanza, use_values=False) self.xmpp.loop.create_task( muc.add_to_bookmarks(auto_join=False, preserve=False, notify="always") ) self.send(always_stanza.format(counter=1), use_values=False) self.recv(iq_confirmation_stanza.format(counter=1)) self.send(invite_stanza, use_values=False) patch.stop() async def __fill_roster(self): async for _ in self.get_romeo_session().contacts.fill(): continue def __get_participants(self): muc = self.get_private_muc(resources=["movim"]) self.run_coro(self.__fill_roster()) participants_before: list[Participant] = list( self.run_coro(self.__participants_as_list(muc)) ) for p in participants_before: p._presence_sent = True p.commit() return participants_before async def __participants_as_list(self, muc: MUC): return [p async for p in muc.get_participants() if not p.is_user] def __test_rename_common(self, old_nick, participants_before): muc = self.get_private_muc() p = participants_before[0] self.send( # language=XML f""" """ ) self.send( # language=XML f""" """ ) muc = self.run_coro(muc.session.bookmarks.by_legacy_id(muc.legacy_id)) participants_after = self.run_coro(self.__participants_as_list(muc)) assert len(participants_after) == len(participants_before) nicks = [p.nickname for p in participants_after] assert old_nick not in nicks assert "new-nick" in nicks assert self.next_sent() is None def test_rename_participant_from_participant(self): participants_before = self.__get_participants() p = participants_before[0] old_nick = p.nickname p.nickname = "new-nick" self.__test_rename_common(old_nick, participants_before) def test_rename_participant_from_muc(self): participants_before = self.__get_participants() p = participants_before[0] old_nick = p.nickname p.muc.rename_participant(old_nick, "new-nick") self.__test_rename_common(old_nick, participants_before) def test_rename_from_contact(self): participants_before = self.__get_participants() p = participants_before[0] old_nick = p.nickname p.contact.name = "new-nick" self.__test_rename_common(old_nick, participants_before) def test_rename_from_contact_with_forbidden_char(self): participants_before = self.__get_participants() p = participants_before[0] old_nick = p.nickname p.contact.name = "a forbidden emoji 🎉" self.send( # language=XML f""" a forbidden emoji 🎉 """ ) self.send( # language=XML f""" a forbidden emoji 🎉 """ ) muc = self.get_private_muc() participants_after = self.run_coro(self.__participants_as_list(muc)) assert len(participants_after) == len(participants_before) assert self.next_sent() is None def test_non_anonymous_participants_with_same_nickname(self): muc = self.get_private_muc(resources=["movim"]) participants = self.__get_participants() for p in participants: if p.contact.name == "firstwitch": real_witch = p break else: raise AssertionError assert real_witch.jid == self.run_coro(muc.get_participant("firstwitch")).jid p = self.run_coro(muc.get_participant_by_legacy_id("666")) assert real_witch.jid == self.run_coro(muc.get_participant("firstwitch")).jid p.send_text("Je suis un canaillou") self.send( # language=XML """ """ ) self.send( # language=XML """ Je suis un canaillou """ ) assert self.next_sent() is None assert real_witch.jid == self.run_coro(muc.get_participant("firstwitch")).jid p = self.run_coro(muc.get_participant_by_legacy_id("667")) assert real_witch.jid == self.run_coro(muc.get_participant("firstwitch")).jid p.send_text("Je suis un canaillou") self.send( # language=XML """ """ ) self.send( # language=XML """ Je suis un canaillou """ ) assert self.next_sent() is None def test_illegal_nickname(self): self.recv( # language=XML """ """ ) presences = [self.next_sent() for _ in range(4)] assert all(isinstance(p, Presence) for p in presences) assert set(p.get_from().resource for p in presences) == { "firstwitch", "secondwitch", "thirdwitch", "weirdguy-9u25g", } for p in presences: if p.get_from().resource == "weirdguy-9u25g": assert p["nick"].xml.text == "weirdguy🎉" else: assert "nick" not in p def test_illegal_nickname_quoted_fallback(self): op = self.get_participant("weirdguy🎉") replier = self.get_participant() replier.send_text( "reply", reply_to=MessageReference("some-id", op, "quoted text"), ) self.send( # language=XML """ > weirdguy🎉:\n> quoted text\nreply """ ) def test_newline_in_nick(self): part = self.get_participant("prout\nprout") part.send_text("hoy!") self.send( # language=XML """ hoy! """ ) def test_group_rename(self): group = self.get_private_muc(resources=("gajim",)) group.name = "prout" self.send( # language=XML """ """, use_values=False, ) assert self.next_sent() is None class TestRoleAffiliation(Base): def setUp(self): super().setUp() muc = self.get_private_muc() muc.stored.participants_filled = True muc.commit() def test_role_change_participant_online(self): part = self.get_participant("a-new-one") part.online() self.send( # language=XML """ 0 """ ) part.role = "visitor" self.send( # language=XML """ """ ) assert self.next_sent() is None part.role = "visitor" assert self.next_sent() is None def test_affiliation_change_participant_offline(self): part = self.get_participant(legacy_id="777", presence_sent=False) self.send( # language=XML """ """ ) part.affiliation = "admin" self.send( # language=XML """ """ ) assert self.next_sent() is None part.affiliation = "admin" assert self.next_sent() is None def test_affiliation_change_anonymous_participant(self): self.get_participant("a-newer-one", presence_sent=False) assert self.next_sent() is None @pytest.mark.usefixtures("avatar") class TestSetAvatar(Base, AvatarFixtureMixin): def test_set_avatar(self): self.get_private_muc(resources=("gajim",)) with unittest.mock.patch( "slidge.group.room.LegacyMUC.on_avatar", return_value=1 ): self.recv( # language=XML f""" image/png {b64encode(self.avatar_bytes).decode("utf-8")} """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) self.send( # language=XML f""" {self.avatar_sha1} """ ) assert self.next_sent() is None @pytest.mark.usefixtures("avatar") class TestUserAvatar(Base, AvatarFixtureMixin): def setUp(self): super().setUp() session = self.get_romeo_session() with self.xmpp.store.session(expire_on_commit=False) as orm: user = session.user user.avatar_hash = self.avatar_sha1 orm.add(user) orm.commit() muc = self.get_private_muc(name="room-user-avatar-test", resources=("gajim",)) self.user_participant = self.run_coro(muc.get_user_participant()) def test_user_avatar(self): self.user_participant.send_initial_presence("romeo@montague.lit/gajim") self.send( # language=XML f""" {self.avatar_sha1} """ ) def test_fetch_user_avatar(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) self.recv( # language=XML f""" {self.avatar_base64} """ ) self.send( # language=XML f""" {self.avatar_base64} image/test """, use_values=False, ) assert self.next_sent() is None class TestMUCAdmin(Base): def setUp(self): super().setUp() self.muc = muc = self.get_private_muc( name="room-moderation-test", resources=("gajim",) ) self.user_participant = self.run_coro(muc.get_user_participant()) self.user_participant._presence_sent = True self.user_participant.commit() self.user_jid = self.get_romeo_session().user_jid def test_moderation_not_implemented(self): self.recv( # language=XML f""" This message contains inappropriate content for this forum """ ) self.send( # language=XML """ This message contains inappropriate content for this forum on_user_moderation is not implemented by the legacy module """ ) def test_moderation_success(self): with unittest.mock.patch("slidge.group.LegacyMUC.on_moderate") as on_moderate: self.recv( # language=XML f""" REASON """ ) on_moderate.assert_awaited_once_with("stanza-id-1", "REASON") self.send( # language=XML """ """ ) def test_set_member(self): with unittest.mock.patch( "slidge.group.LegacyParticipant.on_set_affiliation" ) as on_set_affiliation: self.recv( # language=XML f""" A reason """ ) on_set_affiliation.assert_awaited_once() affiliation, reason, nick = on_set_affiliation.call_args[0] assert affiliation == "member" assert reason == "A reason" assert nick == "a-nick" self.send( # language=XML """ """ ) def test_get_owner_form(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" Slidge room configuration Complete this form to modify the configuration of your room. http://jabber.org/protocol/muc#roomconfig """ ) def test_set_description(self): with unittest.mock.patch("slidge.LegacyMUC.on_set_config") as on_set_config: self.recv( # language=XML f""" Slidge room configuration Complete this form to modify the configuration of your room. http://jabber.org/protocol/muc#roomconfig A new name A new description """ ) on_set_config.assert_awaited_once_with( name="A new name", description="A new description", ) self.send( # language=XML """ Slidge room configuration Complete this form to modify the configuration of your room. http://jabber.org/protocol/muc#roomconfig A new name A new description """ ) def test_destruct(self): with unittest.mock.patch( "slidge.LegacyMUC.on_destroy_request" ) as on_destroy_request: self.recv( # language=XML f""" Macbeth doth come. """ ) on_destroy_request.assert_awaited_once_with("Macbeth doth come.") self.send( # language=XML """ Macbeth doth come. """, ) self.send( # language=XML f""" """ ) def test_subject(self): with unittest.mock.patch("slidge.LegacyMUC.on_set_subject") as on_set_subject: self.recv( # language=XML f""" Fire Burn and Cauldron Bubble! """ ) on_set_subject.assert_awaited_once_with("Fire Burn and Cauldron Bubble!") assert self.next_sent() is None def test_kick(self): part = self.run_coro(self.muc.get_participant_by_legacy_id("111")) self.next_sent() assert part.contact with unittest.mock.patch("slidge.group.LegacyParticipant.on_kick") as on_kick: self.recv( # language=XML f""" kick-reason """ ) on_kick.assert_awaited_once() reason = on_kick.call_args[0][0] assert reason == "kick-reason" self.send( # language=XML """ """ ) class TestJoinAway(Base): def setUp(self): super().setUp() self.muc = muc = self.get_private_muc( name="room-moderation-test", resources=("gajim",) ) self.user_participant = self.run_coro(muc.get_user_participant()) self.user_jid = self.get_romeo_session().user_jid self.juliet = self.run_coro( self.get_romeo_session().contacts.by_legacy_id("123") ) def get_juliet_participant(self): return self.run_coro(self.muc.get_participant_by_contact(self.juliet)) def test_online_contact_joins(self): self.juliet.online() assert self.next_sent() is None muc = self.muc muc.stored.participants_filled = True muc.commit() self.get_juliet_participant() self.send( # language=XML """ """ ) _aff_msg = self.next_sent() self.muc.remove_participant(self.get_juliet_participant()) _aff_msg = self.next_sent() self.send( # language=XML """ """ ) assert self.next_sent() is None def test_away_contact_joins(self): self.juliet.away() assert self.next_sent() is None muc = self.muc self.muc.stored.participants_filled = True muc.commit() self.run_coro(self.muc.get_participant_by_contact(self.juliet)) self.send( # language=XML """ away """ ) _aff_msg = self.next_sent() assert self.next_sent() is None self.muc.remove_participant(self.get_juliet_participant()) self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) assert self.next_sent() is None class TestMentions(Base): def test_mentions(self): muc = self.get_private_muc("weird", ("gajim",)) session = self.get_romeo_session() with unittest.mock.patch( "test_muc.MUC.on_message", return_value=None ) as on_message: self.recv( # language=XML f""" I am {muc.user_nick} I want weirdguy🎉 to kiss me """ ) on_message.assert_awaited_once_with( XMPPTextMessage( body=f"I am {muc.user_nick} I want weirdguy🎉 to kiss me", mentions=( Mention( contact=self.run_coro( muc.get_participant("weirdguy🎉") ).contact, start=23, end=32, ), ), ) ) class TestHats(Base): def test_hats(self): muc = self.get_private_muc("room-private", ("gajim",)) participant = self.run_coro(muc.get_participant("i-wear-hats")) participant.send_last_presence(force=True, no_cache_online=True) self.send( # language=XML """ """ ) muc.stored.participants_filled = True muc.commit() participant = self.run_coro(muc.get_participant("i-wear-hats")) participant.set_hats([Hat("uri1", "title1"), Hat("uri2", "title2")]) self.send( # language=XML """ """ ) participant = self.run_coro(muc.get_participant("i-wear-hats")) participant.set_hats([Hat("uri1", "title1"), Hat("uri3", "title3")]) self.send( # language=XML """ """ ) class TestNickChange(Base): def test_user_nick_change(self): muc = self.get_private_muc("room-private", ("gajim",)) self.recv( # language=XML f""" """ ) self.send( # language=XML f""" Slidge does not let you change your nickname in groups. """ ) assert self.next_sent() is None def test_user_gets_away(self): muc = self.get_private_muc("room-private", ("gajim",)) self.recv( # language=XML f""" """ ) assert self.next_sent() is None class TestMUCRegistration(Base): def test_request_registration_form_unknown_muc(self): session = self.get_romeo_session() self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_request_registration_form_known_muc(self): muc = self.get_private_muc("room-private", ("gajim",)) self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_request_remove_known_muc(self): muc = self.get_private_muc("room-private", ("gajim",)) with unittest.mock.patch("slidge.group.LegacyMUC.on_leave") as olg: self.recv( # language=XML f""" """ ) olg.assert_awaited_once_with() self.send( # language=XML f""" """ ) self.send( # language=XML """ You left this chat from an XMPP client. 0 """ ) self.recv( # language=XML f""" """, ) self.send( # language=XML """ on_ibr_remove is not implemented by the legacy module """ ) slidge/tests/test_muc_all_info_filled_on_startup.py000066400000000000000000000344711522535037600233450ustar00rootroot00000000000000import pytest from conftest import AvatarFixtureMixin, UUIDFixtureMixin from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from slidge import BaseGateway, BaseSession, GatewayUser from slidge.contact import LegacyContact from slidge.group import LegacyBookmarks, LegacyMUC from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" GROUPS = True class Contact(LegacyContact): async def update_info(self): if self.legacy_id.startswith("room"): raise XMPPError if self.legacy_id == "no-nick": self.online() return self.name = "duplicate" class MUC(LegacyMUC): async def update_participants(self): if self.legacy_id == "room-occupant-id": await self.get_participant("nick", occupant_id="unique-id-1") await self.get_participant("nick", occupant_id="unique-id-2") elif self.legacy_id == "room-user-nick-taken": await self.get_participant("user", occupant_id="unique-id-1") await self.get_user_participant(occupant_id="user-id") else: await self.get_participant_by_legacy_id("dup1") dup = await self.get_participant(occupant_id="unique-id") dup.nickname = "duplicate" class Bookmarks(LegacyBookmarks): ROOMS = ["room", "room-occupant-id", "room-user-nick-taken"] async def fill(self): for legacy_id in Bookmarks.ROOMS: muc = await self.by_legacy_id(legacy_id) await muc.update_participants() @pytest.mark.usefixtures("avatar") class TestMUCAllInfoOnStartup(UUIDFixtureMixin, AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("user@test").bare, legacy_module_data={"username": "myname", "city": ""}, preferences={"sync_avatar": False, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register(Iq(sfrom="user@test")) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza @property def user_session(self) -> Session: return BaseSession.get_self_or_unique_subclass().from_jid(JID("user@test")) def test_join(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) self.send( # language=XML """ """, use_values=False, ) self.recv( # language=XML """ """ ) self.send( # language=XML """ http://jabber.org/protocol/muc#roominfo 1 0 100 0 3 0 moderators 1 0 """ ) def test_join_occupant_id(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) def test_updating_info(self): contact = self.run_coro(self.user_session.contacts.by_legacy_id("no-nick")) with contact.updating_info(): contact.name = "prout" assert contact.name == "prout" contact = self.run_coro(self.user_session.contacts.by_legacy_id("no-nick")) assert contact.name == "prout" class TestMUCUserNickIsTaken(UUIDFixtureMixin, AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("user@test").bare, legacy_module_data={"username": "myname", "city": ""}, preferences={"sync_avatar": False, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register(Iq(sfrom="user@test")) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza def test_user_nick_is_taken(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) assert self.next_sent() is not None # subject assert self.next_sent() is not None # room avatar slidge/tests/test_muc_subject.py000066400000000000000000000171611522535037600174210ustar00rootroot00000000000000import unittest import pytest from conftest import AvatarFixtureMixin from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, GatewayUser, LegacyBookmarks, LegacyContact, LegacyMUC, ) from slidge.util.test import SlidgeTest from slidge.util.types import MucType class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" GROUPS = True class Contact(LegacyContact): async def update_info(self): if self.legacy_id.startswith("group"): raise XMPPError() class Bookmarks(LegacyBookmarks): async def fill(self) -> None: pass class MUC(LegacyMUC): async def update_info(self): if not self.legacy_id.startswith("group"): raise XMPPError() self.type = MucType.GROUP @pytest.mark.usefixtures("avatar") class TestMUCSubject(AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register( Iq(sfrom="romeo@montague.lit/gajim") ) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza self.send( # language=XML """ """ ) @property def romeo_session(self) -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) def test_empty_subject(self): muc = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("group")) with unittest.mock.patch("uuid.uuid4", return_value="uuid"): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, use_values=False, ) self.send( # language=XML """ """, use_values=False, ) def test_set_thread_subject(self): muc: MUC = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("group")) muc.add_user_resource("movim") with unittest.mock.patch("uuid.uuid4", return_value="uuid"): juliet_participant = self.run_coro(muc.get_participant("juliet")) juliet_participant.set_thread_subject("legacy-thread-id", "some-subject") self.send( # language=XML """ legacy-thread-id some-subject """ ) def test_user_set_thread_subject(self): muc: MUC = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("group")) muc.add_user_resource("movim") with ( unittest.mock.patch( "slidge.group.room.LegacyMUC.on_set_subject" ) as on_set_subject, unittest.mock.patch( "slidge.group.room.LegacyMUC.on_set_thread_subject" ) as on_set_thread_subject, ): self.recv( # language=XML """ thread-id some-subject """ ) on_set_subject.assert_not_called() on_set_thread_subject.assert_awaited_once() assert on_set_thread_subject.call_args[0] == ("thread-id", "some-subject") with ( unittest.mock.patch( "slidge.group.room.LegacyMUC.on_set_subject" ) as on_set_subject, unittest.mock.patch( "slidge.group.room.LegacyMUC.on_set_thread_subject" ) as on_set_thread_subject, ): self.recv( # language=XML """ thread-id some-subject some-body """ ) on_set_subject.assert_not_called() on_set_thread_subject.assert_not_called() slidge/tests/test_occupant_id.py000066400000000000000000000033111522535037600173760ustar00rootroot00000000000000from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyParticipant, LegacyRoster, ) from slidge.group import LegacyMUC from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True class Roster(LegacyRoster[LegacyContact]): async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if legacy_id.startswith("room-"): raise XMPPError("item-not-found", "This is a MUC JID") return legacy_id class Bookmarks(LegacyBookmarks[LegacyMUC[LegacyParticipant]]): async def fill(self) -> None: pass class TestOccupantId(SlidgeTest): plugin = globals() def setUp(self) -> None: super().setUp() self._add_slidge_user() def test_update_contact_in_previously_anonymous_participant(self) -> None: muc = self.get_joined_muc("room-occupant") self.run_coro(muc.get_participant(occupant_id="occupant-id")) self.run_coro( muc.get_participant_by_legacy_id("user-id", occupant_id="occupant-id") ) by_occupant_id: LegacyParticipant = self.run_coro( muc.get_participant(occupant_id="occupant-id") ) by_legacy_id: LegacyParticipant = self.run_coro( muc.get_participant_by_legacy_id("user-id") ) assert by_occupant_id.occupant_id == "occupant-id" assert by_legacy_id.occupant_id == "occupant-id" assert by_occupant_id.contact.legacy_id == "user-id" assert by_legacy_id.contact.legacy_id == "user-id" slidge/tests/test_resourceprep.py000066400000000000000000000020341522535037600176250ustar00rootroot00000000000000from unittest import mock import pytest from slixmpp import JID from slidge.db.models import Participant from slidge.group import LegacyParticipant @pytest.fixture def muc(): muc = mock.MagicMock() muc.jid = JID("room@component") return muc def test_unassigned_code_points(muc): part = LegacyParticipant(muc, Participant(nickname="fiesta! 🎉")) assert "🎉" not in part.jid.resource def test_control_chars(muc): part = LegacyParticipant( muc, Participant(nickname="leet hackk\ber and I have control chars in my nick") ) assert "\b" not in part.jid.resource def test_control_chars_and_unassigned_code_points(muc): part = LegacyParticipant( muc, Participant( nickname="I'm a leet hackk\ber" + "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉" * 10 + ", I have control chars, emojis in my nick and a ridiculously long nickname" ), ) assert "\b" not in part.jid.resource assert "🎉" not in part.jid.resource slidge/tests/test_session.py000066400000000000000000001015441522535037600166000ustar00rootroot00000000000000import unittest.mock import pytest from conftest import AvatarFixtureMixin from slixmpp import JID, Message, register_stanza_plugin from slixmpp import __version__ as slix_version from slixmpp.plugins.xep_0060.stanza import EventItem from slixmpp.plugins.xep_0084 import MetaData from slidge import BaseGateway, BaseSession from slidge.util.test import SlidgeTest from slidge.util.types import ( LegacyAttachment, LinkPreview, XMPPAttachment, XMPPAttachmentMessage, XMPPTextMessage, ) class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" @pytest.mark.usefixtures("avatar") class TestSession(AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() self.setup_logged_session() self.xmpp.plugin["xep_0060"].map_node_event( MetaData.namespace, "avatar_metadata" ) register_stanza_plugin(EventItem, MetaData) @staticmethod def get_romeo_session() -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) def test_gateway_receives_presence_probe(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" YUP chat """ ) assert self.next_sent() is None def test_avatar(self): with unittest.mock.patch("slidge.BaseSession.on_avatar") as on_avatar: self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) self.recv( # language=XML f""" {self.avatar_base64} """ ) on_avatar.assert_awaited_with( self.avatar_bytes, self.avatar_sha1, "image/png", 5, 5 ) self.recv( # language=XML f""" """ ) def test_avatar_unpublish(self): with unittest.mock.patch("slidge.BaseSession.on_avatar") as on_avatar: self.recv( # language=XML f""" """ ) on_avatar.assert_awaited_with(None, None, None, None, None) def test_user_send_invitation_to_standard_muc(self): self.recv( # language=XML f""" """ ) msg = self.next_sent() assert msg["type"] == "error" assert msg["error"]["condition"] == "bad-request" def test_user_send_invitation(self): with unittest.mock.patch( "slidge.group.LegacyParticipant.on_invitation" ) as on_invitation: self.recv( # language=XML f""" """ ) on_invitation.assert_awaited_once_with( "Hey Hecate, this is the place for all good witches!" ) def test_link_preview(self): with unittest.mock.patch( "slidge.contact.LegacyContact.on_message" ) as on_message: self.recv( # language=XML f""" I wanted to mention https://the.link.example.com/what-was-linked-to Page Title Page Description Canonical URL https://link.to.example.com/image.png Some Website """ ) on_message.assert_awaited_once_with( XMPPTextMessage( body="I wanted to mention https://the.link.example.com/what-was-linked-to", link_previews=( LinkPreview( about="https://the.link.example.com/what-was-linked-to", title="Page Title", description="Page Description", url="Canonical URL", image="https://link.to.example.com/image.png", type=None, site_name="Some Website", ), ), ) ) def test_link_preview_base64_image(self): with unittest.mock.patch( "slidge.contact.LegacyContact.on_message" ) as on_message: self.recv( # language=XML f""" I wanted to mention https://the.link.example.com/what-was-linked-to data:image/jpeg;base64,{self.avatar_base64}] """ ) on_message.assert_awaited_once() args, _kwargs = on_message.call_args assert len(args) == 1 message = args[0] assert isinstance(message, XMPPTextMessage) img = message.link_previews[0].image assert img == self.avatar_bytes def test_juliet_sends_link_preview(self): self.juliet.send_text( "I wanted to mention https://the.link.example.com/what-was-linked-to", link_previews=[ LinkPreview( about="https://the.link.example.com/what-was-linked-to", title="Page Title", description="Page Description", url="Canonical URL", image="https://link.to.example.com/image.png", type=None, site_name="Some Website", ) ], ) # rdf:about="https://the.link.example.com/what-was-linked-to" # slixtext issue about namespaces here prevents us from using the XML as string msg = self.next_sent() assert ( msg["link_metadata"]["about"] == "https://the.link.example.com/what-was-linked-to" ) assert msg["link_metadata"]["title"] == "Page Title" assert msg["link_metadata"]["description"] == "Page Description" assert msg["link_metadata"]["url"] == "Canonical URL" assert msg["link_metadata"]["image"] == "https://link.to.example.com/image.png" assert msg["link_metadata"]["site_name"] == "Some Website" def test_juliet_sends_empty_link_preview(self): self.juliet.send_text( "I wanted to mention https://the.link.example.com/what-was-linked-to", link_previews=[ LinkPreview( about="", title="", description="", url=None, image=None, type=None, site_name="", ) ], ) msg = self.next_sent() assert msg assert "link_metadata" not in msg def test_juliet_sends_link_preview_image_as_bytes(self): self.juliet.send_text( "I wanted to mention https://the.link.example.com/what-was-linked-to", link_previews=[ LinkPreview( about="https://the.link.example.com/what-was-linked-to", title="Page Title", description="Page Description", url="Canonical URL", image=self.avatar_bytes, type=None, site_name="Some Website", ) ], ) msg = self.next_sent() assert ( msg["link_metadata"]["image"] == "data:image/jpeg;base64," + self.avatar_jpeg_base64 ) def test_mark_all_messages(self): self.xmpp.MARK_ALL_MESSAGES = True self.juliet.send_text("whatever", "msg_00") self.juliet.send_text("whatever", "msg_01") self.juliet.send_text("whatever", "msg_02") with unittest.mock.patch( "slidge.contact.LegacyContact.on_displayed" ) as on_displayed: self.recv( # language=XML f""" """ ) assert on_displayed.await_count == 3 for i in range(3): assert on_displayed.call_args_list[i][0][0] == f"msg_0{i}" def test_mark_all_messages_duplicate(self): self.xmpp.MARK_ALL_MESSAGES = True self.juliet.send_text("whatever", "msg_00") self.send( # language=XML """ whatever """, use_values=False, ) self.juliet.send_text("whatever", "msg_00") self.send( # language=XML """ whatever """, use_values=False, ) def test_sticker_oob(self) -> None: with unittest.mock.patch( "slidge.contact.LegacyContact.on_sticker" ) as on_sticker: self.recv( # language=XML f""" some fallback {self.avatar_url} """ ) on_sticker.assert_awaited_once() args, _ = on_sticker.call_args sticker = args[0] assert sticker.hashes["sha_256"] == self.avatar_sha256 def test_sticker_sfs(self) -> None: with unittest.mock.patch( "slidge.contact.LegacyContact.on_sticker" ) as on_sticker: self.recv( # language=XML f""" Un autocollant a été envoyé via Movim image/png 😘 {len(self.avatar_bytes)} 5x5 {self.avatar_sha256} """ ) on_sticker.assert_awaited_once() args, _ = on_sticker.call_args sticker = args[0] assert sticker.hashes["sha_256"] == self.avatar_sha256 def test_movim_sticker(self): sticker_stanza = f""" Un autocollant a été envoyé via Movim

Sticker

""" self.recv(sticker_stanza) self.send( # language=XML f""" """ ) with unittest.mock.patch( "slidge.contact.LegacyContact.on_sticker", return_value="xxx" ) as on_sticker: self.recv( # language=XML f""" iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9YGARc5KB0XV+IAAAAddEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIFRoZSBHSU1Q72QlbgAAAF1JREFUGNO9zL0NglAAxPEfdLTs4BZM4DIO4C7OwQg2JoQ9LE1exdlYvBBeZ7jqch9//q1uH4TLzw4d6+ErXMMcXuHWxId3KOETnnXXV6MJpcq2MLaI97CER3N0vr4MkhoXe0rZigAAAABJRU5ErkJggg== """ ) on_sticker.assert_awaited_once() args, _kwargs = on_sticker.call_args sticker = args[0] assert sticker.hashes["sha_1"] == "4b97ce7f0f06a0e05999f3c719cd5b4f3da992a7" assert sticker.path.exists() assert sticker.content_type == "image/png" # this time slidge must have cached the BoBd ata, so no bob-fetching IQ with unittest.mock.patch( "slidge.contact.LegacyContact.on_sticker", return_value="yyy" ) as on_sticker: self.recv(sticker_stanza) on_sticker.assert_awaited_once() args, _kwargs = on_sticker.call_args sticker = args[0] assert sticker.hashes["sha_1"] == "4b97ce7f0f06a0e05999f3c719cd5b4f3da992a7" assert sticker.path.exists() assert self.next_sent() is None def test_movim_custom_emoji(self): with unittest.mock.patch( "slidge.contact.LegacyContact.on_message" ) as on_message: self.recv( # language=XML f""" fdsf :amogus:

fdsf :amogus:

""" ) on_message.assert_awaited_once_with(XMPPTextMessage(body="fdsf :amogus:")) def test_bob_not_found(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ Bits of binary 'bogus' is not available """ ) def test_carbon_retract(self): with ( unittest.mock.patch( "slidge.contact.LegacyContact.on_retract" ) as on_retract, unittest.mock.patch( "slidge.contact.LegacyContact.on_message" ) as on_message, ): self.juliet.retract("some-id", carbon=True) self.recv( # language=XML f""" /me retracted the message 1269564719166132224 """ ) on_message.assert_not_awaited() on_retract.assert_not_awaited() def test_new_thread_from_xmpp(self): with ( unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value=None ) as on_message, unittest.mock.patch( "slidge.contact.contact.LegacyContact.create_thread", return_value="legacy-thread-id", ), ): self.recv( # language=XML f""" I start a new thread xmpp-thread-id """ ) on_message.assert_awaited_once() args, _kwargs = on_message.call_args assert isinstance(args[0], XMPPTextMessage) assert args[0].thread == "legacy-thread-id" with unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value=None ) as on_message: self.recv( # language=XML f""" I send a new message in the new thread xmpp-thread-id """ ) on_message.assert_awaited_once() args, _kwargs = on_message.call_args assert args[0].thread == "legacy-thread-id" def test_multi_correction_caption(self): from slidge import global_config global_config.USE_ATTACHMENT_ORIGINAL_URLS = True self.xmpp.use_message_ids = True self.run_coro( self.juliet.send_file( LegacyAttachment(url=self.avatar_url, caption="prout"), legacy_msg_id="original-id", ) ) msg = self.next_sent() assert isinstance(msg, Message) assert msg["body"] == self.avatar_url assert msg["id"] == "original-id" self.send( # language=XML """ prout """, use_values=False, ) self.run_coro( self.juliet.send_file( LegacyAttachment( url=self.avatar_url + "--NEW", caption="prout", ), legacy_msg_id="original-id", correction=True, ) ) self.send( # language=XML """ /me retracted the message 3 """, use_values=False, ) msg = self.next_sent() assert isinstance(msg, Message) assert msg["body"] == self.avatar_url + "--NEW" assert msg["replace"]["id"] == "original-id" self.send( # language=XML """ prout """, use_values=False, ) self.xmpp.use_message_ids = False global_config.USE_ATTACHMENT_ORIGINAL_URLS = False def test_multi_correction(self): from slidge import global_config global_config.USE_ATTACHMENT_ORIGINAL_URLS = True self.xmpp.use_message_ids = True self.run_coro( self.juliet.send_file(self.avatar_url, legacy_msg_id="original-id") ) msg = self.next_sent() assert isinstance(msg, Message) assert msg["body"] == self.avatar_url assert msg["id"] == "original-id" self.run_coro( self.juliet.send_file( self.avatar_url + "--NEW", legacy_msg_id="original-id", correction=True, ) ) msg = self.next_sent() assert isinstance(msg, Message) assert msg["body"] == self.avatar_url + "--NEW" assert msg["id"] == "3" assert msg["replace"]["id"] == "original-id" self.xmpp.use_message_ids = False global_config.USE_ATTACHMENT_ORIGINAL_URLS = False def test_sims(self): with ( unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value=None, ) as on_message, ): self.recv( # language=XML f""" Look at the nice view from the summit. image/jpeg summit.jpg 3032449 2XarmwTlNxDAMkvymloX3S5+VbylNrJt/l5QyPa+YoU= 2AfMGH8O7UNPTvUVAM9aK13mpCY= Photo from the summit. """ ) on_message.assert_awaited_once_with( XMPPAttachmentMessage( body="Look at the nice view from the summit.", attachments=(XMPPAttachment(url="https://" + self.avatar_url),), ) ) def test_user_retract(self): with ( unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value="legacy-id", ) as on_message, ): on_message.return_value = "legacy-id" self.recv( # language=XML """ hoy """ ) on_message.assert_awaited_once_with(XMPPTextMessage(body="hoy")) with unittest.mock.patch( "slidge.contact.LegacyContact.on_retract" ) as on_retract: self.recv( # language=XML """ """ ) on_retract.assert_awaited_once_with("legacy-id", thread=None) def test_reject_omemo(self): elements = [ "", "", "blabla", "blabla", ] for el in elements: with ( unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value=None, ) as on_message, ): self.recv( # language=XML f""" {el} your client blablabla """ ) on_message.assert_not_awaited() self.send( # language=XML """ You cannot send encrypted messages through this gateway """, use_values=False, ) slidge/tests/test_session_2.py000066400000000000000000001002751522535037600170210ustar00rootroot00000000000000import asyncio import unittest.mock from datetime import UTC, datetime import pytest from conftest import AvatarFixtureMixin from slixmpp import JID, Iq from slixmpp import __version__ as slix_version from slixmpp.exceptions import XMPPError from slidge import BaseGateway, BaseSession, GatewayUser, MucType from slidge.contact import LegacyContact from slidge.group import LegacyBookmarks, LegacyMUC from slidge.util.test import SlidgeTest from slidge.util.types import HoleBound, XMPPTextMessage class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" GROUPS = True class Contact(LegacyContact): async def update_info(self): if self.legacy_id.startswith("room"): raise XMPPError if self.legacy_id != "juliet": return self.is_friend = True self.added_to_roster = True self.name = "A name" self.online("status msg") await self.set_avatar("AVATAR_URL") class MUC(LegacyMUC): async def update_info(self): if self.legacy_id in ( "room-noinfo", "room-duplicate-participant", "room-avatar-slow", "room-contact-conflict", "room-reaction-fallback", "room-mark-all-carbon", ): return if self.legacy_id == "room-avatar-in-slow-task": self.session.create_task(self._slow_set_avatar(), "slow-avatar") return self.name = "Cool name" self.description = "Cool description" self.type = MucType.CHANNEL_NON_ANONYMOUS self.subject = "Cool subject" self.subject_setter = await self.get_participant_by_legacy_id("juliet") self.subject_date = datetime(2000, 1, 1, 0, 0, tzinfo=UTC) self.n_participants = 666 self.user_nick = "Cool nick" await self.set_avatar("AVATAR_URL") async def backfill( self, after: HoleBound | None = None, before: HoleBound | None = None, ): if self.legacy_id == "room-history": juliet = await self.get_participant_by_legacy_id("juliet") juliet.send_text("history") async def fill_participants(self): if "duplicate-participant" in self.legacy_id: yield await self.get_participant_by_legacy_id("duplicate") yield await self.get_participant_by_legacy_id("duplicate") if "contact-conflict" in self.legacy_id: yield await self.get_participant_by_legacy_id("contact-conflict") async def _slow_set_avatar(self): await asyncio.sleep(1) await self.set_avatar("AVATAR_URL") class Bookmarks(LegacyBookmarks): async def fill(self): return @pytest.mark.usefixtures("avatar") class TestSession2(AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register( Iq(sfrom="romeo@montague.lit/gajim") ) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza self.send( # language=XML """ """ ) @property def romeo_session(self) -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) def test_contact_init(self): self.run_coro(self.romeo_session.contacts.by_legacy_id("juliet")) self.send( # language=XML f""" status msg """ ) self.send( # language=XML """ A name """, use_values=False, ) self.send( # language=XML f""" """, use_values=False, # I do not understand why this is necessary, related on test run order?!? ) assert self.next_sent() is None juliet: Contact = self.run_coro( self.romeo_session.contacts.by_legacy_id("juliet") ) assert juliet.name == "A name" assert juliet.is_friend cached_presence = juliet._get_last_presence() assert cached_presence is not None assert cached_presence.pstatus == "status msg" assert juliet.avatar is not None def test_group_init(self): self.run_coro(self.romeo_session.bookmarks.by_legacy_id("room")) self.next_sent() # juliet presence self.next_sent() # juliet nick self.next_sent() # juliet avatar muc = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("room")) assert self.next_sent() is None # self.run_coro(muc._set) assert muc.name == "Cool name" assert muc.description == "Cool description" assert muc.type == MucType.CHANNEL_NON_ANONYMOUS assert muc.n_participants == 666 assert muc.user_nick == "Cool nick" assert muc.avatar is not None assert muc.subject == "Cool subject" assert muc.subject_date == datetime(2000, 1, 1, 0, 0, tzinfo=UTC) assert ( muc.subject_setter == self.run_coro(self.romeo_session.contacts.by_legacy_id("juliet")).name ) def test_set_user_nick_outside_update_info(self): muc = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("room")) assert muc.user_nick == "Cool nick" muc.user_nick = "Cooler nick" muc = self.run_coro(self.romeo_session.bookmarks.by_legacy_id("room")) assert muc.user_nick == "Cooler nick" def test_user_available(self): self.run_coro(self.romeo_session.contacts.by_legacy_id("juliet")) for _ in range(3): assert self.next_sent() is not None self.recv( # language=XML f""" """ ) assert self.next_sent() is not None assert self.next_sent() is None def test_leave_group(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room") ) self.next_sent() # juliet presence self.next_sent() # juliet nick self.next_sent() # juliet avatar assert self.next_sent() is None assert muc.jid in list([m.jid for m in self.romeo_session.bookmarks]) muc.add_user_resource("gajim") self.run_coro(self.romeo_session.bookmarks.remove(muc)) self.send( # language=XML """ You left this group from the official client. 0 """ ) assert muc.jid not in list([m.jid for m in self.romeo_session.bookmarks]) def test_correction(self): with unittest.mock.patch( "slidge.contact.LegacyContact.on_message", return_value="legacy-msg-id" ) as on_message: self.recv( # language=XML """ body """ ) on_message.assert_awaited_once_with(XMPPTextMessage(body="body")) with unittest.mock.patch( "slidge.contact.LegacyContact.on_message" ) as on_message: self.recv( # language=XML """ new body """ ) on_message.assert_awaited_once_with( XMPPTextMessage(body="new body", replace="legacy-msg-id") ) def test_participant_avatar_race_condition(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room") ) self.next_sent() # juliet presence self.next_sent() # juliet nick self.next_sent() # juliet avatar contact1: Contact = self.run_coro( self.romeo_session.contacts.by_legacy_id("slow") ) contact1.online() _participant1 = self.run_coro(muc.get_participant_by_contact(contact1)) contact1.avatar = "SLOW" self.run_coro(asyncio.wait_for(contact1._set_avatar_task, 2)) contact2: Contact = self.run_coro( self.romeo_session.contacts.by_legacy_id("slow") ) assert contact2.avatar.url == "AVATAR_URL" def test_conflict_on_join(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-noinfo") ) assert self.next_sent() is None self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) _subject = self.next_sent() self.send( # language=XML """ """, use_values=False, ) assert self.next_sent() is None def test_fill_participant_duplicate(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-duplicate-participant") ) assert self.next_sent() is None self.recv( # language=XML f""" """ ) assert self.next_sent().get_from().resource == "duplicate" assert self.next_sent().get_from().resource == muc.user_nick def test_presence_to_user_account(self): self.romeo_session.contacts.user_legacy_id = "user-id" self.recv( # language=XML """ """ ) self.send( # language=XML """ Actions with yourself are not supported. """ ) def test_slow_avatar_in_task(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-in-slow-task") ) assert not muc.participants_filled assert self.next_sent() is None assert not muc.participants_filled assert self.next_sent() is None self.recv( # language=XML f""" """ ) self.next_sent() muc = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-in-slow-task") ) assert muc.participants_filled for task in self.romeo_session._BaseSession__tasks: task: asyncio.Task if task.get_name() == "slow-avatar": self.run_coro(asyncio.wait_for(task, 30)) break muc = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-in-slow-task") ) assert muc.avatar.url == self.avatar_url assert muc.participants_filled def test_slow_avatar(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-slow") ) assert not muc.participants_filled assert self.next_sent() is None muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-slow") ) muc.avatar = "SLOW" assert not muc.participants_filled assert self.next_sent() is None self.recv( # language=XML f""" """ ) self.next_sent() muc2 = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-slow") ) assert muc2.participants_filled self.run_coro(asyncio.wait_for(muc._set_avatar_task, 2)) muc = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-avatar-slow") ) assert muc.participants_filled def test_live_message_then_fill_participants(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-contact-conflict") ) part = self.run_coro(muc.get_participant_by_legacy_id("contact-conflict")) part.send_text("some text") self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) def test_disco_unnamed_room(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ http://jabber.org/protocol/muc#roominfo 1 0 100 0 0 moderators 1 0 """, ) def test_reaction_fallback(self): self.romeo_session.user.preferences["reaction_fallback"] = True contact = self.run_coro(self.romeo_session.contacts.by_legacy_id("reacter")) contact.react("msg-id", "♥") self.send( # language=XML """ """, use_values=False, ) def test_reaction_fallback_muc(self): self.romeo_session.user.preferences["reaction_fallback"] = True muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-reaction-fallback") ) part = self.run_coro(muc.get_participant_by_legacy_id("participant-x")) muc.add_user_resource("gajim") part.send_text("some text\non lines", legacy_msg_id="msg-id") presence = self.next_sent() assert ( presence["from"] == "room-reaction-fallback@aim.shakespeare.lit/participant-x" ) self.send( # language=XML """ some text\non lines """, use_values=False, ) with unittest.mock.patch("uuid.uuid4", return_value="uuid"): part.react("msg-id", "♥") self.send( # language=XML """ > some text\n> on lines\n♥ """, use_values=False, ) def test_muc_user_retract(self): muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-retract") ) muc.add_user_resource("gajim") with unittest.mock.patch("slidge.group.LegacyMUC.on_message") as on_message: on_message.return_value = "legacy-id" self.recv( # language=XML """ hoy """ ) on_message.assert_awaited_once_with(XMPPTextMessage(body="hoy")) self.next_sent() with unittest.mock.patch("slidge.group.LegacyMUC.on_retract") as on_retract: self.recv( # language=XML """ """ ) on_retract.assert_awaited_once_with("legacy-id", thread=None) def test_mark_all_messages_muc(self): self.xmpp.MARK_ALL_MESSAGES = True muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-mark-all") ) muc.add_user_resource("gajim") part = self.run_coro(muc.get_participant_by_legacy_id("participant-x")) part.send_text("whatever", "msg_00") part.send_text("whatever", "msg_01") part.send_text("whatever", "msg_02") with unittest.mock.patch("slidge.group.LegacyMUC.on_displayed") as on_displayed: self.recv( # language=XML f""" """ ) assert on_displayed.await_count == 3 for i in range(3): assert on_displayed.call_args_list[i][0][0] == f"msg_0{i}" with unittest.mock.patch("slidge.group.LegacyMUC.on_displayed") as on_displayed: self.recv( # language=XML f""" """ ) on_displayed.assert_not_awaited() def test_mark_all_messages_muc_not_found(self): self.xmpp.MARK_ALL_MESSAGES = True muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-mark-all") ) muc.add_user_resource("gajim") part = self.run_coro(muc.get_participant_by_legacy_id("participant-x")) part.send_text("whatever", "msg_00") part.send_text("whatever", "msg_01") part.send_text("whatever", "msg_02") with unittest.mock.patch("slidge.group.LegacyMUC.on_displayed") as on_displayed: self.recv( # language=XML f""" """ ) assert on_displayed.await_count == 3, on_displayed.call_args_list for i in range(3): assert on_displayed.call_args_list[i][0][0] == f"msg_0{i}" with unittest.mock.patch("slidge.group.LegacyMUC.on_displayed") as on_displayed: self.recv( # language=XML f""" """ ) on_displayed.assert_not_awaited() # assert on_displayed.call_args_list[0][0][0].jid.username == "room-mark-all" # assert on_displayed.call_args_list[0][0][1] == f"msg_03" def test_mark_all_messages_muc_carbon(self): # Privileges are set as side effect of another test, when running the full test # suite. I have not found where unfortunately :( self.xmpp.plugin["xep_0356"].granted_privileges.clear() self.xmpp.MARK_ALL_MESSAGES = True muc: LegacyMUC = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-mark-all-carbon") ) muc.add_user_resource("gajim") part = self.run_coro(muc.get_participant_by_legacy_id("participant-x")) part.send_text("whatever", "msg_00") _pres = self.next_sent() _msg = self.next_sent() for i in range(1, 3): part.send_text("whatever", f"msg_0{i}") _msg = self.next_sent() part = self.run_coro(muc.get_user_participant()) with unittest.mock.patch("uuid.uuid4", return_value="some-uuid"): part.displayed("msg_01") self.send( # language=XML f""" """ ) assert self.next_sent() is None part.displayed("msg_01") assert self.next_sent() is None self.xmpp.MARK_ALL_MESSAGES = False def test_mark_all_messages_muc_carbon_not_found(self): self.xmpp.MARK_ALL_MESSAGES = True muc = self.run_coro( self.romeo_session.bookmarks.by_legacy_id("room-mark-all-carbon") ) muc.add_user_resource("gajim") part = self.run_coro(muc.get_user_participant()) with unittest.mock.patch("uuid.uuid4", return_value="some-uuid"): part.displayed("msg_xx") self.send( # language=XML f""" """ ) self.xmpp.MARK_ALL_MESSAGES = False slidge/tests/test_set_name_before_fill.py000066400000000000000000000026331522535037600212370ustar00rootroot00000000000000from conftest import AvatarFixtureMixin from slidge import BaseGateway, BaseSession from slidge.contact import LegacyContact, LegacyRoster from slidge.group import LegacyBookmarks from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): await self.contacts.by_name("some id", "some name") await self.bookmarks.by_name("some group id", "some group name") return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" class Roster(LegacyRoster): async def by_name(self, legacy_id: str, name: str): c = await self.by_legacy_id(legacy_id) c.name = name return c class Contact(LegacyContact): def use_contact_info(self, name: str): self.name = name class Bookmarks(LegacyBookmarks): async def fill(self): return async def by_name(self, legacy_id: str, name: str): muc = await self.by_legacy_id(legacy_id) muc.name = name class TestSetNameBeforeFill(AvatarFixtureMixin, SlidgeTest): plugin = globals() def setUp(self): super().setUp() self.setup_logged_session(1) def test_set_contact_name_before_fill(self): contact = self.run_coro(self.romeo.contacts.by_legacy_id("some id")) assert contact.name == "some name" muc = self.run_coro(self.romeo.bookmarks.by_legacy_id("some group id")) assert muc.name == "some group name" slidge/tests/test_shakespeare.py000066400000000000000000002224501522535037600174100ustar00rootroot00000000000000import datetime import logging import re import tempfile import unittest.mock from copy import copy from pathlib import Path from xml.etree import ElementTree as ET from slixmpp import JID, Message, Presence from slixmpp import __version__ as slix_version from slixmpp.exceptions import XMPPError from slixmpp.plugins.xep_0030 import DiscoInfo from slixmpp.plugins.xep_0082 import format_datetime from slixmpp.plugins.xep_0356.permissions import ( MessagePermission, Permissions, PresencePermission, RosterAccess, ) from sqlalchemy import delete from slidge import * # noqa: F403 from slidge import ( FormField, GatewayUser, LegacyBookmarks, LegacyContact, LegacyRoster, SearchResult, ) from slidge.command.categories import ADMINISTRATION from slidge.core import config from slidge.core.mixins.attachment import AttachmentMixin from slidge.db.models import Contact from slidge.util.test import SlidgeTest from slidge.util.types import ( ChatState, LegacyAttachment, XMPPMessage, ) received_presences: list[Presence | None] = [] text_received_by_juliet = [] composing_chat_states_received_by_juliet = [] unregistered = [] reactions_received_by_juliet = [] class Session(BaseSession): # noqa: F405 async def on_search(self, form_values: dict[str, str]): if form_values["leg"] == "exists": return SearchResult( fields=[FormField(var="jid", label="JID", type="jid-single")], items=[{"jid": "exists@example.com"}], ) def __init__(self, user): super().__init__(user) async def wait_for_ready(self, timeout=10): return async def login(self): pass async def logout(self): pass class Gateway(BaseGateway[Session]): # noqa: F405 COMPONENT_NAME = "SLIDGE TEST" SEARCH_FIELDS = [FormField(var="leg", label="Enter the legacy ID")] SEARCH_TITLE = "Search for legacy contacts" GROUPS = True async def unregister(self, session: Session): unregistered.append(session) class Roster(LegacyRoster): async def jid_username_to_legacy_id(self, jid_username: str) -> str: log.debug("Requested JID to legacy: %s", jid_username) if jid_username == "juliet": return "123" elif jid_username == "new-friend": return "456" else: raise XMPPError(text="Only juliet", condition="item-not-found") async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if legacy_id == "123": return "juliet" elif legacy_id == "456": return "new-friend" else: raise RuntimeError class ContactShakespeare(LegacyContact): async def on_message( self, message: XMPPMessage, thread=None, link_previews=(), ): if self.jid.node == "juliet": text_received_by_juliet.append((message.body, self)) assert self.user_jid.bare == "romeo@montague.lit" assert self.user_jid == JID("romeo@montague.lit") self.send_text("I love you") return 0 async def on_chat_state(self, chat_state: ChatState, thread=None): if chat_state == "composing": composing_chat_states_received_by_juliet.append(self) async def on_displayed(self, legacy_msg_id: str, thread=None): pass async def on_react( self, legacy_msg_id: str, emojis: list[str], thread=None, ): if self.jid.node == "juliet": for e in emojis: reactions_received_by_juliet.append([legacy_msg_id, e]) class Bookmarks(LegacyBookmarks): @staticmethod async def jid_local_part_to_legacy_id(local_part): if local_part != "room": raise XMPPError("item-not-found") else: return local_part async def fill(self): await self.by_legacy_id("room1") await self.by_legacy_id("room2") class Base(SlidgeTest): plugin = globals() def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.get_romeo_session().logged = True @staticmethod def get_romeo_session() -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) @property def juliet(self) -> LegacyContact: session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) return self.run_coro(session.contacts.by_jid(JID("juliet@aim.shakespeare.lit"))) class TestAimShakespeareBase(Base): def loop(self, x): self.run_coro(x) def test_jabber_iq_gateway(self): self.recv( # language=XML """ """ ) self.send( # language=XML f""" {Gateway.SEARCH_TITLE} {Gateway.SEARCH_FIELDS[0].label} """ ) self.recv( # language=XML """ exists """ ) self.send( # language=XML """ exists@example.com """ ) self.recv( # language=XML """ not-exists """ ) self.send( # language=XML """ No contact was found with the info you provided. """, use_values=False, ) def test_jabber_iq_gateway_on_contact(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ This can only be used on the component JID """ ) def test_from_romeo_to_eve(self): self.recv( # language=XML """ Art thou not Romeo, and a Montague? """ ) self.send( # language=XML """ Only juliet """, use_values=False, ) def test_from_romeo_to_juliet(self): self.recv( # language=XML """ Art thou not Romeo, and a Montague? """ ) text, contact = text_received_by_juliet[-1] assert text == "Art thou not Romeo, and a Montague?" assert contact.legacy_id == "123" m: Message = self.next_sent() assert m.get_from() == "juliet@aim.shakespeare.lit/slidge" assert m["body"] == "I love you" m2 = copy( m ) # there must be a better way to check for the presence of the markable thing m2.enable("markable") assert m == m2 text_received_by_juliet.clear() def test_delivery_receipt(self): self.xmpp.PROPER_RECEIPTS = True self.recv( # language=XML """ Art thou not Romeo, and a Montague? """ ) self.next_sent() # auto reply in our test plugin session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) juliet = self.run_coro( session.contacts.by_jid(JID("juliet@aim.shakespeare.lit")) ) juliet.received("123") self.send( # language=XML """ """ ) self.send( # language=XML """ """ ) assert self.next_sent() is None def test_romeo_composing(self): self.recv( # language=XML """ """ ) assert len(composing_chat_states_received_by_juliet) == 1 assert composing_chat_states_received_by_juliet[0].legacy_id == "123" def test_from_eve_to_juliet(self): # just ignore messages from unregistered users self.recv( # language=XML """ Art thou not Romeo, and a Montague? """ ) self.send( # language=XML """ You are not registered to this gateway """ ) def test_juliet_sends_text(self): session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) juliet = self.run_coro( session.contacts.by_jid(JID("juliet@aim.shakespeare.lit")) ) juliet.send_text(body="What what?") msg = self.next_sent() assert msg["from"] == f"juliet@aim.shakespeare.lit/{LegacyContact.RESOURCE}" assert msg["to"] == "romeo@montague.lit" assert msg["body"] == "What what?" def test_unregister(self): assert len(unregistered) == 0 self.recv( # language=XML """ """ ) # this creates a session self.recv( # language=XML """ """ ) assert len(unregistered) == 1 assert unregistered[0].user.jid == "romeo@montague.lit" unregistered.clear() def test_jid_validator(self): self.xmpp.jid_validator = re.compile(".*@noteverybody") self.recv( # language=XML """ """ ) self.send( # language=XML """ Your account is not allowed to use this gateway. The admin controls that with the USER_JID_VALIDATOR option. """, use_values=False, ) self.recv( # language=XML """ bill Calliope """ ) self.send( # language=XML """ Your account is not allowed to use this gateway. The admin controls that with the USER_JID_VALIDATOR option. """, use_values=False, ) self.xmpp.jid_validator = re.compile(".*") def test_reactions(self): self.recv( # language=XML """ 👋 🐢 """ ) assert len(reactions_received_by_juliet) == 2 msg_id, emoji = reactions_received_by_juliet[0] assert msg_id == "555" assert emoji == "👋" msg_id, emoji = reactions_received_by_juliet[1] assert msg_id == "555" assert emoji == "🐢" session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) juliet = self.run_coro( session.contacts.by_jid(JID("juliet@aim.shakespeare.lit")) ) juliet.react("legacy1", ["👋", "👋"]) msg = self.next_sent() assert msg["reactions"]["id"] == "legacy1" assert len(msg["reactions"]) == 1 for r in msg["reactions"]: assert r["value"] == "👋" reactions_received_by_juliet.clear() def test_reactions_fallback(self): self.recv( # language=XML """ > huuuuu\n👍 👍 0516cd85-4d78-4d29-bc87-378e33d820b3 """ ) assert len(reactions_received_by_juliet) == 1 msg_id, emoji = reactions_received_by_juliet[0] assert msg_id == "4940890112" assert emoji == "👍" assert len(text_received_by_juliet) == 0 text_received_by_juliet.clear() reactions_received_by_juliet.clear() def test_last_seen(self): session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) juliet = self.run_coro( session.contacts.by_jid(JID("juliet@aim.shakespeare.lit")) ) juliet.is_friend = True now = datetime.datetime.now(datetime.UTC) juliet.away(last_seen=now) sent = self.next_sent() assert sent["idle"]["since"] == now def test_disco_adhoc_commands_unregistered(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_disco_adhoc_commands_as_logged_user(self): self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) def test_disco_adhoc_commands_as_non_logged_user(self): self.get_romeo_session().logged = False self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) self.get_romeo_session().logged = True def test_disco_adhoc_commands_as_admin(self): # monkeypatch.setattr(config, "ADMINS", ("romeo@montague.lit",)) config.ADMINS = (JID("admin@montague.lit"),) self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) config.ADMINS = () def test_disco_adhoc_commands_as_unauthorized(self): self.xmpp.jid_validator = re.compile("nothing") self.recv( # language=XML f""" """ ) st = self.next_sent() assert st["error"]["condition"] == "forbidden" self.xmpp.jid_validator = re.compile(".*") def test_disco_adhoc_command_register(self): self.xmpp.jid_validator = re.compile(".*@whatever.com") self.recv( # language=XML f""" """ ) self.send( # language=XML f""" """ ) self.xmpp.jid_validator = re.compile(".*") def test_adhoc_forbidden_non_admin(self): with unittest.mock.patch( "slixmpp.plugins.xep_0050.adhoc.XEP_0050.new_session", return_value="session-id", ): self.recv( # language=XML f""" """ ) self.send( # language=XML """ 🛷️ Slidge administration """, use_values=False, ) def test_disco_component(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """ ) def test_disco_local_part_unregistered(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, use_values=False, ) def test_disco_registered_existing_contact(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, ) def test_disco_registered_existing_contact_bare_jid(self): self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, ) def test_disco_items_registered_existing_contact(self): session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) self.run_coro(session.bookmarks.fill()) self.recv( # language=XML f""" """ ) self.send( # language=XML """ """, ) def test_disco_restricted_reaction(self): session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) self.run_coro(session.contacts.by_jid(JID("juliet@aim.shakespeare.lit"))) LegacyContact.REACTIONS_SINGLE_EMOJI = True self.recv( # language=XML f""" """ ) self.send( # language=XML """ urn:xmpp:reactions:0:restrictions 1 """, ) LegacyContact.REACTIONS_SINGLE_EMOJI = False def test_non_existing_contact(self): self.recv( # language=XML f""" DSAD """ ) self.send( # language=XML """ Only juliet """, use_values=False, ) def test_attachments(self): with tempfile.NamedTemporaryFile() as f: a = LegacyAttachment(path=f.name) ids = [] async def send_file(self, file_path, legacy_msg_id=None, *_a, **_k): ids.append(legacy_msg_id) m = Message() m.set_id(legacy_msg_id) return "", [m] orig = LegacyContact.send_file LegacyContact.send_file = send_file self.loop(self.juliet.send_files([], body="Hey")) assert not self.next_sent().get_id() self.loop(self.juliet.send_files([], body="Hey", legacy_msg_id="prout")) assert self.next_sent().get_id() == "prout" assert self.juliet self.loop(self.juliet.send_files([], body="")) assert self.next_sent() is None self.loop(self.juliet.send_files([a, a, a], body="")) assert ids.pop(-3) is None assert ids.pop(-2) is None assert ids.pop(-1) is None self.loop(self.juliet.send_files([a, a, a], legacy_msg_id="leg")) assert ids.pop(-3) == "leg" assert ids.pop(-2) is None assert ids.pop(-1) is None self.loop(self.juliet.send_files([], body="hoy")) assert not self.next_sent().get_id() self.loop(self.juliet.send_files([], body="hoy", legacy_msg_id="leg")) assert self.next_sent().get_id() == "leg" self.loop( self.juliet.send_files([], body="hoy", legacy_msg_id="leg", body_first=True) ) assert self.next_sent().get_id() == "leg" self.loop( self.juliet.send_files( [a, a, a, a], body="hoy", legacy_msg_id="leg", body_first=True ) ) assert self.next_sent().get_id() == "leg" assert ids.pop(-4) is None assert ids.pop(-3) is None assert ids.pop(-2) is None assert ids.pop(-1) is None self.loop( self.juliet.send_files( [a, a, a, a], body="hoy", legacy_msg_id="leg", body_first=False ) ) assert ids.pop(-4) == "leg" assert ids.pop(-3) is None assert ids.pop(-2) is None assert ids.pop(-1) is None assert not self.next_sent().get_id() self.loop(self.juliet.send_files([a, a, a, a], body="hoy")) assert ids.pop(-4) is None assert ids.pop(-3) is None assert ids.pop(-2) is None assert ids.pop(-1) is None assert not self.next_sent().get_id() self.loop(self.juliet.send_files([a])) assert ids.pop(-1) is None self.loop(self.juliet.send_files([a], legacy_msg_id="leg")) assert ids.pop(-1) == "leg" LegacyContact.send_file = orig def test_gateway_message(self): session = self.get_romeo_session() session.send_gateway_message("Hello!") self.send( # language=XML """ Hello! """ ) class TestPrivilege(SlidgeTest): plugin = globals() def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@shakespeare.lit"), legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() def test_privilege(self): assert ( self.xmpp.plugin["xep_0356"].granted_privileges["shakespeare.lit"] == Permissions() ) self.recv( # language=XML """ """ ) assert ( self.xmpp.plugin["xep_0356"].granted_privileges["shakespeare.lit"].message == MessagePermission.OUTGOING ) assert ( self.xmpp.plugin["xep_0356"].granted_privileges["shakespeare.lit"].presence == PresencePermission.NONE ) assert ( self.xmpp.plugin["xep_0356"].granted_privileges["shakespeare.lit"].roster == RosterAccess.BOTH ) session = BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@shakespeare.lit") ) juliet: ContactShakespeare = self.run_coro( session.contacts.by_jid(JID("juliet@aim.shakespeare.lit")) ) juliet.send_text("body", "545", carbon=True) self.send( # language=XML """ body """, ) juliet.is_friend = True self.xmpp.loop.create_task(juliet.add_to_roster()) self.send( # language=XML """ slidge """ ) class TestContact(SlidgeTest): plugin = globals() def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.execute(delete(Contact)) orm.commit() romeo = self.get_romeo_session() romeo.logged = True romeo.contacts.ready.set_result(True) @staticmethod def get_romeo_session() -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@montague.lit") ) @staticmethod def get_presence(ptype: str): return f""" """ def get_contact(self, legacy_id: str) -> ContactShakespeare: session = self.get_romeo_session() return self.run_coro(session.contacts.by_legacy_id(legacy_id)) def get_juliet(self) -> LegacyContact: return self.get_contact("123") def get_new_friend(self) -> LegacyContact: return self.get_contact("456") def test_caps(self): juliet = self.get_juliet() juliet.is_friend = True juliet.online() self.send( # language=XML f""" 0 """ ) def test_caps_extended(self): LegacyContact.REACTIONS_SINGLE_EMOJI = True LegacyContact.CORRECTION = False juliet = self.get_juliet() juliet.is_friend = True juliet.online() self.send( # language=XML f""" 0 """ ) LegacyContact.REACTIONS_SINGLE_EMOJI = False LegacyContact.CORRECTION = True def test_vcard_temp(self): juliet = self.get_juliet() juliet.is_friend = True self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) juliet.set_vcard(full_name="Juliet Something") self.next_sent() self.recv( # language=XML """ """ ) self.send( # language=XML """ xmpp:juliet@aim.shakespeare.lit Juliet Something """, use_values=False, ) def test_probe(self): juliet = self.get_juliet() probe = self.get_presence("probe") juliet.is_friend = True self.recv(probe) p = self.next_sent() assert p["type"] == "unavailable" juliet.online() assert self.next_sent()["type"] == "available" self.recv(probe) assert self.next_sent()["type"] == "available" juliet.is_friend = False self.recv(probe) p = self.next_sent() assert p["type"] == "unsubscribed" assert p["from"] == juliet.jid.bare assert self.next_sent() is None def test_user_subscribe_to_friend(self): juliet = self.get_juliet() juliet.is_friend = True sub = self.get_presence("subscribe") with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_request" ) as mock: self.recv(sub) mock.assert_not_awaited() p = self.next_sent() assert p["type"] == "subscribed" assert p["from"] == juliet.jid.bare assert self.next_sent() is None assert juliet.is_friend def test_user_subscribe_to_non_friend_accept(self): juliet = self.get_juliet() juliet.is_friend = False sub = self.get_presence("subscribe") with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_request" ) as mock: self.recv(sub) mock.assert_awaited_once() assert self.next_sent() is None assert not juliet.is_friend juliet.name = "JULIET" assert self.next_sent() is None self.run_coro(juliet.accept_friend_request()) p = self.next_sent() assert p["type"] == "subscribed" assert p["from"] == juliet.jid.bare assert ( self.next_sent()["pubsub_event"]["items"]["item"]["nick"]["nick"] == "JULIET" ) assert self.next_sent() is None assert juliet.is_friend def test_user_subscribe_to_non_friend_reject(self): juliet = self.get_juliet() juliet.is_friend = False sub = self.get_presence("subscribe") with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_request" ) as mock: self.recv(sub) mock.assert_awaited_once() assert self.next_sent() is None juliet.name = "JULIET" assert self.next_sent() is None juliet.reject_friend_request() p = self.next_sent() assert p["from"] == juliet.jid.bare assert p["type"] == "unsubscribed" assert self.next_sent() is None assert not juliet.is_friend def test_juliet_send_friend_request_user_accepts(self): juliet = self.get_juliet() juliet.name = "JUJU" juliet.send_friend_request() p = self.next_sent() assert p["from"] == juliet.jid.bare assert p["type"] == "subscribe" assert p["to"] == "romeo@montague.lit" assert p["nick"]["nick"] == "JUJU" assert self.next_sent() is None with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_accept" ) as mock: self.recv( # language=XML f""" """ ) mock.assert_awaited_once() assert self.next_sent() is None def test_juliet_send_friend_request_user_rejects(self): juliet = self.get_juliet() juliet.name = "JUJU" juliet.is_friend = False juliet.send_friend_request() p = self.next_sent() assert p["type"] == "subscribe" assert p["from"] == juliet.jid.bare assert p["to"] == "romeo@montague.lit" assert p["nick"]["nick"] == "JUJU" assert self.next_sent() is None with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_delete" ) as mock: self.recv( # language=XML f""" """ ) mock.assert_not_awaited() assert self.next_sent() is None juliet.is_friend = True with unittest.mock.patch( "slidge.contact.LegacyContact.on_friend_delete" ) as mock: self.recv( # language=XML f""" """ ) mock.assert_awaited_once() assert self.next_sent() is None def test_send_several_subscription_requests_in_a_row(self): juliet = self.get_juliet() juliet.online() juliet.send_friend_request() assert self.next_sent()["type"] == "subscribe" juliet.send_friend_request() assert self.next_sent()["type"] == "subscribe" def test_correct(self): juliet = self.get_juliet() juliet.correct("old_msg_id", "new content") msg = self.next_sent() assert msg["replace"]["id"] == "old_msg_id" assert msg["body"] == "new content" assert msg["id"] == "" juliet.correct("old_msg_id", "new content", correction_event_id="correction_id") msg = self.next_sent() assert msg["replace"]["id"] == "old_msg_id" assert msg["body"] == "new content" assert msg["id"] == "correction_id" def test_retract(self): juliet = self.get_juliet() juliet.retract("old_msg_id") # msg = self.next_sent() self.send( # language=XML """ /me retracted the message old_msg_id """ ) def test_presence(self): juliet = self.get_juliet() juliet.is_friend = True now = datetime.datetime.now(tz=datetime.UTC) a_week_ago = now - datetime.timedelta(days=7) juliet.away(status="Bye bye", last_seen=now) p = self.next_sent() assert p["status"] == f"Bye bye -- Last seen {now:%A %H:%M %p GMT}" assert p["idle"]["since"] == now assert p["show"] == "away" juliet.extended_away(status="Bye bye", last_seen=a_week_ago) p = self.next_sent() assert p["status"] == f"Bye bye -- Last seen {a_week_ago:%b %-d %Y}" assert p["idle"]["since"] == a_week_ago assert p["show"] == "xa" juliet.busy(last_seen=now) p = self.next_sent() assert p["status"] == f"Last seen {now:%A %H:%M %p GMT}" assert p["idle"]["since"] == now assert p["show"] == "dnd" juliet.busy(last_seen=a_week_ago) p = self.next_sent() assert p["status"] == f"Last seen {a_week_ago:%b %-d %Y}" assert p["idle"]["since"] == a_week_ago def test_disco_bare(self): juliet = self.get_juliet() juliet.is_friend = True self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_disco_resource(self): juliet = self.get_juliet() juliet.is_friend = True self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_body_needing_escaping(self): self.get_juliet().send_text("\x1b[0;31mRED") self.send( # language=XML """ �[0;31mRED """ ) class TestCarbon(SlidgeTest): plugin = globals() def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@shakespeare.lit").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.recv( # language=XML """ """ ) self.get_romeo_session().logged = True @staticmethod def get_romeo_session() -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( # noqa: F405 JID("romeo@shakespeare.lit") ) def get_juliet(self) -> LegacyContact: session = self.get_romeo_session() return self.run_coro(session.contacts.by_legacy_id("123")) def test_carbon_send_file(self): orig = AttachmentMixin._AttachmentMixin__get_url async def get_url(self, att, *a, **k): return "URL" AttachmentMixin._AttachmentMixin__get_url = get_url juliet = self.get_juliet() juliet.send_text("TEXT", "445", carbon=True) self.send( # language=XML """ TEXT """ ) with tempfile.NamedTemporaryFile("w+") as f: f.write("test") f.seek(0) self.run_coro( juliet.send_file(Path(f.name), legacy_msg_id="446", carbon=True) ) stamp = format_datetime( datetime.datetime.fromtimestamp(Path(f.name).stat().st_mtime) ) self.send( # language=XML f""" {Path(f.name).name} 4 {stamp} n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= {Path(f.name).name} 4 {stamp} n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= URL URL """ ) AttachmentMixin._AttachmentMixin__get_url = orig def test_carbon_ignore(self): """ Ensure we don't react to stanzas we send to ourselves. """ juliet = self.get_juliet() juliet.send_text("TEXT", "123456", carbon=True) self.send( # language=XML """ TEXT """ ) with unittest.mock.patch( "test_shakespeare.ContactShakespeare.on_message" ) as mock: mock.get_id = lambda *a: "msg_id" self.recv( # language=XML """ TEXT """ ) mock.assert_not_awaited() class TestUserGetsOnline(Base): def setUp(self): super().setUp() self.get_romeo_session().contacts.ready.set_result(True) self.juliet.is_friend = True self.juliet.added_to_roster = True self.juliet.name = "Juliet" self.send( # language=XML """ Juliet """, use_values=False, ) def _assert_send_nick(self): self.send( # language=XML """ Juliet """, use_values=False, ) def test_user_online_without_caps(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) self.recv( # language=XML """ """ ) self._assert_send_nick() assert self.next_sent() is None def test_user_online_with_caps(self): with unittest.mock.patch( "slixmpp.plugins.xep_0115.caps.XEP_0115.get_caps" ) as get_caps: get_caps.return_value = DiscoInfo( xml=ET.fromstring( # language=XML """ """ ) ) self.recv( # language=XML """ """ ) self._assert_send_nick() assert self.next_sent() is None def test_user_online_with_caps_no_nick_notify(self): with unittest.mock.patch( "slixmpp.plugins.xep_0115.caps.XEP_0115.get_caps" ) as get_caps: get_caps.return_value = DiscoInfo( xml=ET.fromstring( # language=XML """ """ ) ) self.recv( # language=XML """ """ ) assert self.next_sent() is None class TestUserPresence(Base): def setUp(self): self.patcher = unittest.mock.patch( "slidge.core.session.BaseSession.on_presence" ) self.presence = self.patcher.start() super().setUp() def tearDown(self): super().tearDown() self.patcher.stop() def test_user_presence(self): self.recv( # language=XML """ """ ) self.presence.assert_called_with( "cheogram", "available", "", {"cheogram": {"status": "", "show": "", "priority": 0}}, {"status": "", "show": "", "priority": 0}, ) self.presence.assert_awaited_once() self.presence.reset_mock() self.recv( # language=XML """ away I use gajim, yay! """ ) self.presence.assert_called_with( "gajim", "away", "I use gajim, yay!", { "cheogram": {"status": "", "show": "", "priority": 0}, "gajim": {"status": "I use gajim, yay!", "show": "away", "priority": 0}, }, {"status": "I use gajim, yay!", "show": "", "priority": 0}, ) self.recv( # language=XML """ """ ) self.presence.assert_called_with( "gajim", "unavailable", "", { "cheogram": {"status": "", "show": "", "priority": 0}, }, {"status": "", "show": "", "priority": 0}, ) log = logging.getLogger(__name__) slidge/tests/test_spaces.py000066400000000000000000001014261522535037600163720ustar00rootroot00000000000000import unittest.mock from slixmpp import JID, Iq from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyMUC, LegacyParticipant, ) from slidge.command.user import _get_updated_spaces from slidge.util.test import SlidgeTest from slidge.util.types import SpaceMetadata class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True SPACES = True class Contact(LegacyContact): async def update_info(self): if self.legacy_id in ("programming-socks", "boring-software", "editors"): raise XMPPError("item-not-found", "Not a contact") class Bookmarks(LegacyBookmarks["MUC"]): async def fill(self): for legacy_id in ( "programming-socks", "boring-software", "editors", "fancy-hotels", "finance", ): await self.by_legacy_id(legacy_id) async def fetch_space_metadata(self, legacy_id: str): match legacy_id: case "dev": return SpaceMetadata( creator_legacy_id="lead-dev", owner_legacy_ids=["lead-dev", "lead-dev-friend"], name="The developers corner", description="Nerds welcome!", member_count=666, ) case "sales": return SpaceMetadata( creator_legacy_id="ferrari-guy", name="Sales team", ) case "management": return SpaceMetadata( creator_legacy_id="ceo", name="The bosses lounge", ) case _: raise XMPPError("item-not-found", "Not a space") class MUC(LegacyMUC[LegacyParticipant]): async def update_info(self): match self.legacy_id: case "programming-socks": self.name = "Share your best programming socks deal HERE" self.space_legacy_id = "dev" case "boring-software": self.name = "ERP 3000" self.space_legacy_id = "dev" case "editors": self.name = "Settling the text editor wars" self.space_legacy_id = "dev" case "fancy-hotels": self.space_legacy_id = "sales" case "finance": self.space_legacy_id = "management" case "new-dev": self.space_legacy_id = "dev" class TestSpaces(SlidgeTest): plugin = globals() def setUp(self): super().setUp() self._add_slidge_user("emacs-user@shakespeare.lit") def user_session( self, jid: str | JID = "emacs-user@shakespeare.lit" ) -> "BaseSession": return super().user_session(jid) def test_query_spaces_not_implemented(self): Gateway.SPACES = False self.recv( # language=XML """ urn:xmpp:pubsub-filter:0 urn:xmpp:spaces:0 """ ) self.send( # language=XML """ This gateway does not support XEP-0503 """ ) Gateway.SPACES = True def test_query_spaces(self): self.recv( # language=XML """ urn:xmpp:pubsub-filter:0 urn:xmpp:spaces:0 """ ) self.send( # language=XML """ """ ) def test_query_space_metadata(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ http://jabber.org/protocol/pubsub#meta-data urn:xmpp:spaces:0 lead-dev@aim.shakespeare.lit The developers corner lead-dev@aim.shakespeare.lit lead-dev-friend@aim.shakespeare.lit 666 Nerds welcome! """ ) def test_get_all_items(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) def test_get_single_item(self) -> None: self.__recv_get_item(["programming-socks@aim.shakespeare.lit"]) self.__send_items( [ ( "programming-socks@aim.shakespeare.lit", "Share your best programming socks deal HERE", ) ] ) def test_get_multiple_item(self) -> None: self.__recv_get_item( ["programming-socks@aim.shakespeare.lit", "editors@aim.shakespeare.lit"] ) self.__send_items( [ ( "programming-socks@aim.shakespeare.lit", "Share your best programming socks deal HERE", ), ( "editors@aim.shakespeare.lit", "Settling the text editor wars", ), ] ) def test_get_nonexistent_item(self) -> None: self.__recv_get_item(["nope"]) self.send( # language=XML """ Slidge only supports items that are MUCs. """ ) def test_get_nonexistent_item_multiple(self) -> None: self.__recv_get_item(["nope", "editors@aim.shakespeare.lit"]) self.send( # language=XML """ Slidge only supports items that are MUCs. """ ) def test_get_nonexistent_item_multiple2(self) -> None: self.__recv_get_item(["xxx@aim.shakespeare.lit", "editors@aim.shakespeare.lit"]) self.send( # language=XML """ Could not find items: {'xxx@aim.shakespeare.lit'} """ ) def __recv_get_item(self, ids: list[str], node: str = "dev") -> None: items = "" for i in ids: items += f"\n" self.recv( # language=XML f""" {items} """ ) def __send_items(self, data: list[tuple[str, str]], node: str = "dev") -> None: items = "" for id_, name in data: items += f"\n" self.send( # language=XML f""" {items} """ ) def test_muc_advertises_membership(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ http://jabber.org/protocol/muc#roominfo 1 0 100 0 Share your best programming socks deal HERE 0 moderators 1 0 xmpp:aim.shakespeare.lit?node=dev """ ) def test_user_subscribe_success(self) -> None: self.__recv_subscription_request("dev") self.send( # language=XML """ """ ) def test_user_subscribe_non_existing_space(self) -> None: self.__recv_subscription_request("non-existing") iq = self.next_sent() assert isinstance(iq, Iq) assert iq["type"] == "error" assert iq["error"]["type"] == "cancel" assert iq["error"]["condition"] == "item-not-found" def __recv_subscription_request(self, node: str) -> None: self.recv( # language=XML f""" """ ) def test_user_leave_space(self) -> None: with unittest.mock.patch("slidge.BaseSession.on_leave_space") as on_leave_space: self.recv( # language=XML """ """ ) on_leave_space.assert_awaited_once_with("dev") self.send( # language=XML """ """ ) def test_new_room_in_existing_space(self) -> None: self.run_coro(self.user_session().bookmarks.by_legacy_id("new-dev")) self.send( # language=XML """ """, use_values=False, ) def test_get_owner_affiliations(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ Slidge does not implement managing space affiliations. """, ) # For slidge v0.5.0… # self.send( # """ # # # # # # # # # # """ # ) def test_get_node_affiliation(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_get_all_affiliations(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_metadata_change(self) -> None: self.run_coro( self.user_session().bookmarks.update_space_metadata( "dev", SpaceMetadata(name="The new name"), ) ) self.send( # language=XML """ http://jabber.org/protocol/pubsub#node_config The new name urn:xmpp:spaces:0 true true false authorize true true true publishers """ ) def test_get_owner_subscriptions(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_get_subscriptions(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_get_subscriptions_specific_node(self) -> None: self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) def test_get_updated_spaces(self) -> None: spaces = self.run_coro(_get_updated_spaces(self.user_session())) assert len(spaces) assert [s.name for s in spaces] == [ "Sales team", "The bosses lounge", "The developers corner", ] def test_fetch_space_metadata_onlyonce(self) -> None: req = """ """ with unittest.mock.patch( "test_spaces.Bookmarks.fetch_space_metadata", return_value=SpaceMetadata() ) as fetch_space_metadata: self.recv(req.format(id=1)) self.recv(req.format(id=2)) fetch_space_metadata.assert_awaited_once() slidge/tests/test_type_conversion.py000066400000000000000000000066531522535037600203500ustar00rootroot00000000000000import pytest from conftest import AvatarFixtureMixin from slixmpp import JID, Iq from slidge import BaseGateway, BaseSession, GatewayUser, LegacyBookmarks, LegacyRoster from slidge.contact import LegacyContact from slidge.core.session import _sessions from slidge.group import LegacyMUC from slidge.util.test import SlidgeTest class SomeType: def __init__(self, a: int, b: int): self.a = a self.b = b @classmethod def from_str(cls, s: str): a, b = (int(x) for x in s.split("-")) return SomeType(a, b) def __str__(self): return f"{self.a}-{self.b}" class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" GROUPS = True class Roster(LegacyRoster): async def by_some_type(self, some_type: SomeType) -> str: return await self.by_legacy_id(f"{some_type.a}-{some_type.b}") class Mixin: legacy_id: str @property def some_type(self) -> SomeType: return SomeType.from_str(self.legacy_id) class Contact(LegacyContact, Mixin): pass class MUC(LegacyMUC, Mixin): pass class Bookmarks(LegacyBookmarks[MUC]): async def fill(self): pass @pytest.mark.usefixtures("avatar") class TestLegacyTypeConversion(AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() with self.xmpp.store.session() as orm: user = GatewayUser( jid=JID("romeo@montague.lit/gajim").bare, legacy_module_data={"username": "romeo", "city": ""}, preferences={"sync_avatar": True, "sync_presence": True}, ) orm.add(user) orm.commit() self.run_coro( self.xmpp._BaseGateway__dispatcher._on_user_register( Iq(sfrom="romeo@montague.lit/gajim") ) ) welcome = self.next_sent() assert welcome["body"] stanza = self.next_sent() assert "logging in" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing contacts" in stanza["status"].lower(), stanza stanza = self.next_sent() assert "syncing groups" in stanza["status"].lower(), stanza probe = self.next_sent() assert probe.get_type() == "probe" stanza = self.next_sent() assert "yup" in stanza["status"].lower(), stanza self.send( # language=XML """ """ ) def tearDown(self): super().tearDown() _sessions.clear() @property def romeo_session(self) -> Session: return BaseSession.get_self_or_unique_subclass().from_jid( JID("romeo@montague.lit") ) def test_contact(self): contact = self.run_coro( self.romeo_session.contacts.by_legacy_id(SomeType(1, 2)) ) assert contact.some_type.a == 1 assert contact.some_type.b == 2 def test_muc(self): muc = self.run_coro(self.romeo_session.bookmarks.by_legacy_id(SomeType(1, 2))) assert muc.some_type.a == 1 assert muc.some_type.b == 2 slidge/tests/test_user_nick_invalid_resourcepart.py000066400000000000000000000047061522535037600234050ustar00rootroot00000000000000from slixmpp.exceptions import XMPPError from slidge import ( BaseGateway, BaseSession, LegacyBookmarks, LegacyContact, LegacyParticipant, LegacyRoster, ) from slidge.util.test import SlidgeTest from slidge.util.types import MessageReference class Session(BaseSession): async def login(self): pass class Gateway(BaseGateway): COMPONENT_NAME = "Megacorp Chat XMPP Gateway" GROUPS = True class Contact(LegacyContact): pass class Roster(LegacyRoster): async def legacy_id_to_jid_username(self, legacy_id: str) -> str: if not legacy_id.startswith("room-"): raise XMPPError return legacy_id class Bookmarks(LegacyBookmarks): async def fill(self): self.user_nick = "🎉coquinou" class TestMamCorrection(SlidgeTest): plugin = globals() def setUp(self): super().setUp() self._add_slidge_user() def test_reply_to_user(self) -> None: muc = self.get_joined_muc("room-coquinou") part: LegacyParticipant = self.run_coro(muc.get_user_participant()) part.send_text( "a body", "a-legacy-id", reply_to=MessageReference( "ref", author="user", body="blabla", ), ) self.send( # language=XML """ > 🎉coquinou:\n> blabla\na body 🎉coquinou """ ) slidge/tests/test_util.py000066400000000000000000000166411522535037600160750ustar00rootroot00000000000000import re from datetime import UTC, datetime import pytest from slixmpp import JID from slidge.contact import LegacyContact from slidge.core import config from slidge.core.mixins.presence import get_last_seen_fallback from slidge.util import ( SubclassableOnce, is_valid_phone_number, ) from slidge.util.types import LegacyAttachment, Mention, Reply from slidge.util.util import merge_resources, replace_mentions, strip_leading_emoji def test_subclass(): SubclassableOnce.TEST_MODE = False # fmt: off class A(SubclassableOnce): pass # noqa: E701 with pytest.raises(AttributeError): A.get_unique_subclass() assert A.get_self_or_unique_subclass() is A class B(A): pass # noqa: E701 assert A.get_self_or_unique_subclass() is B with pytest.raises(AttributeError): B.get_unique_subclass() with pytest.raises(RuntimeError): class C(A): pass # noqa: E701 A.reset_subclass() class C(A): pass # noqa: E701 assert A.get_self_or_unique_subclass() is C A.reset_subclass() class D(SubclassableOnce): pass # noqa: E701 assert D.get_self_or_unique_subclass() is D # fmt: on SubclassableOnce.TEST_MODE = True def test_phone_validation(): assert is_valid_phone_number("+33") assert not is_valid_phone_number("+") assert not is_valid_phone_number("+asdfsadfa48919sadf") assert not is_valid_phone_number("12597891") def test_strip_delay(monkeypatch): monkeypatch.setattr(config, "IGNORE_DELAY_THRESHOLD", 300) class MockDelay: @staticmethod def set_stamp(x): pass @staticmethod def set_from(x): pass class MockC: STRIP_SHORT_DELAY = True class xmpp: boundjid = JID("test") class MockMsg: delay_added = None def __getitem__(self, key): if key == "delay": self.delay_added = True return MockDelay msg = MockMsg() LegacyContact._add_delay(MockC, msg, datetime.now()) assert not msg.delay_added monkeypatch.setattr(config, "IGNORE_DELAY_THRESHOLD", 0) msg = MockMsg() LegacyContact._add_delay(MockC, msg, datetime.now()) assert msg.delay_added def test_merge_presence(): assert merge_resources( { "1": { "show": "", "status": "", "priority": 0, } } ) == { "show": "", "status": "", "priority": 0, } assert merge_resources( { "1": { "show": "dnd", "status": "X", "priority": -10, }, "2": { "show": "dnd", "status": "", "priority": 0, }, } ) == { "show": "dnd", "status": "X", "priority": 0, } assert merge_resources( { "1": { "show": "", "status": "", "priority": 0, }, "2": { "show": "away", "status": "", "priority": 0, }, "3": { "show": "dnd", "status": "", "priority": 0, }, } ) == { "show": "", "status": "", "priority": 0, } assert merge_resources( { "1": { "show": "", "status": "", "priority": 0, }, "2": { "show": "away", "status": "", "priority": 0, }, "3": { "show": "dnd", "status": "Blah blah", "priority": 0, }, } ) == { "show": "", "status": "Blah blah", "priority": 0, } assert merge_resources( { "1": { "show": "", "status": "", "priority": 0, }, "2": { "show": "away", "status": "Blah", "priority": 0, }, "3": { "show": "dnd", "status": "Blah blah", "priority": 10, }, } ) == { "show": "", "status": "Blah blah", "priority": 0, } assert merge_resources( { "1": { "show": "", "status": "", "priority": 0, }, "2": { "show": "away", "status": "Blah", "priority": 0, }, "3": { "show": "dnd", "status": "", "priority": 10, }, } ) == { "show": "", "status": "Blah", "priority": 0, } def test_replace_mentions(): mentions = [] text = "Text Mention 1 and Mention 2, and Mention 3" for match in re.finditer("Mention 1|Mention 2|Mention 3", text): span = match.span() nick = match.group() mentions.append( Mention(contact=f"Contact{nick[-1]}", start=span[0], end=span[1]) ) assert ( replace_mentions(text, mentions, lambda c: "@" + c[-1]) == "Text @1 and @2, and @3" ) mentions = [] text = "Text Mention 1 and Mention 2, and Mention 3 blabla" for match in re.finditer("Mention 1|Mention 2|Mention 3", text): span = match.span() nick = match.group() mentions.append( Mention(contact=f"Contact{nick[-1]}", start=span[0], end=span[1]) ) assert ( replace_mentions(text, mentions, lambda c: "@" + c[-1]) == "Text @1 and @2, and @3 blabla" ) def test_strip_emoji(): try: import emoji # noqa: F401 except ImportError: return assert strip_leading_emoji("🛷️ Slidge administration") == "Slidge administration" assert strip_leading_emoji("no emoji") == "no emoji" assert strip_leading_emoji("no") == "no" assert strip_leading_emoji("👤 Contacts") == "Contacts" assert strip_leading_emoji("👥 Groups") == "Groups" def test_last_seen_fallback_formatting(): now = datetime.now(tz=UTC) res, should_update = get_last_seen_fallback(now) assert should_update assert "AM" in res or "PM" in res def test_attachment_str(tmp_path): path = tmp_path / "file" path.write_text("") att = LegacyAttachment(path=path) assert str(att) == f"Attachment(path={path!r}, is_sticker=False)" att = LegacyAttachment(url="https://url", legacy_file_id="xxx") assert ( str(att) == "Attachment(legacy_file_id='xxx', url='https://url', is_sticker=False)" ) att = LegacyAttachment(data=b"aaaaa", legacy_file_id="xxx") assert ( str(att) == "Attachment(legacy_file_id='xxx', is_sticker=False, data=<5 bytes>)" ) att = LegacyAttachment(data=b"aaaaa") assert str(att) == "Attachment(is_sticker=False, data=<5 bytes>)" @pytest.mark.parametrize( "in_,out", [ (None, None), ("bla", "bla"), ("bla\nbla", "bla\nbla"), (">bla\n>bla", "bla\nbla"), ("> bla\n> bla", "bla\nbla"), ("> bla\nbla", "bla\nbla"), ], ) def test_strip_quote_mark(in_: str, out: str) -> None: assert Reply(msg_id="x", to=None, fallback=in_).fallback_no_quote_mark == out slidge/tests/test_vcard.py000066400000000000000000000171141522535037600162130ustar00rootroot00000000000000import pytest from conftest import AvatarFixtureMixin from slidge import BaseGateway, BaseSession from slidge.contact import LegacyContact from slidge.util.test import SlidgeTest class Session(BaseSession): async def login(self): return "YUP" class Gateway(BaseGateway): COMPONENT_NAME = "A test" class Contact(LegacyContact): async def update_info(self): if self.legacy_id == "has-vcard": self.set_vcard(full_name="A full name", pronouns="they/fae") async def fetch_vcard(self): if self.legacy_id == "on-demand": self.set_vcard(full_name="Lazy") @pytest.mark.usefixtures("avatar") class TestSession(AvatarFixtureMixin, SlidgeTest): plugin = globals() xmpp: Gateway def setUp(self): super().setUp() self.setup_logged_session() def _assert_broadcast_on_demand(self): self.send( # language=XML """ xmpp:on-demand@aim.shakespeare.lit Lazy """ ) def test_vcard_in_update_info(self): self.run_coro(self.romeo.contacts.by_legacy_id("has-vcard")) self.send( # language=XML """ xmpp:has-vcard@aim.shakespeare.lit A full name they/fae """ ) assert self.next_sent() is None def test_vcard_outside_update_info(self): self.juliet.set_vcard("Another full name") self.send( # language=XML """ xmpp:juliet@aim.shakespeare.lit Another full name """ ) assert self.next_sent() is None def test_fetch_raw_iq(self): self.run_coro(self.romeo.contacts.by_legacy_id("on-demand")) assert self.next_sent() is None self.recv( # language=XML """ """ ) self.send( # language=XML """ xmpp:on-demand@aim.shakespeare.lit Lazy """, use_values=False, ) self._assert_broadcast_on_demand() assert self.next_sent() is None def test_fetch_raw_iq_empty(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """, use_values=False, ) assert self.next_sent() is None def test_fetch_pubsub(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ xmpp:on-demand@aim.shakespeare.lit Lazy """ ) self._assert_broadcast_on_demand() assert self.next_sent() is None def test_fetch_pubsub_empty(self): self.recv( # language=XML """ """ ) self.send( # language=XML """ """ ) assert self.next_sent() is None slidge/uv.lock000066400000000000000000021235711522535037600136540ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] [[package]] name = "accessible-pygments" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] [[package]] name = "aiodns" version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycares" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/22/a2d928e0e42baad0471d12ec44c71152ac870486e8298dddb2893b888c29/aiodns-4.0.4.tar.gz", hash = "sha256:cb10e0c0d2591636716ad2fe402e977c16d71bdaf76bb8cb49e8a6633596f736", size = 29918, upload-time = "2026-05-20T01:54:15.557Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] name = "aiohttp" version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [package.optional-dependencies] speedups = [ { name = "aiodns", marker = "sys_platform != 'android' and sys_platform != 'ios'" }, { name = "backports-zstd", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios'" }, { name = "brotli", marker = "platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios'" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, ] [[package]] name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "alabaster" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] name = "alembic" version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] name = "anyio" version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] name = "ast-serialize" version = "0.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] name = "astroid" version = "4.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/da/fd/24475b7cfb70298e8921bc077adb46a3fe77887422545d8a061573e130ee/astroid-4.1.2.tar.gz", hash = "sha256:d6c4a52bfcda4bbeb7359dead642b0248b90f7d9a07e690230bd86fefd6d37f1", size = 414896, upload-time = "2026-03-22T19:16:42.075Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/97/4ee9b0438e85bf0a808a89ef0be357319252ab27e1b313ae0aef7aeaa5a6/astroid-4.1.2-py3-none-any.whl", hash = "sha256:21312e682c0866dc5a309ee57e4b88ea92751b9955a58b1c31371cbbeb088707", size = 279956, upload-time = "2026-03-22T19:16:40.062Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "backports-zstd" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/b5/5a873da082bd08acd6a497f7aae224e94a7c27fa8f24488089cc50a16c84/backports_zstd-1.6.0.tar.gz", hash = "sha256:80a7859ffe70bf239d7a2ce15293bdeb5b4280ff7dc326ffab312b0e254dbb24", size = 1000009, upload-time = "2026-06-14T10:50:58.555Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/90/428dd82228b1b6d62d5a1bf312c29e6c125af6a182fcfd82768ca179dcc7/backports_zstd-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4fc41b2df5529cad5ceb230319e82728096d4b353ce8d4df68a2ec37e291bb8", size = 437067, upload-time = "2026-06-14T10:49:28.335Z" }, { url = "https://files.pythonhosted.org/packages/ef/48/768edf21fe33bae8d874470b1be136681d4d32eb820a32e1c98262ebe39b/backports_zstd-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:83391ef5935cc0f329b1abca414ae20ffe40d335fc21a4b5e664f08a74317d5f", size = 363454, upload-time = "2026-06-14T10:49:29.784Z" }, { url = "https://files.pythonhosted.org/packages/29/8a/d462c2e5071eb573378f0d26760f6590613086fdf59c2d3c66bdfffb9f41/backports_zstd-1.6.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7d3f64c503af7b60115b97c16feaf75bd191ef2c978d5c0c7725a6682bef63c5", size = 507393, upload-time = "2026-06-14T10:49:31.077Z" }, { url = "https://files.pythonhosted.org/packages/b9/cb/af58363b0dd0b497282ecef1fa99789b03cc1885a01a41394cad42ceeff6/backports_zstd-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0308990ffc998df3c7ed35276bde049728b5c3956203cae40d80893576a41459", size = 476957, upload-time = "2026-06-14T10:49:32.53Z" }, { url = "https://files.pythonhosted.org/packages/e4/fd/5fbdf2275cefae95c4b3509f6db2dc372d0587ebafea342d28781d51d932/backports_zstd-1.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c298785e2fadeab82342040f2d9ce764ce500e6da6a6d99a2de514e63580b5a", size = 582618, upload-time = "2026-06-14T10:49:33.723Z" }, { url = "https://files.pythonhosted.org/packages/99/6f/7dd45c53c907ea67f635c3900b58bb3347c01dc2ded441402028aae0ef9c/backports_zstd-1.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae106fe16e36efc60ab098d02478d30aa0e31e1420eb4ecf0116459253bc6361", size = 642279, upload-time = "2026-06-14T10:49:34.938Z" }, { url = "https://files.pythonhosted.org/packages/4d/25/a9e37dd035027565fa0b7e367da50e88a6ab26e7fd413269aa118e25258b/backports_zstd-1.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7293fefe15f0e5852bdb4ad1e0e26f3cbd4d3e61c19f751ecc4ff34bc1eb237d", size = 492486, upload-time = "2026-06-14T10:49:36.06Z" }, { url = "https://files.pythonhosted.org/packages/a1/52/659686bf8f7c53ea279e1c44038504b82a6901cee2f5ae83c30bbf581301/backports_zstd-1.6.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ece8e7288db5b827ef8c64b2f78519f1a173a8991a625978fce02eccd7654fe9", size = 566440, upload-time = "2026-06-14T10:49:37.536Z" }, { url = "https://files.pythonhosted.org/packages/d9/1a/c7ea5a0ff607a1a6066bb7c7cb65ae20e2f85da6adc69ab77fd8943e180c/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28eef3881164f3c23ce58ed59e4684103bdd279583eb2d299858c9e9b72fde9a", size = 482899, upload-time = "2026-06-14T10:49:38.805Z" }, { url = "https://files.pythonhosted.org/packages/83/48/bd2b91100ee4fe6bb4d816e3659cbbb0cda5dd32760d2379c54d1752ec25/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:481a1e9bd8f419fdc625307aa20234687f99368c75df511ef589693c5fea4c6f", size = 510826, upload-time = "2026-06-14T10:49:40.062Z" }, { url = "https://files.pythonhosted.org/packages/25/fe/fa28509d7ce2ad59404e7ce738a2fd858e12dfd9a896629f10330222a7fb/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3b6713371f8987a1178df93cb36f29eef191f224021e2d656b2f11ce60d26816", size = 586941, upload-time = "2026-06-14T10:49:41.305Z" }, { url = "https://files.pythonhosted.org/packages/45/28/757daf2399aa71bb37f9f7f48b42ab03fc51c340eccfad2fec92a23f6aa3/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b0ddbcd2866b8ff1a2836e4b0e4d44788f5b992d83fac75a38cda8f9a2bee079", size = 564261, upload-time = "2026-06-14T10:49:42.49Z" }, { url = "https://files.pythonhosted.org/packages/4e/53/9b9db30cb2c148a69c40ad7647aa787338041f3dc81c5b22113286e590e9/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2914abea516704bdafb2090acd3f15b5f9debecfabd15b8dd8285b2ad3b92209", size = 632869, upload-time = "2026-06-14T10:49:43.981Z" }, { url = "https://files.pythonhosted.org/packages/81/a4/1692fbb88af8aaf900a53619fcc95c9e45d9ff162223a47fd672a9893c8d/backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd085eafa2aac6f883afd28210a3231f717f25409a1e44a39bb7b04c8c5b5646", size = 496496, upload-time = "2026-06-14T10:49:45.118Z" }, { url = "https://files.pythonhosted.org/packages/93/42/c5a66c47320bd12ce84a7341330ea582d67069bdb70214bca0b6bf394cfd/backports_zstd-1.6.0-cp311-cp311-win32.whl", hash = "sha256:b81b4cf3d6e0ad7ac92bef248f49fafc954262c5fb0f7e19d6aac497e5a856b2", size = 291613, upload-time = "2026-06-14T10:49:46.473Z" }, { url = "https://files.pythonhosted.org/packages/2a/f2/f22c19b4cdde429805ff5ac8dd77a95569a7c4cb8991741b2ff0d538f220/backports_zstd-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:10b61850c4112952e05aa6e6cce8c9a5936fbeadb321e154216705cc76a14afa", size = 329078, upload-time = "2026-06-14T10:49:47.71Z" }, { url = "https://files.pythonhosted.org/packages/ef/dc/e902a3f1eb92c4907b5f47f90cb3c2734ee315c4ff67179fc111343b45ba/backports_zstd-1.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:068ef3d8c18815a2e3a752f766313e19910e7c50939b956923748d9c04ebcb1b", size = 291727, upload-time = "2026-06-14T10:49:48.929Z" }, { url = "https://files.pythonhosted.org/packages/1e/bb/009af3a9532d4cc66d5385391c512210fae32ab2442605f26aca1d8d2957/backports_zstd-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0466b14723f3b7697669c00ee66fe16e30e25636b286b0a923fa86fa3d8a753c", size = 437407, upload-time = "2026-06-14T10:49:50.155Z" }, { url = "https://files.pythonhosted.org/packages/0c/76/f7c02efde81ebb9993586f9e435d2fd1191a6f806f640e4eeb8d004493ed/backports_zstd-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d146926e997d2d3de8212bdcbf4985344a2622ca3bec458d8908000a84fd883", size = 363519, upload-time = "2026-06-14T10:49:51.383Z" }, { url = "https://files.pythonhosted.org/packages/2e/5e/0cf66f12472fe3e082cc4134395a7e0b8746cfb30aabd74251ce8fafa9a7/backports_zstd-1.6.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:460fd6b3f338c659507ae36cfd6b58ac9942a2ff233c5cf574416dfec0451a84", size = 507756, upload-time = "2026-06-14T10:49:52.497Z" }, { url = "https://files.pythonhosted.org/packages/03/95/7ed25c90369360f96f8bfa961540845e063377c32a43b775201af66a588c/backports_zstd-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c2b1f4a640c51130caa92cef5bf72bd3c3dbbcfbf814c37403aa0601b1811b0", size = 477578, upload-time = "2026-06-14T10:49:53.887Z" }, { url = "https://files.pythonhosted.org/packages/e3/75/f16b1d3e33ca396525847c81d96e3de7bc74d2c6f9ca2ddee76b0c450697/backports_zstd-1.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:beb43e9885202c8d4f3762319ed4d5e98e197622afbff8439fbbdd81d08938b9", size = 583029, upload-time = "2026-06-14T10:49:55.132Z" }, { url = "https://files.pythonhosted.org/packages/e1/2b/a17b111b631e1c79a0e570881c1a266c661b936585afa395435a458b1991/backports_zstd-1.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fbb746522ebfc11155f1cd688e2c48ef3d74125e38b63eabdaab068a055c3e88", size = 641741, upload-time = "2026-06-14T10:49:56.42Z" }, { url = "https://files.pythonhosted.org/packages/6b/b2/d17b2722c636d64b4e77ddc68d8d0625719d39f94021be8719a218af4c0a/backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93", size = 495554, upload-time = "2026-06-14T10:49:57.652Z" }, { url = "https://files.pythonhosted.org/packages/63/12/2853e8b6c03f03795b6548ea61f82cc104d4f7ff2523a04bc69f46984663/backports_zstd-1.6.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f69365ee2b836939137de024a302395a1cb8654fb6dc5ffef6381105259c8f87", size = 570027, upload-time = "2026-06-14T10:49:59.003Z" }, { url = "https://files.pythonhosted.org/packages/18/aa/83f37b81f3b8c6ea035bf260ec374648bd59372894c02323dc9de3cbdf77/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66cf8038893c7708ec345ffb3ac63c775d10f430f323ac2f0334fdb6a397c57c", size = 483594, upload-time = "2026-06-14T10:50:00.49Z" }, { url = "https://files.pythonhosted.org/packages/f5/6a/d77f8cd2ff642d3b3652c1ccab5b6583114dbf10f8cb0143531357c83998/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e514c71ca72f3b56bd8fbda1a6a5b7d1100a2764b42a3c74a38841f25f9b00ab", size = 511206, upload-time = "2026-06-14T10:50:01.86Z" }, { url = "https://files.pythonhosted.org/packages/56/b2/99a60fe4d1aac8053769d2463271d5df37a7c11c387072fdbb0b16aed7f7/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7741e44f7938ec94f9a52678c8d19b7bc548522ffdc39c9e4481af8db545fa9a", size = 587416, upload-time = "2026-06-14T10:50:03.236Z" }, { url = "https://files.pythonhosted.org/packages/ec/1e/a9c003fe4d14bd4bf671598d4c7dcc1cef51e3513d9d7111ba1d07b6f07b/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97e8a9674652496c7612b528085dd5a296c052a2edc466ca1bfb7b0b27820413", size = 567615, upload-time = "2026-06-14T10:50:04.524Z" }, { url = "https://files.pythonhosted.org/packages/ec/b9/955bd604f692c550c7cb66d00bd7691ead5c86df8ebd23d7254eeaa90789/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:23a793f2fed4dbf0517319759a2cded0b0dd8e8d3797fe30badd5693e320c175", size = 632269, upload-time = "2026-06-14T10:50:05.86Z" }, { url = "https://files.pythonhosted.org/packages/18/d7/9f61f612f8a4193484c78a1f26db82a50141234189885113ef0085a8a961/backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b951113113ed4b8d173418a4f155c14b739dace626b3fa3f82be1831958d39e4", size = 500066, upload-time = "2026-06-14T10:50:07.446Z" }, { url = "https://files.pythonhosted.org/packages/81/a3/19fb8c48d94139481c5ccaf2fb54c31b543fa635fd7bd7399aadd15752ac/backports_zstd-1.6.0-cp312-cp312-win32.whl", hash = "sha256:6430b34a2ae6fcc604672f4f913102563473d9a015bdca1ce8c95041cc1f2677", size = 291825, upload-time = "2026-06-14T10:50:08.762Z" }, { url = "https://files.pythonhosted.org/packages/58/38/40ba081c6c71f0f22c64d3d54b912ad75a4e6812caa1397cbb15b5693b12/backports_zstd-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:08793876172551a930ce4d65c712cd516184d1a97070d4a1193e05bf0cf7040d", size = 329201, upload-time = "2026-06-14T10:50:09.979Z" }, { url = "https://files.pythonhosted.org/packages/2b/6c/f7116dd2edc6f960545f0d8616939eae3a20031b3b6669697d4f9fd83b2e/backports_zstd-1.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:03b7c59c71f7a597e2bcb3f8368371e9a660a1bdf1c37afc1f1ad1496a013c19", size = 291901, upload-time = "2026-06-14T10:50:11.198Z" }, { url = "https://files.pythonhosted.org/packages/7c/39/87a665244a65f5b87a06b848c29a8cce07e91d59c5988ee2a32c0293a21c/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:30576f49b82328ec8af16c11100efe52ca88526f71bbe100ef6b4e707dc13bf2", size = 357457, upload-time = "2026-06-14T10:50:14.906Z" }, { url = "https://files.pythonhosted.org/packages/7f/8b/854d4a47bb8b7a48bfb2ed381c7b03a70efb4fc49f0e4a1509b38a2e1727/backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b4bddfcfb6679215d6f4dc5f79a1f9301af339480d70527a14b57a1f2e6b6cbf", size = 366139, upload-time = "2026-06-14T10:50:16.399Z" }, { url = "https://files.pythonhosted.org/packages/8f/de/c3af43eb8df6f2581e157e18a3e0121eadb826055b2fde3f91ec188689cb/backports_zstd-1.6.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:65048ed08c5124f05ff9f355ab9703014bb2dbe7f8d9948ce193685b1775f442", size = 446683, upload-time = "2026-06-14T10:50:17.633Z" }, { url = "https://files.pythonhosted.org/packages/5c/39/87cf3d883d386c10ac52f5322604fb9afdd204229f4c47d4a820a839b8ff/backports_zstd-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5918fc6b31437208721276964323933cd86077b8d5b469c59c1b3fd2c8220a05", size = 436869, upload-time = "2026-06-14T10:50:19.113Z" }, { url = "https://files.pythonhosted.org/packages/5e/b6/9479e6f0f18824ad38e8d7dd85161ab0842a198be669421232925bb30960/backports_zstd-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b6c8b02ab0ccb2431bb7bc238be91d158b308915e7b07937388e540466fe7e7", size = 363090, upload-time = "2026-06-14T10:50:20.302Z" }, { url = "https://files.pythonhosted.org/packages/d9/74/a5e98fe108e17c91d9bc590a19e77f5d47d579e34d3f5bc098a949d6c27c/backports_zstd-1.6.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:711e6b98f8924e8b4a61ff97ab6321f33de024e1ed6a32f5123763aeda8459be", size = 507070, upload-time = "2026-06-14T10:50:21.536Z" }, { url = "https://files.pythonhosted.org/packages/69/f5/392bb7dce7363b77bc5403060f418fad438b9cfdd3edd10d65cee7d8fd11/backports_zstd-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ba9ac10fc393e5123a08802e0e895a107cb4a66b9973d2844dbd8a343111e59", size = 477200, upload-time = "2026-06-14T10:50:22.91Z" }, { url = "https://files.pythonhosted.org/packages/e4/4d/dfb665806ba4f74bc48071d32006843b53568c4a17ff627a3061de5eaa09/backports_zstd-1.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f723219335387d7546412d8141e0303590600949b4184a1391a0c6a3c756058", size = 582724, upload-time = "2026-06-14T10:50:24.28Z" }, { url = "https://files.pythonhosted.org/packages/57/b2/beeca7393a8310debd82ee2f0ce5c1801e8d7cb673f7f226f4a0866ca238/backports_zstd-1.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64b94d7a836568926a3309ff510c7f8261b881b341fd4992cabf4f0998878f8a", size = 643493, upload-time = "2026-06-14T10:50:25.736Z" }, { url = "https://files.pythonhosted.org/packages/38/26/ce90e9eed6f25aaa4a4fa305a2aaf2d2ad81fd69de8eb248ddd91c80d1e0/backports_zstd-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e39258a09b1c7ca70b5e94a5c5ccfe4700b4250b8077cfeab31d0f79565d4c9b", size = 492190, upload-time = "2026-06-14T10:50:27.205Z" }, { url = "https://files.pythonhosted.org/packages/17/9b/37b9b146df1f5452419a96071a7017cbac212ec9b137d7a88ca46dc2aa9e/backports_zstd-1.6.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15b1aae0f64cd742df4bba1d989d0a09a6ec619202543fdba684640454541fd3", size = 567432, upload-time = "2026-06-14T10:50:28.386Z" }, { url = "https://files.pythonhosted.org/packages/06/66/81b30991be83237529f36335ac3682bce26409064b906ac6122874575196/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25b5ddc789480072551af571a746e9500356b2aff0499861cf2ca07ea7431e68", size = 483021, upload-time = "2026-06-14T10:50:29.654Z" }, { url = "https://files.pythonhosted.org/packages/49/2a/792c65dcc1e45eb0c1bdc012ee94b84867186bfe27a860d0813bd216f03b/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a13cfa3410a75e4cb87abdb669aaf79da861cb79299159054ff8f77b9671bc40", size = 510596, upload-time = "2026-06-14T10:50:31.657Z" }, { url = "https://files.pythonhosted.org/packages/1d/22/01b92a600505620e4cb5f20429e181f30458b7207ca8b52ca5ca6068c35f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2ddab55a5f54dec8acfad68ef70f1c704fd21919990ddc238afbd6f496e61c6a", size = 587143, upload-time = "2026-06-14T10:50:32.868Z" }, { url = "https://files.pythonhosted.org/packages/d8/60/4672f5110b9eb01388cc6225a739e3a5fcd749a63a9c4c1450a04fa27113/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fa305a84087e10d7a85e8a8a3dcba8cdbda4868f2180173b264b7b488fd37c55", size = 565238, upload-time = "2026-06-14T10:50:34.173Z" }, { url = "https://files.pythonhosted.org/packages/5c/3b/19928d60ea7d25820bf12ef88de74534ca85b56ff7cf13c1b0e74e3a3d7c/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:df27b57d214a3124fbe4e933ef5a903d4567f154260d9aece8c797a987f2a205", size = 633970, upload-time = "2026-06-14T10:50:35.506Z" }, { url = "https://files.pythonhosted.org/packages/df/97/c4cecb3e0ff53563ef9819f0395d919ceaae9c5147392ac23bac7afdb20f/backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28fecd73459d74910ae1987ab84b7bef690d3dd860948430dd5555108b006daf", size = 496539, upload-time = "2026-06-14T10:50:37.015Z" }, { url = "https://files.pythonhosted.org/packages/eb/f4/46b2f29d2938a80e56e61a19f11ab093f531a9f8cd0ec8eeaac1246bcd99/backports_zstd-1.6.0-cp313-cp313-win32.whl", hash = "sha256:3e689af303df287142770abe3a48bbefd24dab4a09da5807d0e1fa8c75bab026", size = 291451, upload-time = "2026-06-14T10:50:38.518Z" }, { url = "https://files.pythonhosted.org/packages/d1/ad/b529f92166da61f496621345f95d2dc583c8ca5ac553c084a4ef6c12cd71/backports_zstd-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b067b1ef9c8e41fb0882c828aa37829938b5c0dab067eca72b23fc24c563b9da", size = 329023, upload-time = "2026-06-14T10:50:39.742Z" }, { url = "https://files.pythonhosted.org/packages/30/d8/6be904d20345fbebec583ca83676e01f30c76118b283eb666d8ec8291ca1/backports_zstd-1.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:a838296f5b84c920172fb579cac894d255c1fc25457c7234613ddcfa385e49b7", size = 291636, upload-time = "2026-06-14T10:50:41.004Z" }, { url = "https://files.pythonhosted.org/packages/e8/09/898fe2f8196fa7ab825f5fed786c68581fdac7d23a8e20baa0cc01cb2f0b/backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aeef8563b82ed4af328f98e5041c1b4800d86f68f857ffd1577d4d47dc9aa6cd", size = 411023, upload-time = "2026-06-14T10:50:50.286Z" }, { url = "https://files.pythonhosted.org/packages/6e/ad/6ad9af1596ab5f284bb53954be41396e13d23c81cdfe3d945402e8ee0215/backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb75e33131946fabd6319061df3b8b1d588fe0963183280e9b5f49f7772fc09", size = 340554, upload-time = "2026-06-14T10:50:51.523Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/f083d7c8a4ee5d0bb21b4d3144e76de9f655ca4dd0bffcb95baa5bc47a62/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ef132cfb638e9a86bd5dc07fb4e1cb895bc55bce6bb5e759366e8b160d0747e2", size = 421694, upload-time = "2026-06-14T10:50:52.917Z" }, { url = "https://files.pythonhosted.org/packages/41/d7/693b20f3ccae2e05d166f98fe55b1657451170b72c804ed9f6b98df520be/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab70eace272d6f122b121c057e436709b50a28abf30d97aab28433c08f4a4095", size = 395237, upload-time = "2026-06-14T10:50:54.448Z" }, { url = "https://files.pythonhosted.org/packages/53/a1/484e0f9ec994bd2285d6747e7c8028350f1a177e9210bc57637898042d3b/backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17efb3d11137de5166dd51eedab9c36ad633402acba386eee8d715213ea47e49", size = 415201, upload-time = "2026-06-14T10:50:55.854Z" }, { url = "https://files.pythonhosted.org/packages/3c/56/70860ece85cd49b564305cbc22bf6c4183975427ff6dfe2097e855f5dd5e/backports_zstd-1.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:994167ff6551b9c1ce226e0aab16295b98c94507b5701aa60d2c32b7d50796b1", size = 315721, upload-time = "2026-06-14T10:50:57.074Z" }, ] [[package]] name = "beautifulsoup4" version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "brotli" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, ] [[package]] name = "brotlicffi" version = "1.2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, ] [[package]] name = "certifi" version = "2026.6.17" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "configargparse" version = "1.7.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, ] [[package]] name = "coverage" version = "7.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] name = "distlib" version = "0.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "emoji" version = "2.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, ] [[package]] name = "filelock" version = "3.29.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "furo" version = "2025.12.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] [[package]] name = "greenlet" version = "3.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "imagesize" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "librt" version = "0.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] [[package]] name = "mako" version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "mypy" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] name = "platformdirs" version = "4.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "propcache" version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "psycopg2-binary" version = "2.9.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" }, { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" }, { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" }, { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" }, { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" }, { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" }, { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" }, { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" }, { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" }, { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" }, { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" }, { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] [[package]] name = "pyasn1" version = "0.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycares" version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/a0/9c823651872e6a0face3f0311de2a40c8bbcb9c8dcb15680bd019ac56ac7/pycares-5.0.1.tar.gz", hash = "sha256:5a3c249c830432631439815f9a818463416f2a8cbdb1e988e78757de9ae75081", size = 652222, upload-time = "2026-01-01T12:37:00.604Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/87/78/43b09f4b8e5fb8a6024661b458b48987abdb39304c78117b106b10a029f1/pycares-5.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c29ca77ff9712e20787201ca8e76ad89384771c0e058a0a4f3dc05afbc4b32de", size = 136177, upload-time = "2026-01-01T12:35:11.567Z" }, { url = "https://files.pythonhosted.org/packages/19/05/194c0e039ff52b166b50e79ff166c61f931fbca2bf94fc0dbaaf39041518/pycares-5.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f11424bf5cf6226d0b136ed47daa58434e377c61b62d0100d1de7793f8e34a72", size = 130960, upload-time = "2026-01-01T12:35:12.828Z" }, { url = "https://files.pythonhosted.org/packages/0d/84/5fce65cc058c5ab619c0dd1370d539667235a5565da72ca77f3f741cdc70/pycares-5.0.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d765afb52d579879f5c4f005763827d3b1eb86b23139e9614e6089c9f98db017", size = 220584, upload-time = "2026-01-01T12:35:14.005Z" }, { url = "https://files.pythonhosted.org/packages/f6/74/d82304297308f6c24a17961bf589b53eefa5f7f2724158c842c67fa0b302/pycares-5.0.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea0d57ba5add4bfbcc40cbdfa92bbb8a5ef0c4c21881e26c7229d9bdc92a4533", size = 252166, upload-time = "2026-01-01T12:35:15.293Z" }, { url = "https://files.pythonhosted.org/packages/39/a2/0ead3ba4228a490b52eb44d43514dae172c90421bb30a3659516e5b251a2/pycares-5.0.1-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9ec2aa3553d33e6220aeb1a05f4853fb83fce4cec3e0dea2dc970338ea47dc", size = 239085, upload-time = "2026-01-01T12:35:16.594Z" }, { url = "https://files.pythonhosted.org/packages/26/ad/e59f173933f0e696a6afbbd63935114d1400524a72da4f2cbafc6002a398/pycares-5.0.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c63fb2498b05e9f5670a1bf3b900c5d09343b3b6d5001a9714d593f9eb54de1", size = 222936, upload-time = "2026-01-01T12:35:17.521Z" }, { url = "https://files.pythonhosted.org/packages/98/fa/d85bfe663a9c292efd8e699779027612c0c65ff50dc4cc9eb7a143613460/pycares-5.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71316f7a87c15a8d32127ff01374dc2c969c37410693cc0cf6532590b7f18e7a", size = 223506, upload-time = "2026-01-01T12:35:18.535Z" }, { url = "https://files.pythonhosted.org/packages/2a/6b/4c225a5b10a4c9f88891a20bfe363eca1b1ce7d5244b396e5683c6070998/pycares-5.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a2117dffbb78615bfdb41ad77b17038689e4e01c66f153649e80d268c6228b4f", size = 251633, upload-time = "2026-01-01T12:35:19.819Z" }, { url = "https://files.pythonhosted.org/packages/26/ce/ba2349413b5197b72ec19c46e07f6be3a324f80a7b1579c7cbb1b82d6dc2/pycares-5.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7d7c4f5d8b88b586ef2288142b806250020e6490b9f2bd8fd5f634a78fd20fcf", size = 237703, upload-time = "2026-01-01T12:35:20.827Z" }, { url = "https://files.pythonhosted.org/packages/84/2f/1fd794e6fca10d9e20569113d10a4f92cc2b4242d3eb45524419a37cca6b/pycares-5.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433b9a4b5a7e10ef8aef0b957e6cd0bfc1bb5bc730d2729f04e93c91c25979c0", size = 222622, upload-time = "2026-01-01T12:35:22.518Z" }, { url = "https://files.pythonhosted.org/packages/c9/07/7db7977649b210092a7e02d550fcebdfa69bc995c684a3b960c88a5dc4ce/pycares-5.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:cf2699883b88713670d3f9c0a1e44ac24c70aeace9f8c6aa7f0b9f222d5b08a5", size = 117438, upload-time = "2026-01-01T12:35:23.402Z" }, { url = "https://files.pythonhosted.org/packages/fc/ca/f322ddaa8b3414667de8faeea944ce9d3ddfaf1455839f499a21fcea4cec/pycares-5.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:9528dc11749e5e098c996475b60f879e1db5a6cb3dd0cdc747530620bb1a8941", size = 108920, upload-time = "2026-01-01T12:35:24.599Z" }, { url = "https://files.pythonhosted.org/packages/75/67/e84ba11d3fec3bf1322c3b302c4df13c85e0a1bc48f16d65cd0f59ad9853/pycares-5.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ee551be4f3f3ac814ac8547586c464c9035e914f5122a534d25de147fa745e1", size = 136241, upload-time = "2026-01-01T12:35:25.439Z" }, { url = "https://files.pythonhosted.org/packages/ce/ae/50fbb3b4e52b9f1d16a36ffabd051ef8b2106b3f0a0d1c1113904d187a9d/pycares-5.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:252d4e5a52a68f825eaa90e16b595f9baee22c760f51e286ab612c6829b96de3", size = 131069, upload-time = "2026-01-01T12:35:26.293Z" }, { url = "https://files.pythonhosted.org/packages/0e/ea/f431599f1ac42149ea4768e516db7cdae3a503a6646319ae63ab66da1486/pycares-5.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1aa549b8c2f2e224215c793d660270778dcba9abc3b85abbc7c41eabe4f1e5", size = 221120, upload-time = "2026-01-01T12:35:27.143Z" }, { url = "https://files.pythonhosted.org/packages/6e/4f/0a7a6c8b3a64ee5149e935c167cd8ba5d1fdd766ec03e273dbc7502f7bea/pycares-5.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db7c9c9f16e8311998667a7488e817f8cbeedec2447bac827c71804663f1437e", size = 252228, upload-time = "2026-01-01T12:35:28.443Z" }, { url = "https://files.pythonhosted.org/packages/49/3d/7f9fd20e97ee30c4b959f87ab26e47ddcef666e5e7717e45f2245fe9d70a/pycares-5.0.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9c4c8bb69bab863f677fa166653bb872bfa5d5a742f1f30bebc2d53b6e71db", size = 239473, upload-time = "2026-01-01T12:35:29.794Z" }, { url = "https://files.pythonhosted.org/packages/a4/d0/c67967a10abd89529cb9aded9d73f43e5de00cf21243638ef529f6757262/pycares-5.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09ef90da8da3026fcba4ed223bd71e8057608d5b3fec4f5990b52ae1e8c855cc", size = 223831, upload-time = "2026-01-01T12:35:30.781Z" }, { url = "https://files.pythonhosted.org/packages/4f/9a/94aacaf22a20b7d342c8f18bf006be57967beef6319adc668d4d86b627be/pycares-5.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ce193ebd54f4c74538b751ebb0923a9208c234ff180589d4d3cec134c001840e", size = 223963, upload-time = "2026-01-01T12:35:31.691Z" }, { url = "https://files.pythonhosted.org/packages/e6/e1/3666aab6fc5e7d0c669b981fe0407e6a4b67e4e6a37ac429d440274663d5/pycares-5.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36b9ff18ef231277f99a846feade50b417187a96f742689a9d08b9594e386de4", size = 251813, upload-time = "2026-01-01T12:35:32.918Z" }, { url = "https://files.pythonhosted.org/packages/94/44/ddab5fbc16ad0084a827167ae8628f54c7a55ce6b743585e6f47a5dd527e/pycares-5.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5e40ea4a0ef0c01a02ef7f7390a58c62d237d5ad48d36bc3245e9c2ac181cc22", size = 238181, upload-time = "2026-01-01T12:35:34.078Z" }, { url = "https://files.pythonhosted.org/packages/66/27/05467933e0e5c4e712302a2d7499797bc3029bf4d0d8ffbfe737254482b7/pycares-5.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f323b0ddfd2c7896af6fba4f8851d34d3d13387566aa573d93330fb01cb1038", size = 223552, upload-time = "2026-01-01T12:35:35.076Z" }, { url = "https://files.pythonhosted.org/packages/3e/e2/14f3837e943d46ee12441fe6aaa418fdb2f698d42e179f368eaa9829744b/pycares-5.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:bdc6bcafb72a97b3cdd529fc87210e59e67feb647a7e138110656023599b84da", size = 117478, upload-time = "2026-01-01T12:35:36.133Z" }, { url = "https://files.pythonhosted.org/packages/d3/c3/3284061f18188d5085338e1f1fd4f03d9c135657acf16f8020b9dd3be5fc/pycares-5.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:f8ef4c70c1edaf022875a8f9ff6c0c064f82831225acc91aa1b4f4d389e2e03a", size = 108889, upload-time = "2026-01-01T12:35:37.135Z" }, { url = "https://files.pythonhosted.org/packages/92/0a/6bd9bdc2d0ee23ff3aabab7747212e2c5323a081b9b745624d62df88f7e9/pycares-5.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d1b2c6b152c65f14d0e12d741fabb78a487f0f0d22773eede8d8cfc97af612b", size = 136242, upload-time = "2026-01-01T12:35:38.372Z" }, { url = "https://files.pythonhosted.org/packages/18/2a/2e9f888fc076cfe7a3493a3c4113e787cc4b4533f531dfb562ac9b04898f/pycares-5.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c8ffcc9a48cfc296fe1aefc07d2c8e29a7f97e4bb366ce17effea6a38825f70", size = 131070, upload-time = "2026-01-01T12:35:39.262Z" }, { url = "https://files.pythonhosted.org/packages/ec/5b/83b5aaf7b6ed102f63cd768a747b6cb5d4624f2eaecd84868d103b9dbf39/pycares-5.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8efc38c2703e3530b823a4165a7b28d7ce0fdcf41960fb7a4ca834a0f8cfe79", size = 221137, upload-time = "2026-01-01T12:35:40.155Z" }, { url = "https://files.pythonhosted.org/packages/33/d3/d77ab0b33fb805d02896c385176c462e3386d94457a5e508245c39f41829/pycares-5.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e380bf6eff42c260f829a0a14547e13375e949053a966c23ca204a13647ef265", size = 252252, upload-time = "2026-01-01T12:35:41.287Z" }, { url = "https://files.pythonhosted.org/packages/14/32/8afbc798bce26dfcc5bc1f6bf1560d31cdd0af837ff52cbede657bf9262e/pycares-5.0.1-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:35dd5858ee1246bd092a212b5e85a8ef70853f7cfaf16b99569bf4af3ae4695d", size = 239447, upload-time = "2026-01-01T12:35:42.614Z" }, { url = "https://files.pythonhosted.org/packages/61/1b/a056393fda383b2eda5dab20bd0dd034fd631bf5ae754aabb20da815bdfe/pycares-5.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c257c6e7bf310cdb5823aa9d9a28f1e370fed8c653a968d38a954a8f8e0375ce", size = 223822, upload-time = "2026-01-01T12:35:43.594Z" }, { url = "https://files.pythonhosted.org/packages/ca/c7/9817f0fb954ab9926f88403f2b91a3e4984a277e2b7a4563e0118e4e1ffa/pycares-5.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07711acb0ef75758f081fb7436acaccc91e8afd5ae34fd35d4edc44297e81f27", size = 223986, upload-time = "2026-01-01T12:35:44.893Z" }, { url = "https://files.pythonhosted.org/packages/e1/a9/c0ea15c871c77e8c20bcaab18f56ae83988ea4c302155d106cc6a1bd83a9/pycares-5.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:30e5db1ae85cffb031dd8bc1b37903cd74c6d37eb737643bbca3ff2cd4bc6ae2", size = 251838, upload-time = "2026-01-01T12:35:46.271Z" }, { url = "https://files.pythonhosted.org/packages/be/a4/fe4068abfadf3e06cc22333e87e4730de3c170075572041d5545926062a3/pycares-5.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:efbe7f89425a14edbc94787042309be77cb3674415eb6079b356e1f9552ba747", size = 238238, upload-time = "2026-01-01T12:35:47.196Z" }, { url = "https://files.pythonhosted.org/packages/a7/25/4f140518768d974af4221cfd574a30d99d40b3d5c54c479da2c1553be59e/pycares-5.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5de9e7ce52d638d78723c24704eb032e60b96fbb6fe90c6b3110882987251377", size = 223574, upload-time = "2026-01-01T12:35:48.191Z" }, { url = "https://files.pythonhosted.org/packages/1e/0a/6e4afa4a2baffd1eba6c18a90cda17681d4838d3cab5a485e471386e04dc/pycares-5.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:0e99af0a1ce015ab6cc6bd85ce158d95ed89fb3b654515f1d0989d1afcf11026", size = 117472, upload-time = "2026-01-01T12:35:50.674Z" }, { url = "https://files.pythonhosted.org/packages/57/d0/a99f97e9aa8c8404fc899540cf30be63cda0df5150e3c0837423917c7e4c/pycares-5.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a511c9f3b11b7ce9f159c956ea1b8f2de7f419d7ca9fa24528d582cb015dbf9", size = 108889, upload-time = "2026-01-01T12:35:51.902Z" }, { url = "https://files.pythonhosted.org/packages/38/b2/4af99ff17acb81377c971831520540d1859bf401dc85712eb4abc2e6751f/pycares-5.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e330e3561be259ad7a1b7b0ce282c872938625f76587fae7ac8d6bc5af1d0c3d", size = 136635, upload-time = "2026-01-01T12:35:53.365Z" }, { url = "https://files.pythonhosted.org/packages/42/da/e2e1683811c427492ee0e86e8fae8d55eb5cca032220438599991fdad866/pycares-5.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82bd37fec2a3fa62add30d4a3854720f7b051386e2f18e6e8f4ee94b89b5a7b0", size = 131093, upload-time = "2026-01-01T12:35:54.28Z" }, { url = "https://files.pythonhosted.org/packages/cd/2a/9cf2120cafc19e5c589d5252a9ddd3108cc87e9db09938d16317807de03b/pycares-5.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:258c38aaa82ad1d565b4591cdb93d2c191be8e0a2c70926999c8e0b717a01f2a", size = 221096, upload-time = "2026-01-01T12:35:57.096Z" }, { url = "https://files.pythonhosted.org/packages/2c/cc/c5fbf6377e2d6b1f1618f147ad898e5d8ae1585fc726d6301f07aeda6cac/pycares-5.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ccc1b2df8a09ca20eefbe20b9f7a484d376525c0fb173cfadd692320013c6bc5", size = 252330, upload-time = "2026-01-01T12:35:58.182Z" }, { url = "https://files.pythonhosted.org/packages/3b/df/17a7c518c45bb994f76d9064d2519674e2a3950f895abbe6af123ead04ac/pycares-5.0.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c4dfc80cc8b43dc79e02a15486c58eead5cae0a40906d6be64e2522285b5b39", size = 239799, upload-time = "2026-01-01T12:36:00.378Z" }, { url = "https://files.pythonhosted.org/packages/3f/6c/d79c94809742b56b9180a9a9ec2937607db0b8eb34b8ca75d86d3114d6dd/pycares-5.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f498a6606247bfe896c2a4d837db711eb7b0ba23e409e16e4b23def4bada4b9d", size = 223501, upload-time = "2026-01-01T12:36:02.695Z" }, { url = "https://files.pythonhosted.org/packages/69/08/83084b67cbce08f44fd803b88816fc80d2fe2fb3d483d5432925df44371b/pycares-5.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a7d197835cdb4b202a3b12562b32799e27bb132262d4aa1ac3ee9d440e8ec22c", size = 223708, upload-time = "2026-01-01T12:36:04.357Z" }, { url = "https://files.pythonhosted.org/packages/15/57/63a6e9ef356c5149b8ec72a694e02207fd8ae643895aeb78a9f0c07f1502/pycares-5.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f78ab823732b050d658eb735d553726663c9bccdeeee0653247533a23eb2e255", size = 251816, upload-time = "2026-01-01T12:36:05.618Z" }, { url = "https://files.pythonhosted.org/packages/43/1c/1c85c6355cf7bc3ae86a1024d60f9cabdc12af63306a5f59370ac8718a41/pycares-5.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f444ab7f318e9b2c209b45496fb07bff5e7ada606e15d5253a162964aa078527", size = 238259, upload-time = "2026-01-01T12:36:07.609Z" }, { url = "https://files.pythonhosted.org/packages/5d/7f/bd5ff5a460e50433f993560e4e5d229559a8bf271dbdf6be832faf1973b5/pycares-5.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de80997de7538619b7dd28ec4371e5172e3f9480e4fc648726d3d5ba661ca05", size = 223732, upload-time = "2026-01-01T12:36:09.893Z" }, { url = "https://files.pythonhosted.org/packages/b5/fe/e77738366e00dc0918bbeb0c8fc63579e5d9cec748a2b838e207e548b5d9/pycares-5.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:206ce9f3cb9d51f5065c81b23c22996230fbc2cf58ae22834c623631b2b473aa", size = 120847, upload-time = "2026-01-01T12:36:11.494Z" }, { url = "https://files.pythonhosted.org/packages/81/17/758e9af7ee8589ac6deddf7ea56d75b982f155bc2052ef61c45d5f371389/pycares-5.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:45fb3b07231120e8cb5b75be7f15f16115003e9251991dc37a3e5c63733d63b5", size = 112595, upload-time = "2026-01-01T12:36:12.973Z" }, { url = "https://files.pythonhosted.org/packages/56/12/4f1d418fed957fc96089c69d9ec82314b3b91c48c7f9463385842acad9c4/pycares-5.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:602f3eac4b880a2527d21f52b2319cb10fde9225d103d338c4d0b2b07f136849", size = 137061, upload-time = "2026-01-01T12:36:15.027Z" }, { url = "https://files.pythonhosted.org/packages/29/8c/559cea98a8a5d0f38b50b4b812a07fdbcdb1a961bed9e2e9d5d343e53c6f/pycares-5.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1c3736deef003f0c57bc4e7f94d54270d0824350a8f5ceaba3a20b2ce8fb427", size = 131551, upload-time = "2026-01-01T12:36:16.74Z" }, { url = "https://files.pythonhosted.org/packages/34/cd/aee5d8070888d7be509d4f32a348e2821309ec67980498e5a974cd9e4990/pycares-5.0.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e63328df86d37150ce697fb5d9313d1d468dd4dddee1d09342cb2ed241ce6ad9", size = 230409, upload-time = "2026-01-01T12:36:18.909Z" }, { url = "https://files.pythonhosted.org/packages/5e/94/15d5cf7d8e7af4b4ce3e19ea117dfe565c08d60d82f043ad23843703a135/pycares-5.0.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57f6fd696213329d9a69b9664a68b1ff2a71ccbdc1fc928a42c9a92858c1ec5d", size = 261297, upload-time = "2026-01-01T12:36:20.771Z" }, { url = "https://files.pythonhosted.org/packages/af/46/24f6ddc7a37ec6eaa1c38f617f39624211d8e7cdca49b644bfc5f467f275/pycares-5.0.1-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d0878edabfbecb48a29e8769284003d8dbc05936122fe361849cd5fa52722e0", size = 248071, upload-time = "2026-01-01T12:36:22.925Z" }, { url = "https://files.pythonhosted.org/packages/fa/f0/7eb7fe44f0db55b9083725ab7a084874c2dc02806d9613e07e719838c2ab/pycares-5.0.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50e21f27a91be122e066ddd78c2d0d2769e547561481d8342a9d652a345b89f7", size = 232073, upload-time = "2026-01-01T12:36:25.773Z" }, { url = "https://files.pythonhosted.org/packages/1d/cd/993b17e0c049a56b5af4df3fd053acc57b37e17e0dcd709b2d337c22d57d/pycares-5.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:97ceda969f5a5d5c6b15558b658c29e4301b3a2c4615523797b5f9d4ac74772e", size = 232815, upload-time = "2026-01-01T12:36:27.798Z" }, { url = "https://files.pythonhosted.org/packages/7a/ff/170177bcc5dff31e735f209f5de63362f513ac18846c83d50e4e68f57866/pycares-5.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d1713e602ab09882c3e65499b2cc763bff0371117327cad704cf524268c2604", size = 261111, upload-time = "2026-01-01T12:36:29.94Z" }, { url = "https://files.pythonhosted.org/packages/4d/4a/4c6497b8ca9279b4038ee8c7e2c49504008d594d06a044e00678b30c10fe/pycares-5.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:954a379055d6c66b2e878b52235b382168d1a3230793ff44454019394aecac5e", size = 246311, upload-time = "2026-01-01T12:36:31.352Z" }, { url = "https://files.pythonhosted.org/packages/06/19/1603f51f0d73bf34017a9e6967540c2bc138f9541aa7cc1ef38990b3ce9d/pycares-5.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:145d8a20f7fd1d58a2e49b7ef4309ec9bdcab479ac65c2e49480e20d3f890c23", size = 232027, upload-time = "2026-01-01T12:36:34.374Z" }, { url = "https://files.pythonhosted.org/packages/7a/de/c000a682757b84688722ac232a24a86b6f195f1f4732432ecf35d0a768a5/pycares-5.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ebc9daba03c7ff3f62616c84c6cb37517445d15df00e1754852d6006039eb4a4", size = 121267, upload-time = "2026-01-01T12:36:35.741Z" }, { url = "https://files.pythonhosted.org/packages/b2/c4/8bfffecd08b9b198113fcff5f0ab84bbe696f07dec46dd1ccae0e7b28c23/pycares-5.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e0a86eff6bf9e91d5dd8876b1b82ee45704f46b1104c24291d3dea2c1fc8ebcb", size = 113043, upload-time = "2026-01-01T12:36:37.895Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "python-discovery" version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] [[package]] name = "python-magic" version = "0.4.27" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "qrcode" version = "8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, ] [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rlottie-python" version = "1.3.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c5/38/3c70069ffa7ff869b55f7f699144da45db7b4b121821ac520a4ad9c9186d/rlottie_python-1.3.8.tar.gz", hash = "sha256:a8404bc5e8fa9b4e5c7dad2173930c11450c9a2d95091f4f6a213bc77c4d9288", size = 4164872, upload-time = "2025-08-23T20:18:43.377Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/21/9c/add6ab4b4d4f955727126a5f45a077510488de6ec368519e09b1635ba68d/rlottie_python-1.3.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:00deebcd325d28fa5e610a8d74d45647055fb279db378f9e0743c972890da89f", size = 1564510, upload-time = "2025-08-23T20:17:53.393Z" }, { url = "https://files.pythonhosted.org/packages/b4/39/e1e5887757aa9f6f58c7090fac36a4c8df8e7425de7d446b320f7f6947d8/rlottie_python-1.3.8-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b44efea6f64366ac68fd53b5029b23b131b6289f66aa40db32a5840cb6d5b884", size = 846057, upload-time = "2025-08-23T20:17:55.348Z" }, { url = "https://files.pythonhosted.org/packages/a0/c6/84f787fc8516acaf5d256c3934491afeb15a6799e73609169b3ffe40e5df/rlottie_python-1.3.8-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:93ce14a420cb4ee1bf149440ae4f65a5e8b5ebcce6102d4467ab7cfa0b2f4f52", size = 752795, upload-time = "2025-08-23T20:17:56.66Z" }, { url = "https://files.pythonhosted.org/packages/63/db/d41f469826b5d5a0af738ff63eac938489aeb68ca7e91a3b8a41db985fc3/rlottie_python-1.3.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9ae16eaf7e668f765db1ed366542ca5bd06932e5f20df429105b27ee721a02", size = 974054, upload-time = "2025-08-23T20:17:57.721Z" }, { url = "https://files.pythonhosted.org/packages/9b/5d/17a3d10bdb601f127fc3267ca2aa27ea4097a0970d92eafa59b6f81ec2f5/rlottie_python-1.3.8-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7775f011030669549d5762cc58aac72f63addac0bf81360ddfe5ac65d3c6224a", size = 1052251, upload-time = "2025-08-23T20:17:59.142Z" }, { url = "https://files.pythonhosted.org/packages/7b/04/3d072260e8cd27796acb200053d4029a8af0c8c6f3e6c651d84a73026512/rlottie_python-1.3.8-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1768fd1d94186ac401d653b49999584d99996115e8dffc00d275844d63531225", size = 1209030, upload-time = "2025-08-23T20:18:00.455Z" }, { url = "https://files.pythonhosted.org/packages/d8/e8/f227e09c90d35790f0b357b0e0397cb4a5dba582475a4a45bd72ce2a0eac/rlottie_python-1.3.8-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb1458f676df31f5cfd5857fbb57fd71d5023a6bdbbcf334a623028fd7516fda", size = 935838, upload-time = "2025-08-23T20:18:01.516Z" }, { url = "https://files.pythonhosted.org/packages/f5/c8/914cc8032c121ea329b1331e9ef0f0de482ef6fd68bbad781f734e3d6b13/rlottie_python-1.3.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14168f8448aa3af107a442d565d31a7aa62b2f578fd9b5136a5198bc3d5ce855", size = 1032901, upload-time = "2025-08-23T20:18:02.952Z" }, { url = "https://files.pythonhosted.org/packages/8a/f0/a679a50a08d45c5950295428157014fa82c414c3b1719546025b3089c44a/rlottie_python-1.3.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:c5182a9e681f5e66db4980dac125310a63b9acc92eb2a9433095e327227634fb", size = 1467490, upload-time = "2025-08-23T20:18:04.823Z" }, { url = "https://files.pythonhosted.org/packages/d7/fa/c6caa0ce77981c3a8cddd7c0d6c0d213d139126dd9f916a41ed200572488/rlottie_python-1.3.8-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:7f13dc6a56420711650ff52e61ab7f6b85bbbd70ea561c75ea467111c0c3900e", size = 1503623, upload-time = "2025-08-23T20:18:05.873Z" }, { url = "https://files.pythonhosted.org/packages/62/dc/bcc124795c051adcfaa89e7a5c457e6a92067556ae9a8a3feea7d5e70acf/rlottie_python-1.3.8-cp37-abi3-musllinux_1_1_ppc64le.whl", hash = "sha256:f32ed872c0a9d9383a37f72b75725f381f098d099c374ca6f4095407cbce6db7", size = 1676526, upload-time = "2025-08-23T20:18:06.967Z" }, { url = "https://files.pythonhosted.org/packages/f5/e1/d393527c5af448e8c22fb289bbdf6c4dbdd2cd9e0cc908694fbc231f7e87/rlottie_python-1.3.8-cp37-abi3-musllinux_1_1_s390x.whl", hash = "sha256:1e47ab633775924e5ade3ab66256479142a17738101e3ad5046b9b0d55832a8c", size = 1466798, upload-time = "2025-08-23T20:18:08.581Z" }, { url = "https://files.pythonhosted.org/packages/23/c4/04ad236109876267982b0cb737db140bdd191333d4f62d293b1ea048127f/rlottie_python-1.3.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e309cde2889c360dcf7ce79cdc8a8c1a8dd6184778df8336c66a215312fd3eac", size = 1518390, upload-time = "2025-08-23T20:18:09.977Z" }, { url = "https://files.pythonhosted.org/packages/28/02/0d8c5602e5d497a41e11051fe00a7686bd5c589d112d34c6c88ca4e5b322/rlottie_python-1.3.8-cp37-abi3-win32.whl", hash = "sha256:c4320fd47cc8b95eb524dc91b352cfdb94b244e954b0dae40461bd4df40d816b", size = 332877, upload-time = "2025-08-23T20:18:11.443Z" }, { url = "https://files.pythonhosted.org/packages/f9/ca/9b47a54a0553ba4ee1eb57ad279c23ce4e01c30638da5f2a1e4bee4dc357/rlottie_python-1.3.8-cp37-abi3-win_amd64.whl", hash = "sha256:c89f0701fe207d734b6e669f9a4c8779b2d5cd6a015baa59023a66a4f3923ec1", size = 381448, upload-time = "2025-08-23T20:18:12.468Z" }, { url = "https://files.pythonhosted.org/packages/04/e1/5b28e37cfc4c35f41a00917a2c53b4387d9971e0ca8f463f464494964a85/rlottie_python-1.3.8-cp37-abi3-win_arm64.whl", hash = "sha256:397509e01c1210e987e1c1f69b4faf0f89f1dbb1b14f6edabd5fb661cc4ca885", size = 321269, upload-time = "2025-08-23T20:18:13.686Z" }, ] [[package]] name = "roman-numerals" version = "4.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "ruff" version = "0.15.20" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] name = "slidge" source = { editable = "." } dependencies = [ { name = "aiohttp", extra = ["speedups"] }, { name = "alembic" }, { name = "configargparse" }, { name = "defusedxml" }, { name = "pillow" }, { name = "python-magic" }, { name = "qrcode" }, { name = "slixmpp" }, { name = "sqlalchemy" }, { name = "thumbhash" }, ] [package.optional-dependencies] lottie = [ { name = "rlottie-python" }, ] postgres = [ { name = "psycopg2-binary" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, { name = "emoji" }, { name = "furo" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pygments" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, { name = "slidge-sphinx-extensions" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autoapi" }, { name = "sphinx-autobuild" }, { name = "utidylib" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", extras = ["speedups"], specifier = "~=3.11" }, { name = "alembic", specifier = "~=1.14" }, { name = "configargparse", specifier = "~=1.7" }, { name = "defusedxml", specifier = "~=0.7.1" }, { name = "pillow", specifier = "~=12.0" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.11,<3" }, { name = "python-magic", specifier = "~=0.4.27" }, { name = "qrcode", specifier = "~=8.0" }, { name = "rlottie-python", marker = "extra == 'lottie'", specifier = "~=1.3.8" }, { name = "slixmpp", specifier = "~=1.17" }, { name = "sqlalchemy", specifier = "~=2.0" }, { name = "thumbhash", specifier = "~=0.1.2" }, ] provides-extras = ["lottie", "postgres"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = ">=7.6.10" }, { name = "emoji", specifier = ">=2.14.0" }, { name = "furo", specifier = ">=2024.8.6" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pygments", specifier = ">=2.18.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "ruff", specifier = ">=0.8.4" }, { name = "slidge-sphinx-extensions", specifier = ">=0.2.1,<0.3" }, { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-autoapi", specifier = ">=3.4.0" }, { name = "sphinx-autobuild", specifier = ">=2025.8.25" }, { name = "utidylib", specifier = ">=0.10" }, ] [[package]] name = "slidge-sphinx-extensions" version = "0.2.2" source = { registry = "https://codeberg.org/api/packages/slidge/pypi/simple" } dependencies = [ { name = "docutils" }, { name = "requests" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://codeberg.org/api/packages/slidge/pypi/files/slidge-sphinx-extensions/0.2.2/slidge_sphinx_extensions-0.2.2.tar.gz", hash = "sha256:45b8354e08614f77080af5a959a755bca050113e59e3dfc803e12aff4499f95a" } wheels = [ { url = "https://codeberg.org/api/packages/slidge/pypi/files/slidge-sphinx-extensions/0.2.2/slidge_sphinx_extensions-0.2.2-py3-none-any.whl", hash = "sha256:22b604dccf8b70e366cb8559d4207f2dfe90118efc45528db8abc20e34189c30" }, ] [[package]] name = "slixmpp" version = "1.17.0" source = { registry = "https://codeberg.org/api/packages/poezio/pypi/simple/" } dependencies = [ { name = "aiodns" }, { name = "pyasn1" }, { name = "pyasn1-modules" }, ] sdist = { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0.tar.gz", hash = "sha256:05257ee0f2aecb42d6479f6071c89ad3c734fda0ee753eb4625ecdc6114d90b1" } wheels = [ { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e76821d5652c24ff2146e66b22c1158115d6a19ee6004eb00b675644e2e38cb" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cd8ef55a52fdb7eebdade72d62ac8617328bbc8716861b359260bcfb4f3925d" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cb2f83c369c7a0fdd6e391c84e7de0e414c178686d68cc9b07ebd725462d9289" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:98829509c626e916c721057a686f04bc55fc142a8dc5d5a1e8604b9a8a61c0ad" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9d8612ba00dc889cfe261329d74771ca5d46b1c27b56164edc1b116dfc99649f" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:618796ae636a65aa4b1dba1a36ba119170bf17febfdd88f27c267189e91e69c7" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:637c99c67dee1fb5c375e28153aea00cbc7ca1e9a0acd5518423acbcb1401387" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb40ca6dff2c677b50dec9efc967a67498844c1d984682183dd99fe3430bb59f" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f5709541857ee9152da577df4af1fcf9241b2809f2720afaf2b1d27324e36da" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc36d96ccc93604f3ab0b59e49d954fc720c275fb1af63d5c521884b54cf96b7" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c22ccadbccf90471235416a89c9af1584c0675fb235df4007d287c7275cbd4fe" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf65f15f1e8dff014a305f6c7a4ec20eb71bfcf9bb2472b8daf3eb40991730fd" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03df6b43750501fb725db247f7f596800d467963b9fa90b974b67e1468a511d6" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d6d0180b9048a4952894300046c9d93e40e8b5e52bf1e7c665a662a5ac78661" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1b7814de8f4001320ec5db0e23378f194eda6cefbd30425f5115255ca5b23bb7" }, { url = "https://codeberg.org/api/packages/poezio/pypi/files/slixmpp/1.17.0/slixmpp-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f8ac50221947ad34d0485acb99dfd259d2378f5e888583c388b23bbe9832791a" }, ] [[package]] name = "snowballstemmer" version = "3.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] name = "soupsieve" version = "2.8.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, ] [[package]] name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", ] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] name = "sphinx-autoapi" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/25/386d851320bc306e9d7d41b8cecf113f638e205a99595c481c146622de94/sphinx_autoapi-3.8.0.tar.gz", hash = "sha256:9f8ac7d43baf28a0831ac0e392fab6a095b875af07e52d135a5f716cc3cf1142", size = 58418, upload-time = "2026-03-08T03:49:52.931Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/3a/8923a543fa2422d32be4c5311f488e4f275acde263c811e4d5d22bb544cb/sphinx_autoapi-3.8.0-py3-none-any.whl", hash = "sha256:245aefdeab85609ae4aa3576b0d99f69676aa6333dda438761bd125755b3c42d", size = 35994, upload-time = "2026-03-08T03:49:51.383Z" }, ] [[package]] name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "starlette" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, ] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, ] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, ] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, ] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] [[package]] name = "sqlalchemy" version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] name = "starlette" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "thumbhash" version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d1/17/fc026b10f448e25413e4cc692c9ca00cc8ef0e4022a1dd8d7b90761c9be9/thumbhash-0.1.2.tar.gz", hash = "sha256:ef4e6398f93f3b5ad480dc8e3a3b3a200813c86a8857fb069f8478be809f8247", size = 13766, upload-time = "2023-03-26T20:37:10.822Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/64/0b5d39612708e892ca67934a0d98b2a2b06aabe8261be800b5db54f9702e/thumbhash-0.1.2-py3-none-any.whl", hash = "sha256:5dbddde2349c35c65d70af11637e9c89a97a2b5a134f3e14bcf2a5dd82522a22", size = 5112, upload-time = "2023-03-26T20:37:09.146Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "utidylib" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/33/84bb6e8d4e6d8e94a35d1041c4c7f53b0b0ba3a27c249d0e53110b7b8bf3/utidylib-1.0.0.tar.gz", hash = "sha256:7c1281a02935cb40ac4e54a0e40761a4dce2afb968cf7575a05d62cf694e37c0", size = 16435, upload-time = "2026-01-07T12:23:14.959Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/66/90/0ca1669d33fbda91fbf38b627ec16d13d1476fbd12ab213ad402706e001d/utidylib-1.0.0-py3-none-any.whl", hash = "sha256:3407a5c85436ace4a034985c8b3d10cfc438e3c782d364d30b2fe6d9dfb4e802", size = 10488, upload-time = "2026-01-07T12:23:13.92Z" }, ] [[package]] name = "uvicorn" version = "0.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, ] [[package]] name = "virtualenv" version = "21.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] [[package]] name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] name = "websockets" version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "yarl" version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ]