diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index a50a161ae..000000000 --- a/.coveragerc +++ /dev/null @@ -1,12 +0,0 @@ -[run] -branch = True -omit = - oracle.py - -[report] -exclude_lines = - pass - raise NoSuchElementException - raise NotImplementedError - if 0: - def __str__ \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..82cabe3b4 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3.11-bookworm + + +RUN \ + apt update && apt install bash-completion -y && \ + pip install pre-commit && \ + curl -sSL https://install.python-poetry.org | POETRY_HOME=/home/vscode/.local python3 - + + +RUN \ + echo >> /home/vscode/.bashrc && \ + # add completions to bashrc + # see how ubuntu does it for reference: + # https://git.launchpad.net/ubuntu/+source/base-files/tree/share/dot.bashrc + # https://stackoverflow.com/a/68566555 + echo 'if [ -f /etc/bash_completion ] && ! shopt -oq posix; then' >> /home/vscode/.bashrc && \ + echo ' . /etc/bash_completion' >> /home/vscode/.bashrc && \ + echo 'fi' >> /home/vscode/.bashrc && \ + echo >> /home/vscode/.bashrc && \ + echo '. <(poetry completions)' >> /home/vscode/.bashrc diff --git a/.devcontainer/commands/post-create-command.sh b/.devcontainer/commands/post-create-command.sh new file mode 100755 index 000000000..58dc0f754 --- /dev/null +++ b/.devcontainer/commands/post-create-command.sh @@ -0,0 +1,4 @@ +echo "Running post-create-command.sh" + +pre-commit install +poetry install --all-extras diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..da44489a1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,41 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "build": { + // Path is relative to the devcontainer.json file. + // We prebuild the image to get poetry into the image + // This saves the user a bit of time, when re-opening containers + "dockerfile": "Dockerfile" + }, + + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "latest", + "dockerDashComposeVersion": "v2" + } + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": ".devcontainer/commands/post-create-command.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ] + } + } + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..a9a66543c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts +*.sh text eol=lf diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 000000000..e62ece491 --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "4.7.2" +} diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..12c8d19fa --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to `testcontainers-python` + +Welcome to the `testcontainers-python` community! +This should give you an idea about how we build, test and release `testcontainers-python`! + +Highly recommended to read this document thoroughly to understand what we're working on right now +and what our priorities are before you are trying to contribute something. + +This will greatly increase your chances of getting prompt replies as the maintainers are volunteers themselves. + +## Before you Begin + +We recommend following these steps: + +1. Finish reading this document. +2. Read the [recently updated issues][1] +3. Look for existing issues on the subject you are interested in - we do our best to label everything correctly + + +## Local Development + +### Pre-Requisites + +You need to have the following tools available to you: +- `make` - You'll need a GNU Make for common developer activities +- `poetry` - This is the primary package manager for the project +- `pyenv` **Recommended**: For installing python versions for your system. + Poetry infers the current latest version from what it can find on the `PATH` so you are still fine if you don't use `pyenv`. + +### Build and test + + +- Run `make install` to get `poetry` to install all dependencies and set up `pre-commit` + - **Recommended**: Run `make` or `make help` to see other commands available to you. +- After this, you should have a working virtual environment and proceed with writing code with your favourite IDE +- **TIP**: You can run `make core/tests` or `make module//tests` to run the tests specifically for that to speed up feedback cycles +- You can also run `make lint` to run the `pre-commit` for the entire codebase. + + +## Adding new containers + +We have an [issue template](.github/ISSUE_TEMPLATE/new-container.md) for adding new containers, please refer to that for more information. +Once you've talked to the maintainers (we do our best to reply!) then you can proceed with contributing the new container. + +> [!WARNING] +> PLease raise an issue before you try to contribute a new container! It helps maintainers understand your use-case and motivation. +> This way we can keep pull requests foruced on the "how", not the "why"! :pray: +> It also gives maintainers a chance to give you last-minute guidance on caveats or expectations, particularly with +> new extra dependencies and how to manage them. + + +## Raising Issues + +We have [Issue Templates][2] to cover most cases, please try to adhere to them, they will guide you through the process. +Try to look through the existing issues before you raise a new one. + + +## Releasing Versions + +We have automated Semantic Versioning and release via [release-please](workflows/release-please.yml). +This takes care of: +- Detecting the next version, based on the commits that landed on `main` +- When a Release PR has been merged + - Create a GitHub Release with the CHANGELOG included + - Update the [CHANGELOG](../CHANGELOG.md), similar to the GitHub Release + - Release to PyPI via a [trusted publisher](https://docs.pypi.org/trusted-publishers/using-a-publisher/) + - Automatically script updates in files where it's needed instead of hand-crafting it (i.e. in `pyproject.toml`) + +> [!CRITICAL] +> Community modules are supported on a best-effort basis and for maintenance reasons, any change to them +> is only covered under minor and patch changes. +> +> Community modules changes DO NOT contribute to major version changes! +> +> If your community module container was broken by a minor or patch version change, check out the change logs! + +# Thank you! + +Thanks for reading, feedback on documentation is always welcome! + +[1]: https://github.com/testcontainers/testcontainers-python/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc "Recently Updated Issues showing you what we're focusing on" +[2]: https://github.com/testcontainers/testcontainers-python/issues/new/choose "List of current issue templates, please use them" diff --git a/.github/ISSUE_TEMPLATE/bug-or-unexpected-behavior.md b/.github/ISSUE_TEMPLATE/bug-or-unexpected-behavior.md new file mode 100644 index 000000000..98b61f3f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-or-unexpected-behavior.md @@ -0,0 +1,35 @@ +--- +name: Bug or unexpected behavior +about: Create a report to help us improve. +title: 'Bug: ' +labels: bug +assignees: '' + +--- + +**Describe the bug** + +A clear and concise description of what the bug is. What did you expect to happen? What happened instead? + +**To Reproduce** + +Provide a self-contained code snippet that illustrates the bug or unexpected behavior. Ideally, send a Pull Request to illustrate with a test that illustrates the problem. + +```python +raise RuntimeError("something went wrong") +``` + +**Runtime environment** + +Provide a summary of your runtime environment. Which operating system, python version, and docker version are you using? What is the version of `testcontainers-python` you are using? You can run the following commands to get the relevant information. + +```bash +# Get the operating system information (on a unix os). +$ uname -a +# Get the python version. +$ python --version +# Get the docker version and other docker information. +$ docker info +# Get all python packages. +$ pip freeze +``` diff --git a/.github/ISSUE_TEMPLATE/feature-proposal.md b/.github/ISSUE_TEMPLATE/feature-proposal.md new file mode 100644 index 000000000..cabafa26b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-proposal.md @@ -0,0 +1,22 @@ +--- +name: Feature Proposal +about: Send a note to the tc-python team about something you would like to see changed or improved. +title: 'Feature: ' +labels: '🚀 enhancement' +assignees: '' + +--- + + + +**What are you trying to do?** + +Describe the intention of the enhancement. + +**Why should it be done this way?** + +Describe the motivation of the enhancement. + +**Other references:** + +Include any other relevant reading material about the enhancement. diff --git a/.github/ISSUE_TEMPLATE/new-container.md b/.github/ISSUE_TEMPLATE/new-container.md new file mode 100644 index 000000000..b089c2e18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-container.md @@ -0,0 +1,26 @@ +--- +name: New Container +about: Tell the Testcontainers-Python team about a container you'd like to have support for. +title: 'New Container: ' +labels: '🚀 enhancement' +assignees: '' + +--- + + + +**What is the new container you'd like to have?** + +Please link some docker containers as well as documentation/arguments to the benefits of having this container. + +**Why not just use a generic container for this?** + +Please describe why the `DockerContainer("my-image:latest")` approach is not useful enough. + +Having a dedicated `TestContainer` usually means the need for some or all of these: +- complicated setup/configuration +- the wait strategy is complex for the container, usually more than just an http wait + +**Other references:** + +Include any other relevant reading material about the enhancement. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..b05282d90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,41 @@ +--- +name: Question +about: Ask a question about how to use this library. +title: 'Question: ' +labels: '📖 documentation' +assignees: '' + +--- + + + +## What are you trying to do? + +Ask your question here + +## Where are you trying to do it? + +Provide a self-contained code snippet that illustrates the bug or unexpected behavior. +Ideally, include a link to a public repository with a minimal project where someone from the +testcontainers-python can submit a PR with a solution to the problem you are facing with the library. + +## Runtime environment + +Provide a summary of your runtime environment. Which operating system, python version, and docker version are you using? +What is the version of `testcontainers-python` you are using? You can run the following commands to get the relevant information. + +Paste the results of the bash below + +```bash +uname -a +echo "------" +docker info +echo "------" +poetry run python --version +echo "------" +poetry show --tree +``` + +```bash +paste-me-here +``` diff --git a/.github/PULL_REQUEST_TEMPLATE/new_container.md b/.github/PULL_REQUEST_TEMPLATE/new_container.md index f89a8d6d2..c64d66785 100644 --- a/.github/PULL_REQUEST_TEMPLATE/new_container.md +++ b/.github/PULL_REQUEST_TEMPLATE/new_container.md @@ -1,9 +1,44 @@ -You have implemented a new container and would like to contribute it? Great! Here are the necessary steps: - -- [ ] You have added the new container as a module in the `testcontainers` directory (such as `testcontainers/my_fancy_container.py`). -- [ ] You have added any new python dependencies in the `extras_require` section of `setup.py`. -- [ ] You have added the `extra_requires` key to `requirements.in`. -- [ ] You have updated all python requirements by running `make requirements` from the root directory. -- [ ] You have added tests for the new container in the `tests` directory, e.g. `tests/test_my_fancy_container.py`. -- [ ] You have added the name of the container (such as `my_fancy_container`) to the `test-components` matrix in `.github/workflows/main.yml` to ensure the tests are run. -- [ ] You have rebased your development branch on `master` (or merged `master` into your development branch). +# New Container + + + +Fixes ... + + + + +# PR Checklist + +- [ ] Your PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) syntax + as we make use of this for detecting Semantic Versioning changes. + - Additions to the community modules do not contribute to SemVer scheme: + all community features will be tagged [community-feat](https://github.com/testcontainers/testcontainers-python/issues?q=label%3Acommunity-feat+), + but we do not want to release minor or major versions due to features or breaking changes outside of core. + So please use `fix(postgres):` or `fix(my_new_vector_db):` if you want to add or modify community modules. + This may change in the future if we have a separate package released with community modules. +- [ ] Your PR allows maintainers to edit your branch, this will speed up resolving minor issues! +- [ ] The new container is implemented under `modules/*` + - Your module follows [PEP 420](https://peps.python.org/pep-0420/) with implicit namespace packages + (if unsure, look at other existing community modules) + - Your package namespacing follows `testcontainers..*` + and you DO NOT have an `__init__.py` above your module's level. + - Your module has its own tests under `modules/*/tests` + - Your module has a `README.rst` and hooks in the `.. auto-class` and `.. title` of your container + - Implement the new feature (typically in `__init__.py`) and corresponding tests. +- [ ] Your module is added in `pyproject.toml` + - it is declared under `tool.poetry.packages` - see other community modules + - it is declared under `tool.poetry.extras` with the same name as your module name, + we still prefer adding _NO EXTRA DEPENDENCIES_, meaning `mymodule = []` is the preferred addition + (see the notes at the bottom) +- [ ] Your branch is up-to-date (or your branch will be rebased with `git rebase`) + +# Preferred implementation + +- The current consensus among maintainers is to try to avoid enforcing the client library + for the given tools you are triyng to implement. +- This means we want you to avoid adding specific libraries as dependencies to `testcontainers`. +- Therefore, you should implement the configuration and the waiting with as little extra as possible +- You may still find it useful to add your preferred client library as a dev dependency diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml new file mode 100644 index 000000000..de8700062 --- /dev/null +++ b/.github/actions/setup-env/action.yml @@ -0,0 +1,20 @@ +name: setup-env +description: set up the python environment + +inputs: + python-version: + description: "The python version to install and use" + default: "3.12" # we default to latest supported + required: false + +runs: + using: composite + steps: + - name: Setup Poetry + run: pipx install poetry + shell: bash + - name: Setup python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + cache: poetry diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 000000000..e6fe017d5 --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,9 @@ +{ + "release-type": "python", + "packages": { + ".": { + "package-name": "testcontainers" + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 000000000..4e9d96d10 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,155 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ + +repository: + # See https://docs.github.com/en/rest/reference/repos#update-a-repository for all available settings. + + # The name of the repository. Changing this will rename the repository + name: testcontainers-python + + # A short description of the repository that will show up on GitHub + description: "Python library that providing a friendly API to run Docker containers when from tests." + + # A URL with more information about the repository + homepage: https://testcontainers-python.readthedocs.io/en/latest + + # A comma-separated list of topics to set on the repository + topics: database,python,python3,selenium,testcontainers,testing + + # Either `true` to make the repository private, or `false` to make it public. + private: false + + # Either `true` to enable issues for this repository, `false` to disable them. + has_issues: true + + # Either `true` to enable projects for this repository, or `false` to disable them. + # If projects are disabled for the organization, passing `true` will cause an API error. + has_projects: false + + # Either `true` to enable the wiki for this repository, `false` to disable it. + has_wiki: false + + # Either `true` to enable downloads for this repository, `false` to disable them. + has_downloads: true + + # Updates the default branch for this repository. + default_branch: main + + # Either `true` to allow squash-merging pull requests, or `false` to prevent + # squash-merging. + allow_squash_merge: true + + # Either `true` to allow merging pull requests with a merge commit, or `false` + # to prevent merging pull requests with merge commits. + allow_merge_commit: false + + # Either `true` to allow rebase-merging pull requests, or `false` to prevent + # rebase-merging. + allow_rebase_merge: false + + # Either `true` to enable automatic deletion of branches on merge, or `false` to disable + delete_branch_on_merge: true + +# Labels: define labels for Issues and Pull Requests +# If including a `#`, make sure to wrap it with quotes! +labels: + - { name: '⛔ invalid', color: '#e6e6e6', description: '' } + - { name: 'dependencies', color: '#0366d6', description: 'Pull requests that update a dependency file' } + - { name: 'good first issue', color: '#1C49A0', description: '' } + - { name: '✅ close on merge', color: '#0E8A16', description: 'Issue that will be closed by an open pull request' } + - { name: '✨ package: new', color: '#0E8A16', description: '' } + - { name: '❓ question', color: '#cc317c', description: '' } + - { name: '🍏 macos', color: '#C5BF0',, description: '' } + - { name: '🐛 bug', color: '#ee001',, description: '' } + - { name: '🐧 linux', color: '#3ED4D',, description: '' } + - { name: '👀 requires attention', color: '#fef2c0', description: '' } + - { name: '📖 documentation', color: '#d93f0b', description: '' } + - { name: '📦 package: cassandra', color: '#0052CC', description: '' } + - { name: '📦 package: clickhouse', color: '#0052CC', description: '' } + - { name: '📦 package: compose', color: '#0052CC', description: '' } + - { name: '📦 package: core', color: '#0052CC', description: '' } + - { name: '📦 package: elasticsearch', color: '#0052CC', description: '' } + - { name: '📦 package: google', color: '#0052CC', description: '' } + - { name: '📦 package: kafka', color: '#0052CC', description: '' } + - { name: '📦 package: keycloak', color: '#0052CC', description: '' } + - { name: '📦 package: mailpit', color: '#0052CC', description: '' } + - { name: '📦 package: mongodb', color: '#0052CC', description: '' } + - { name: '📦 package: mssql', color: '#0052CC', description: '' } + - { name: '📦 package: neo4j', color: '#0052CC', description: '' } + - { name: '📦 package: oracle', color: '#0052CC', description: '' } + - { name: '📦 package: postgres', color: '#0052CC', description: '' } + - { name: '📦 package: rabbitmq', color: '#0052CC', description: '' } + - { name: '📦 package: selenium', color: '#0052CC', description: '' } + - { name: '📦 package: sftp', color: '#0052CC', description: '' } + - { name: '🔀 requires triage', color: '#bfdadc', description: '' } + - { name: '🔧 maintenance', color: '#c2f759', description: '' } + - { name: '🚀 enhancement', color: '#84b6eb', description: '' } + - { name: '🚫 wontfix', color: '#ffffff', description: '' } + - { name: '🛟 help wanted', color: '#128A0C', description: '' } + +# Collaborators: give specific users access to this repository. +# See https://docs.github.com/en/rest/reference/repos#add-a-repository-collaborator for available options +collaborators: + - username: totallyzen + permission: maintain + - username: alexanderankin + permission: maintain + - username: kiview + permission: admin + #- username: testcontainersbot + # permission: write + + # Note: `permission` is only valid on organization-owned repositories. + # The permission to grant the collaborator. Can be one of: + # * `pull` - can pull, but not push to or administer this repository. + # * `push` - can pull and push, but not administer this repository. + # * `admin` - can pull, push and administer this repository. + # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. + # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. + +# See https://docs.github.com/en/rest/reference/teams#add-or-update-team-repository-permissions for available options +teams: + # Please make sure the team already exist in the organization, as the repository-settings application is not creating them. + # See https://github.com/repository-settings/app/discussions/639 for more information about teams and settings + # - name: go-team + # # The permission to grant the team. Can be one of: + # # * `pull` - can pull, but not push to or administer this repository. + # # * `push` - can pull and push, but not administer this repository. + # # * `admin` - can pull, push and administer this repository. + # # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. + # # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. + # permission: admin + - name: oss-maintainers + permission: admin + +branches: + - name: main + # https://docs.github.com/en/rest/reference/repos#update-branch-protection + # Branch Protection settings. Set to null to disable + protection: + # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. + required_pull_request_reviews: + # The number of approvals required. (1-6) + required_approving_review_count: 1 + # Dismiss approved reviews automatically when a new commit is pushed. + dismiss_stale_reviews: false + # Blocks merge until code owners have reviewed. + require_code_owner_reviews: false + # Specify which users and teams can dismiss pull request reviews. Pass an empty dismissal_restrictions object to disable. User and team dismissal_restrictions are only available for organization-owned repositories. Omit this parameter for personal repositories. + dismissal_restrictions: + users: [] + teams: [] # [go-team] + # Required. Require status checks to pass before merging. Set to null to disable + required_status_checks: + # Required. Require branches to be up to date before merging. + strict: true + # Required. The list of status checks to require in order to merge into this branch + contexts: ["core"] + # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. + enforce_admins: false + # Prevent merge commits from being pushed to matching branches + required_linear_history: true + # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. + restrictions: + apps: [] + users: [kiview,totallyzen,alexanderankin] + teams: [oss-maintainers] diff --git a/.github/workflows/ci-community.yml b/.github/workflows/ci-community.yml new file mode 100644 index 000000000..5ff430683 --- /dev/null +++ b/.github/workflows/ci-community.yml @@ -0,0 +1,66 @@ +# Contrinuous Integration for community modules + +name: modules + +on: + push: + branches: [ main, master ] + paths: + - "modules/**" + pull_request: + branches: [ main, master ] + paths: + - "modules/**" + +jobs: + track-modules: + runs-on: ubuntu-22.04 + steps: + - name: Checkout contents + uses: actions/checkout@v4 + with: + fetch-depth: 0 # recommended by tj-actions/changed-files + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v42 + with: + path: "./modules" + diff_relative: true + dir_names: true + dir_names_exclude_current_dir: true + json: true + - name: Compute modules from files + id: compute-changes + run: | + if [[ "${{ github.ref_name }}" == "master" ]]; then + echo computed_modules=$(find modules/ -mindepth 1 -maxdepth 1 -type d | sed 's#modules/##') + echo computed_modules=$(find modules/ -mindepth 1 -maxdepth 1 -type d | sed 's#modules/##') >> $GITHUB_OUTPUT + else + modules=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | jq '.[] | split("/") | first' | jq -s -c '. | unique') + echo "computed_modules=$modules" + #echo "computed_modules=$modules" >> $GITHUB_OUTPUT + fi + outputs: + changed_modules: ${{ steps.compute-changes.outputs.computed_modules }} + test: + runs-on: ubuntu-22.04 + needs: [track-modules] + if: ${{ needs.track-modules.outputs.changed_modules != '[]' }} + strategy: + fail-fast: false + matrix: + python-version: ${{ github.ref_name == 'master' && fromJSON('["3.11"]') || fromJSON('["3.9", "3.10", "3.11", "3.12"]') }} + module: ${{ fromJSON(needs.track-modules.outputs.changed_modules) }} + steps: + - name: Checkout contents + uses: actions/checkout@v4 + - name: Set up Python + uses: ./.github/actions/setup-env + with: + python-version: ${{ matrix.python-version }} + - name: Install Python dependencies + run: poetry install -E ${{ matrix.module }} + - name: Run tests + run: make modules/${{ matrix.module }}/tests + - name: Run doctests + run: make modules/${{ matrix.module }}/doctests diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml new file mode 100644 index 000000000..00b163141 --- /dev/null +++ b/.github/workflows/ci-core.yml @@ -0,0 +1,61 @@ +# Contrinuous Integration for the core package + +name: core + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + run-tests-and-coverage: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ${{ github.ref_name == 'master' && fromJSON('["3.11"]') || fromJSON('["3.9", "3.10", "3.11", "3.12"]') }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: ./.github/actions/setup-env + with: + python-version: ${{ matrix.python-version }} + - name: Install Python dependencies + run: poetry install --with dev + - name: Run twine check + run: poetry build && poetry run twine check dist/*.tar.gz + - name: Run tests + run: make core/tests + - name: Rename coverage file + run: mv .coverage .coverage.${{ matrix.python-version}} + - name: "Save coverage artifact" + uses: actions/upload-artifact@v4 + with: + name: "coverage-artifact-${{ matrix.python-version}}" + path: ".coverage.*" + retention-days: 1 + - name: Run doctests + run: make core/doctests + if: github.ref_name != 'master' + + coverage-compile: + needs: "run-tests-and-coverage" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: ./.github/actions/setup-env + - name: Install Python dependencies + run: poetry install --all-extras + - name: "Download coverage artifacts" + uses: actions/download-artifact@v4 + with: + pattern: "coverage-artifact-*" + merge-multiple: true + - name: Compile coverage + run: make coverage + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml new file mode 100644 index 000000000..f9da3b409 --- /dev/null +++ b/.github/workflows/ci-lint.yml @@ -0,0 +1,23 @@ +# Contrinuous Integration for the core package + +name: lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + python: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Setup Env + uses: ./.github/actions/setup-env + with: + python-version: "3.9" # the pre-commit is hooked in as 3.9 + - name: Install Python dependencies + run: poetry install --no-interaction + - name: Execute pre-commit handler + run: poetry run pre-commit run -a diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..e27c89ad0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,21 @@ +name: docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: ./.github/actions/setup-env + with: + python-version: "3.11" + - name: Install Python dependencies + run: poetry install --all-extras + - name: Build documentation + run: make docs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index a3baac405..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: testcontainers-python -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - strategy: - fail-fast: false - matrix: - python-version: - - "3.7" - - "3.8" - - "3.9" - - "3.10" - test-component: - - core - - clickhouse.py - - elasticsearch.py - - google.py - - kafka.py - - localstack.py - - mongodb.py - - neo4j.py - - nginx.py - - rabbitmq.py - - redis.py - - selenium.py - - webdriver.py - - keycloak.py - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - name: Setup python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Cache Python dependencies - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles(format('requirements/{0}.txt', matrix.python-version)) }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install wheel - pip install -r requirements/${{ matrix.python-version }}.txt - - name: Run docker diagnostics - run: | - echo "Build minimal container for docker-in-docker diagnostics" - docker build -f Dockerfile.diagnostics -t testcontainers-python . - echo "Bare metal diagnostics" - python diagnostics.py - echo "Container diagnostics with bridge network" - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock --network=bridge testcontainers-python python diagnostics.py - echo "Container diagnostics with host network" - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock --network=host testcontainers-python python diagnostics.py - - name: Make docs - if: matrix.python-version == '3.7' - run: | - sphinx-build -nW docs docs/_build/html - - name: Run checks - run: | - flake8 - py.test -svx --cov-config .coveragerc --cov-report html:skip-covered --cov-report term:skip-covered --cov=testcontainers --tb=short tests/test_${{ matrix.test-component }} - codecov diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 000000000..84d805903 --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,19 @@ +name: lint-pr + +on: + pull_request: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + validate: + name: validate-pull-request-title + runs-on: ubuntu-22.04 + steps: + - name: validate pull request title + uses: kontrolplane/pull-request-title-validator@ab2b54babb5337246f4b55cf8e0a1ecb0575e46d #v1 diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml deleted file mode 100644 index 5339e0260..000000000 --- a/.github/workflows/pypi-release.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Upload Python packages to PyPi -on: - push: - tags: - - 'v*' - -jobs: - build: - runs-on: ubuntu-18.04 - env: - python-version: 3.8 - steps: - - uses: actions/checkout@v2 - - - name: Setup python ${{ env.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ env.python-version }} - - - name: Cache Python dependencies - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles(format('requirements/{0}.txt', env.python-version)) }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - pip install -r requirements/${{ env.python-version }}.txt - - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python generate_version.py - python setup.py bdist_wheel - twine upload dist/* diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..9176c6747 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,35 @@ +name: Release Please + +on: + push: + branches: [ main ] + +jobs: + release: + runs-on: ubuntu-22.04 + outputs: + release_created: ${{ steps.track-release.outputs.release_created }} + steps: + - uses: google-github-actions/release-please-action@v4 + id: track-release + with: + manifest-file: .github/.release-please-manifest.json + config-file: .github/release-please-config.json + publish: + runs-on: ubuntu-22.04 + environment: release + permissions: + id-token: write + needs: + - release + if: ${{ needs.release.outputs.release_created }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: ./.github/actions/setup-env + - name: build package + run: poetry build + # this action uploads packages from the `dist/` directory, which poetry has built in the previous step + # usable once we set up trusted publishing, see https://docs.pypi.org/trusted-publishers/using-a-publisher/ + - name: push package + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 9d6acfb77..18837562c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,5 +66,9 @@ venv .testrepository/ # vscode: -.devcontainer/ .vscode/ + +.DS_Store +.python-version +.env +.github-token diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..5808a0000 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +default_language_version: + python: python3 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 'v4.5.0' + hooks: + - id: check-toml + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.3.5' + hooks: + - id: ruff + # Explicitly setting config to prevent Ruff from using `pyproject.toml` in sub packages. + args: [ '--fix', '--exit-non-zero-on-fix', '--config', 'pyproject.toml' ] + - id: ruff-format + args: [ '--config', 'pyproject.toml' ] + +# - repo: local +# hooks: +# - id: mypy +# name: mypy +# entry: poetry run mypy +# args: ["--config-file", "pyproject.toml"] +# files: "core" # start with the core being type checked +# language: system +# types: [ python ] +# require_serial: true diff --git a/.readthedocs.yml b/.readthedocs.yml index 4f1886a13..8675d8c42 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,19 +1,19 @@ -# .readthedocs.yml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# Read the Docs configuration file (see https://docs.readthedocs.io/en/stable/config-file/v2.html +# for details). -# Required version: 2 -# Build documentation in the docs/ directory with Sphinx sphinx: - configuration: docs/conf.py + configuration: conf.py -# Optionally build your docs in additional formats such as PDF and ePub -formats: all +build: + os: ubuntu-22.04 + tools: + python: "3.10" -# Optionally set the version of Python and requirements required to build your docs -python: - version: 3.7 - install: - - requirements: requirements/3.7.txt + # https://github.com/readthedocs/readthedocs.org/issues/4912#issuecomment-1143587902s + jobs: + post_install: + - pip install poetry==1.7.1 # match version from poetry.lock + - poetry config virtualenvs.create false + - poetry install --all-extras diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e001e139b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,274 @@ +# Changelog + +## [4.7.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.7.1...testcontainers-v4.7.2) (2024-07-15) + + +### Bug Fixes + +* Add container Trino ([#642](https://github.com/testcontainers/testcontainers-python/issues/642)) ([49ce5a5](https://github.com/testcontainers/testcontainers-python/commit/49ce5a5ff2ac46cf51920e16c5e39684886b699a)), closes [#641](https://github.com/testcontainers/testcontainers-python/issues/641) +* **core:** Improve private registry support (tolerate not implemented fields in DOCKER_AUTH_CONFIG) ([#647](https://github.com/testcontainers/testcontainers-python/issues/647)) ([766c382](https://github.com/testcontainers/testcontainers-python/commit/766c382a3aee4eb512ee0f482d6595d3412097c3)) +* **kafka:** add a flag to limit to first hostname for use with networks ([#638](https://github.com/testcontainers/testcontainers-python/issues/638)) ([0ce4fec](https://github.com/testcontainers/testcontainers-python/commit/0ce4fecb2872620fd4cb96313abcba4353442cfd)), closes [#637](https://github.com/testcontainers/testcontainers-python/issues/637) +* **modules:** Mailpit container base API URL helper method ([#643](https://github.com/testcontainers/testcontainers-python/issues/643)) ([df07586](https://github.com/testcontainers/testcontainers-python/commit/df07586d8844c757db62ac0f8b7914c67fd96e05)) + +## [4.7.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.7.0...testcontainers-v4.7.1) (2024-07-02) + + +### Bug Fixes + +* **core:** bad rebase from [#579](https://github.com/testcontainers/testcontainers-python/issues/579) ([#635](https://github.com/testcontainers/testcontainers-python/issues/635)) ([4766e48](https://github.com/testcontainers/testcontainers-python/commit/4766e4829407c19de039effc7ea8fcc8b6dcc214)) +* **modules:** Mailpit Container ([#625](https://github.com/testcontainers/testcontainers-python/issues/625)) ([0b866ff](https://github.com/testcontainers/testcontainers-python/commit/0b866ff3c2d462fa5032945dfa2efd4bd59079da)) +* **modules:** SFTP Server Container ([#629](https://github.com/testcontainers/testcontainers-python/issues/629)) ([2e7dbf1](https://github.com/testcontainers/testcontainers-python/commit/2e7dbf1185c68c7cbfb6bdac7457d1d5f86aba19)) +* **network:** Now able to use Network without context, and has labels to be automatically cleaned up ([#627](https://github.com/testcontainers/testcontainers-python/issues/627)) ([#630](https://github.com/testcontainers/testcontainers-python/issues/630)) ([e93bc29](https://github.com/testcontainers/testcontainers-python/commit/e93bc29c1781c4e73840c4c587160f8e5805feea)) +* **postgres:** get_connection_url(driver=None) should return postgres://... ([#588](https://github.com/testcontainers/testcontainers-python/issues/588)) ([01d6c18](https://github.com/testcontainers/testcontainers-python/commit/01d6c182485555ee83f560739c34f089b0e54e0b)), closes [#587](https://github.com/testcontainers/testcontainers-python/issues/587) +* update test module import ([#623](https://github.com/testcontainers/testcontainers-python/issues/623)) ([16f6ca4](https://github.com/testcontainers/testcontainers-python/commit/16f6ca42621866d8ff87ca539a84da27dbe9a4c4)) + +## [4.7.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.6.0...testcontainers-v4.7.0) (2024-06-28) + + +### Features + +* **core:** Add support for ollama module ([#618](https://github.com/testcontainers/testcontainers-python/issues/618)) ([5442d05](https://github.com/testcontainers/testcontainers-python/commit/5442d054cb8bc11887e09d24e29d9f91dd943307)) +* **core:** Added Generic module ([#612](https://github.com/testcontainers/testcontainers-python/issues/612)) ([e575b28](https://github.com/testcontainers/testcontainers-python/commit/e575b28da912147c5b806abab40a0c92329e2eb7)) +* **core:** allow custom dockerfile path for image build and bypassing build cache ([#615](https://github.com/testcontainers/testcontainers-python/issues/615)) ([ead0f79](https://github.com/testcontainers/testcontainers-python/commit/ead0f797902a94d3b2558e489fe2a0a55c3bb7ad)), closes [#610](https://github.com/testcontainers/testcontainers-python/issues/610) +* **core:** DockerCompose.stop now stops only services that it starts (does not stop the other services) ([#620](https://github.com/testcontainers/testcontainers-python/issues/620)) ([e711800](https://github.com/testcontainers/testcontainers-python/commit/e71180039441e3c7d49467298ef0f498fe786149)) + + +### Bug Fixes + +* **cosmosdb:** Add support for the CosmosDB Emulator ([#579](https://github.com/testcontainers/testcontainers-python/issues/579)) ([8045a80](https://github.com/testcontainers/testcontainers-python/commit/8045a806fcb6908567339a14f2f0d7a169461675)) +* improve ollama docs, s/ollama_dir/ollama_home/g ([#619](https://github.com/testcontainers/testcontainers-python/issues/619)) ([27f2a6b](https://github.com/testcontainers/testcontainers-python/commit/27f2a6bdca8b9c860a96920eebc96f53682ea750)) +* **kafka:** Add Kraft to Kafka containers ([#611](https://github.com/testcontainers/testcontainers-python/issues/611)) ([762d2a2](https://github.com/testcontainers/testcontainers-python/commit/762d2a2130f7ce17dacaed5a96a6898a08cf2bc5)) + + +### Documentation + +* **contributing:** add contribution and new-container guide ([#460](https://github.com/testcontainers/testcontainers-python/issues/460)) ([3519f4b](https://github.com/testcontainers/testcontainers-python/commit/3519f4bdad6eac6c172977303b51cf52b4fa4c04)) + +## [4.6.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.5.1...testcontainers-v4.6.0) (2024-06-18) + + +### Features + +* **core:** Added ServerContainer ([#595](https://github.com/testcontainers/testcontainers-python/issues/595)) ([0768490](https://github.com/testcontainers/testcontainers-python/commit/076849015ad3542384ecf8cf6c205d5d498e4986)) +* **core:** Image build (Dockerfile support) ([#585](https://github.com/testcontainers/testcontainers-python/issues/585)) ([54c88cf](https://github.com/testcontainers/testcontainers-python/commit/54c88cf00ad7bb08eb7894c52bed7a9010fd7786)) + + +### Bug Fixes + +* Add Cockroach DB Module to Testcontainers ([#608](https://github.com/testcontainers/testcontainers-python/issues/608)) ([4aff679](https://github.com/testcontainers/testcontainers-python/commit/4aff6793f28fbeb8358adcc728283ea9a7b94e5f)) +* Container for Milvus database ([#606](https://github.com/testcontainers/testcontainers-python/issues/606)) ([ec76df2](https://github.com/testcontainers/testcontainers-python/commit/ec76df27c3d95ac1b79df3a049b4e2c12539081d)) +* move TESTCONTAINERS_HOST_OVERRIDE to config.py ([#603](https://github.com/testcontainers/testcontainers-python/issues/603)) ([2a5a190](https://github.com/testcontainers/testcontainers-python/commit/2a5a1904391020a9da4be17b32f23b36d9385c29)), closes [#602](https://github.com/testcontainers/testcontainers-python/issues/602) +* **mqtt:** Add mqtt.MosquittoContainer ([#568](https://github.com/testcontainers/testcontainers-python/issues/568)) ([#599](https://github.com/testcontainers/testcontainers-python/issues/599)) ([59cb6fc](https://github.com/testcontainers/testcontainers-python/commit/59cb6fc4e7d93870ff2d0d961d14ccd5142a8a05)) + + +### Documentation + +* **main:** Private registry ([#598](https://github.com/testcontainers/testcontainers-python/issues/598)) ([9045c0a](https://github.com/testcontainers/testcontainers-python/commit/9045c0aea6029283490c89aea985e625dcdfc7b9)) +* Update private registry instructions ([#604](https://github.com/testcontainers/testcontainers-python/issues/604)) ([f5a019b](https://github.com/testcontainers/testcontainers-python/commit/f5a019b6d2552788478e4a10cd17f7a2b453abb9)) + +## [4.5.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.5.0...testcontainers-v4.5.1) (2024-05-31) + + +### Bug Fixes + +* **k3s:** add configuration parameter for disabling cgroup mount to avoid "unable to apply cgroup configuration" ([#592](https://github.com/testcontainers/testcontainers-python/issues/592)) ([8917772](https://github.com/testcontainers/testcontainers-python/commit/8917772d8c90d26086af3b9606657c95928e2b9d)) +* **keycloak:** realm import ([#584](https://github.com/testcontainers/testcontainers-python/issues/584)) ([111bd09](https://github.com/testcontainers/testcontainers-python/commit/111bd094428b83233d7eca693d94e10b34ee8ae8)) + +## [4.5.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.4.1...testcontainers-v4.5.0) (2024-05-25) + + +### Features + +* **core:** Private registry ([#566](https://github.com/testcontainers/testcontainers-python/issues/566)) ([59fbcfa](https://github.com/testcontainers/testcontainers-python/commit/59fbcfaf512d1f094e6d8346d45766e810ee2d44)) + + +### Bug Fixes + +* added types to exec & tc_properties_get_tc_host ([#561](https://github.com/testcontainers/testcontainers-python/issues/561)) ([9eabb79](https://github.com/testcontainers/testcontainers-python/commit/9eabb79f213cfb6d8e60173ff4c40f580ae0972a)) +* on windows, DockerCompose.get_service_host returns an unusable "0.0.0.0" - adjust to 127.0.0.1 ([#457](https://github.com/testcontainers/testcontainers-python/issues/457)) ([2aa3d37](https://github.com/testcontainers/testcontainers-python/commit/2aa3d371647877db45eac1663814dcc99de0f6af)) + +## [4.4.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.4.0...testcontainers-v4.4.1) (2024-05-14) + + +### Bug Fixes + +* Add memcached container ([#322](https://github.com/testcontainers/testcontainers-python/issues/322)) ([690b9b4](https://github.com/testcontainers/testcontainers-python/commit/690b9b4526dcdf930c0733c227009af208f47cda)) +* Add selenium video support [#6](https://github.com/testcontainers/testcontainers-python/issues/6) ([#364](https://github.com/testcontainers/testcontainers-python/issues/364)) ([3c8006c](https://github.com/testcontainers/testcontainers-python/commit/3c8006cb6b94d074d2e33d27e972409886bcc7f3)) +* **core:** add empty _configure to DockerContainer ([#556](https://github.com/testcontainers/testcontainers-python/issues/556)) ([08916c8](https://github.com/testcontainers/testcontainers-python/commit/08916c8fa29c835bc5c62fdbdd26ac1546c0c061)) +* **core:** remove version from compose tests ([#571](https://github.com/testcontainers/testcontainers-python/issues/571)) ([38946d4](https://github.com/testcontainers/testcontainers-python/commit/38946d41dacdc4985fc696a5d58cf7d97e367a1c)) +* **keycloak:** add realm imports ([#565](https://github.com/testcontainers/testcontainers-python/issues/565)) ([f761b98](https://github.com/testcontainers/testcontainers-python/commit/f761b983613e16dc56e560a947247c01052c19f6)) +* **mysql:** Add seed support in MySQL ([#552](https://github.com/testcontainers/testcontainers-python/issues/552)) ([396079a](https://github.com/testcontainers/testcontainers-python/commit/396079a5af4c550084df2be5037a0ff52cd9fb5a)) +* url quote passwords ([#549](https://github.com/testcontainers/testcontainers-python/issues/549)) ([6c5d227](https://github.com/testcontainers/testcontainers-python/commit/6c5d227730d415111c54e7ea3cb5d86b549cc901)) + +## [4.4.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.3.3...testcontainers-v4.4.0) (2024-04-17) + + +### Features + +* **labels:** Add common testcontainers labels ([#519](https://github.com/testcontainers/testcontainers-python/issues/519)) ([e04b7ac](https://github.com/testcontainers/testcontainers-python/commit/e04b7ac78ccf6b79fce75ebd3a4626e00d764aa9)) +* **network:** Add network context manager ([#367](https://github.com/testcontainers/testcontainers-python/issues/367)) ([11964de](https://github.com/testcontainers/testcontainers-python/commit/11964deb9e84c0559a391280202811b83a065ab8)) + + +### Bug Fixes + +* **core:** [#486](https://github.com/testcontainers/testcontainers-python/issues/486) for colima delay for port avail for connect ([#543](https://github.com/testcontainers/testcontainers-python/issues/543)) ([90bb780](https://github.com/testcontainers/testcontainers-python/commit/90bb780c30f42d3cfa2f724fb9ca3b6048d1dd9f)) +* **core:** add TESTCONTAINERS_HOST_OVERRIDE as alternative to TC_HOST ([#384](https://github.com/testcontainers/testcontainers-python/issues/384)) ([8073874](https://github.com/testcontainers/testcontainers-python/commit/807387425913906b214f09c141a0bd0c337d788a)) +* **dependencies:** remove usage of `sqlalchemy` in DB extras. Add default wait timeout for `wait_for_logs` ([#525](https://github.com/testcontainers/testcontainers-python/issues/525)) ([fefb9d0](https://github.com/testcontainers/testcontainers-python/commit/fefb9d0845bf6e0cbddad6868da5336b5b82bcb0)) +* tests for Kafka container running on ARM64 CPU ([#536](https://github.com/testcontainers/testcontainers-python/issues/536)) ([29b5179](https://github.com/testcontainers/testcontainers-python/commit/29b51790ba31acf732eb5f017108bcb6622468f9)) + +## [4.3.3](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.3.2...testcontainers-v4.3.3) (2024-04-09) + + +### Bug Fixes + +* missing typing-extensions dependency ([#534](https://github.com/testcontainers/testcontainers-python/issues/534)) ([ef86d15](https://github.com/testcontainers/testcontainers-python/commit/ef86d15f5c63159dcbeb3dbefe9b8fa1964177d9)), closes [#533](https://github.com/testcontainers/testcontainers-python/issues/533) + +## [4.3.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.3.1...testcontainers-v4.3.2) (2024-04-08) + + +### Bug Fixes + +* **core:** Improve typing for common container usage scenarios ([#523](https://github.com/testcontainers/testcontainers-python/issues/523)) ([d5b8553](https://github.com/testcontainers/testcontainers-python/commit/d5b855323be06f8d1395dd480a347f0efef75703)) +* **core:** make config editable to avoid monkeypatching.1 ([#532](https://github.com/testcontainers/testcontainers-python/issues/532)) ([3be6da3](https://github.com/testcontainers/testcontainers-python/commit/3be6da335ba2026b4800dfd6a19cda4ca8e52be8)) +* **vault:** add support for HashiCorp Vault container ([#366](https://github.com/testcontainers/testcontainers-python/issues/366)) ([1326278](https://github.com/testcontainers/testcontainers-python/commit/13262785dedf32a97e392afc1a758616995dc9d9)) + +## [4.3.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.3.0...testcontainers-v4.3.1) (2024-04-02) + + +### Bug Fixes + +* **core:** env vars not being respected due to constructor call ([#524](https://github.com/testcontainers/testcontainers-python/issues/524)) ([4872ea5](https://github.com/testcontainers/testcontainers-python/commit/4872ea5759347e10150c0d80e4e7bbce3d59c410)), closes [#521](https://github.com/testcontainers/testcontainers-python/issues/521) +* Pin MongoDB images and improve test coverage for maintained versions ([#448](https://github.com/testcontainers/testcontainers-python/issues/448)) ([b5c7a1b](https://github.com/testcontainers/testcontainers-python/commit/b5c7a1b95af5470ee1b5109ed1fb8e1b3af52cf7)) + +## [4.3.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.2.0...testcontainers-v4.3.0) (2024-04-01) + + +### Features + +* **client:** Add custom User-Agent in Docker client as `tc-python/<version>` ([#507](https://github.com/testcontainers/testcontainers-python/issues/507)) ([dd55082](https://github.com/testcontainers/testcontainers-python/commit/dd55082991b3405038a90678a39e8c815f0d1fc8)) + + +### Bug Fixes + +* Add CassandraContainer ([#476](https://github.com/testcontainers/testcontainers-python/issues/476)) ([507e466](https://github.com/testcontainers/testcontainers-python/commit/507e466a1fa9ac64c254ceb9ae0d57f6bfd8c89d)) +* add chroma container ([#515](https://github.com/testcontainers/testcontainers-python/issues/515)) ([0729bf4](https://github.com/testcontainers/testcontainers-python/commit/0729bf4af957f8b6638cc204b108358745c0cfc9)) +* Add Weaviate module ([#492](https://github.com/testcontainers/testcontainers-python/issues/492)) ([90762e8](https://github.com/testcontainers/testcontainers-python/commit/90762e817bf49de6d6366212fb48e7edb67ab0c6)) +* **cassandra:** make cassandra dependency optional/test-only ([#518](https://github.com/testcontainers/testcontainers-python/issues/518)) ([bddbaeb](https://github.com/testcontainers/testcontainers-python/commit/bddbaeb20cbd147c429f8020395355402b8a7268)) +* **core:** allow setting docker command path for docker compose ([#512](https://github.com/testcontainers/testcontainers-python/issues/512)) ([63fcd52](https://github.com/testcontainers/testcontainers-python/commit/63fcd52ec2d6ded5f6413166a3690c1138e4dae0)) +* **google:** add support for Datastore emulator ([#508](https://github.com/testcontainers/testcontainers-python/issues/508)) ([3d891a5](https://github.com/testcontainers/testcontainers-python/commit/3d891a5ec62944d01d1bf3d6f70e6aec83f6e516)) +* Improved Oracle DB module ([#363](https://github.com/testcontainers/testcontainers-python/issues/363)) ([6e6d8e3](https://github.com/testcontainers/testcontainers-python/commit/6e6d8e3c919be3efa581704868193e66da54acf3)) +* inconsistent test runs for community modules ([#497](https://github.com/testcontainers/testcontainers-python/issues/497)) ([914f1e5](https://github.com/testcontainers/testcontainers-python/commit/914f1e55bcb3b10260788c3affb8426f77eb9036)) +* **kafka:** Add redpanda testcontainer module ([#441](https://github.com/testcontainers/testcontainers-python/issues/441)) ([451d278](https://github.com/testcontainers/testcontainers-python/commit/451d27865873bb75f4a09a26442572745408d013)) +* **kafka:** wait_for_logs in kafka container to reduce lib requirement ([#377](https://github.com/testcontainers/testcontainers-python/issues/377)) ([909107b](https://github.com/testcontainers/testcontainers-python/commit/909107b221417a39516f961364beb518d2756f45)) +* **keycloak:** container should use dedicated API endpoints to determine container readiness ([#490](https://github.com/testcontainers/testcontainers-python/issues/490)) ([2e27225](https://github.com/testcontainers/testcontainers-python/commit/2e272253148797759748bd40c42f797697d3163f)) +* **nats:** Client-Free(ish) NATS container ([#462](https://github.com/testcontainers/testcontainers-python/issues/462)) ([302c73d](https://github.com/testcontainers/testcontainers-python/commit/302c73ddaa7a6b5bc071ab0cc36d15461cae348b)) +* **new:** add a new Docker Registry test container ([#389](https://github.com/testcontainers/testcontainers-python/issues/389)) ([0f554fb](https://github.com/testcontainers/testcontainers-python/commit/0f554fbaa9511e0221806f57de971abedf1c0bf2)) +* pass doctests, s/doctest/doctests/, run them in gha, s/asyncpg/psycopg/ in doctest, fix keycloak flakiness: wait for first user ([#505](https://github.com/testcontainers/testcontainers-python/issues/505)) ([545240d](https://github.com/testcontainers/testcontainers-python/commit/545240dfdcb2a565ad7cef0e9813f03b9b6f910e)) +* pass updated keyword args to Publisher/Subscriber client in google/pubsub [#161](https://github.com/testcontainers/testcontainers-python/issues/161) ([#164](https://github.com/testcontainers/testcontainers-python/issues/164)) ([8addc11](https://github.com/testcontainers/testcontainers-python/commit/8addc111c94826c2a619a0880d48550673f4d7b9)) +* Qdrant module ([#463](https://github.com/testcontainers/testcontainers-python/issues/463)) ([e8876f4](https://github.com/testcontainers/testcontainers-python/commit/e8876f422abeb29a7236f2174f7e7a324b7d26cb)) +* remove accidentally added pip in dev dependencies ([#516](https://github.com/testcontainers/testcontainers-python/issues/516)) ([dee20a7](https://github.com/testcontainers/testcontainers-python/commit/dee20a76c88445b911d38b4704c2380114a66794)) +* **ryuk:** Enable Ryuk test suite. Ryuk image 0.5.1 -> 0.7.0. Add RYUK_RECONNECTION_TIMEOUT env variable ([#509](https://github.com/testcontainers/testcontainers-python/issues/509)) ([472b2c2](https://github.com/testcontainers/testcontainers-python/commit/472b2c24aec232a04c00dd7dcd9a9f05f2dfaa66)) + +## [4.2.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.1.0...testcontainers-v4.2.0) (2024-03-24) + + +### Features + +* support influxdb ([#413](https://github.com/testcontainers/testcontainers-python/issues/413)) ([13742a5](https://github.com/testcontainers/testcontainers-python/commit/13742a5dc448c80914953c21f8f2b01177c3fa6c)) + + +### Bug Fixes + +* **arangodb:** tests to pass on ARM CPUs - change default image to 3.11.x where ARM image is published ([#479](https://github.com/testcontainers/testcontainers-python/issues/479)) ([7b58a50](https://github.com/testcontainers/testcontainers-python/commit/7b58a50f3a8703c5d5e974a4ff20bc8e52ae93c8)) +* **core:** DinD issues [#141](https://github.com/testcontainers/testcontainers-python/issues/141), [#329](https://github.com/testcontainers/testcontainers-python/issues/329) ([#368](https://github.com/testcontainers/testcontainers-python/issues/368)) ([b10d916](https://github.com/testcontainers/testcontainers-python/commit/b10d916848cccc016fc457333f7b382b18a7b3ef)) +* **core:** raise an exception when docker compose fails to start [#258](https://github.com/testcontainers/testcontainers-python/issues/258) ([#485](https://github.com/testcontainers/testcontainers-python/issues/485)) ([d61af38](https://github.com/testcontainers/testcontainers-python/commit/d61af383def6eadcd7f2b5ba667eb587c6cc84f1)) +* **core:** use auto_remove=True with reaper instance ([#499](https://github.com/testcontainers/testcontainers-python/issues/499)) ([274a400](https://github.com/testcontainers/testcontainers-python/commit/274a4002600ae70662a5785c7a903cf8846b2ffc)) +* **docs:** update the non-existent main.yml badge ([#493](https://github.com/testcontainers/testcontainers-python/issues/493)) ([1d10c1c](https://github.com/testcontainers/testcontainers-python/commit/1d10c1ca8c8163b8d68338e1d50d0e26d7b0515e)) +* Fix the return type of `DockerContainer.get_logs` ([#487](https://github.com/testcontainers/testcontainers-python/issues/487)) ([cd72f68](https://github.com/testcontainers/testcontainers-python/commit/cd72f6896db3eb1fd5ea60f9c051cb719568a12f)) +* **keycloak:** tests on aarch64, use image from [jboss -> quay], change supported version [16+ -> 18+] ([#480](https://github.com/testcontainers/testcontainers-python/issues/480)) ([5758310](https://github.com/testcontainers/testcontainers-python/commit/5758310532b8a8e1303a24bc534fa8aeb0f75eb2)) +* **postgres:** doctest ([#473](https://github.com/testcontainers/testcontainers-python/issues/473)) ([c9c6f92](https://github.com/testcontainers/testcontainers-python/commit/c9c6f92348299a2cc04988af8d69a53a23a7c7d5)) +* read the docs build works again ([#496](https://github.com/testcontainers/testcontainers-python/issues/496)) ([dfd1781](https://github.com/testcontainers/testcontainers-python/commit/dfd17814a7fc9ede510ae17569004bd92f2a6fa6)) +* readthedocs build - take 1 ([#495](https://github.com/testcontainers/testcontainers-python/issues/495)) ([b3b9901](https://github.com/testcontainers/testcontainers-python/commit/b3b990159154857239e2fb86da3cb85a6a13ab8e)) + +## [4.1.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.0.1...testcontainers-v4.1.0) (2024-03-19) + + +### Features + +* **reliability:** integrate the ryuk container for better container cleanup ([#314](https://github.com/testcontainers/testcontainers-python/issues/314)) ([d019874](https://github.com/testcontainers/testcontainers-python/commit/d0198744c3bdc97a7fe41879b54acb2f5ee7becb)) + + +### Bug Fixes + +* changelog after release-please ([#469](https://github.com/testcontainers/testcontainers-python/issues/469)) ([dcb4f68](https://github.com/testcontainers/testcontainers-python/commit/dcb4f6842cbfe6e880a77b0d4aabb3f396c6dc19)) +* **configuration:** strip whitespaces when reading .testcontainers.properties ([#474](https://github.com/testcontainers/testcontainers-python/issues/474)) ([ade144e](https://github.com/testcontainers/testcontainers-python/commit/ade144ee2888d4044ac0c1dc627f47da92789e06)) +* try to fix release-please by setting a bootstrap sha ([#472](https://github.com/testcontainers/testcontainers-python/issues/472)) ([ca65a91](https://github.com/testcontainers/testcontainers-python/commit/ca65a916b719168c57c174d2af77d45fd026ec05)) + +## [4.0.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.0.0...testcontainers-v4.0.1) (2024-03-11) + + +### Features + +* **postgres:** Remove SqlAlchemy dependency from postgres container ([#445](https://github.com/testcontainers/testcontainers-python/issues/445)) ([f30eb1d](https://github.com/testcontainers/testcontainers-python/commit/f30eb1d4c98d3cc20582573b5def76d533a38b80)) + + +### Bug Fixes + +* **clickhouse:** clickhouse waiting ([#428](https://github.com/testcontainers/testcontainers-python/issues/428)) ([902a5a3](https://github.com/testcontainers/testcontainers-python/commit/902a5a3d5112317782db6a9a91d9fc4bfe5701af)) +* Close docker client when stopping the docker container ([#380](https://github.com/testcontainers/testcontainers-python/issues/380)) ([efb1683](https://github.com/testcontainers/testcontainers-python/commit/efb16832dc0be75014c7388f9b241ae0be36ddd4)) +* failing tests for elasticsearch on machines with ARM CPU ([#454](https://github.com/testcontainers/testcontainers-python/issues/454)) ([701b23a](https://github.com/testcontainers/testcontainers-python/commit/701b23a7a0e4632db13e29c52141f9efc67467a1)) +* go back to 4.0.1 ([#465](https://github.com/testcontainers/testcontainers-python/issues/465)) ([1ac8c24](https://github.com/testcontainers/testcontainers-python/commit/1ac8c24d58e93ead951342dcc36e6f8cee2b5fa7)) +* **mongodb:** waiting for container to start (it was not waiting at all before?) ([#461](https://github.com/testcontainers/testcontainers-python/issues/461)) ([2c4f171](https://github.com/testcontainers/testcontainers-python/commit/2c4f171b001f0c45ff84199adf419c7a70ed81c5)) +* unclosed socket warning in db containers ([#378](https://github.com/testcontainers/testcontainers-python/issues/378)) ([cd90aa7](https://github.com/testcontainers/testcontainers-python/commit/cd90aa7310142059cb00f66bbc3693aedf5ddcb2)) +* Update the copyright header for readthedocs ([#341](https://github.com/testcontainers/testcontainers-python/issues/341)) ([5bef18a](https://github.com/testcontainers/testcontainers-python/commit/5bef18a51360a2d74ba393f86b753abdf9ec5636)) + +## [4.0.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.0.0...testcontainers-v4.0.1) (2024-03-11) + + +### Features + +* **postgres:** Remove SqlAlchemy dependency from postgres container ([#445](https://github.com/testcontainers/testcontainers-python/issues/445)) ([f30eb1d](https://github.com/testcontainers/testcontainers-python/commit/f30eb1d4c98d3cc20582573b5def76d533a38b80)) + + +### Bug Fixes + +* **clickhouse:** clickhouse waiting ([#428](https://github.com/testcontainers/testcontainers-python/issues/428)) ([902a5a3](https://github.com/testcontainers/testcontainers-python/commit/902a5a3d5112317782db6a9a91d9fc4bfe5701af)) +* Close docker client when stopping the docker container ([#380](https://github.com/testcontainers/testcontainers-python/issues/380)) ([efb1683](https://github.com/testcontainers/testcontainers-python/commit/efb16832dc0be75014c7388f9b241ae0be36ddd4)) +* failing tests for elasticsearch on machines with ARM CPU ([#454](https://github.com/testcontainers/testcontainers-python/issues/454)) ([701b23a](https://github.com/testcontainers/testcontainers-python/commit/701b23a7a0e4632db13e29c52141f9efc67467a1)) +* **mongodb:** waiting for container to start (it was not waiting at all before?) ([#461](https://github.com/testcontainers/testcontainers-python/issues/461)) ([2c4f171](https://github.com/testcontainers/testcontainers-python/commit/2c4f171b001f0c45ff84199adf419c7a70ed81c5)) +* unclosed socket warning in db containers ([#378](https://github.com/testcontainers/testcontainers-python/issues/378)) ([cd90aa7](https://github.com/testcontainers/testcontainers-python/commit/cd90aa7310142059cb00f66bbc3693aedf5ddcb2)) +* Update the copyright header for readthedocs ([#341](https://github.com/testcontainers/testcontainers-python/issues/341)) ([5bef18a](https://github.com/testcontainers/testcontainers-python/commit/5bef18a51360a2d74ba393f86b753abdf9ec5636)) + +## [4.0.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v3.7.1...testcontainers-v4.0.0) (2024-03-06) + + +### ⚠ BREAKING CHANGES + +* **compose:** implement compose v2 with improved typing ([#426](https://github.com/testcontainers/testcontainers-python/issues/426)) +* **core:** add support for `tc.host` and de-prioritise `docker:dind` ([#388](https://github.com/testcontainers/testcontainers-python/issues/388)) + +### Features + +* **build:** use poetry and organise modules ([#408](https://github.com/testcontainers/testcontainers-python/issues/408)) ([6c69583](https://github.com/testcontainers/testcontainers-python/commit/6c695835520bdcbf9824e8cefa00f7613d2a7cb9)) +* **compose:** allow running specific services in compose ([f61dcda](https://github.com/testcontainers/testcontainers-python/commit/f61dcda8bd7ea329cd3c836b6d6e2f0bd990335d)) +* **compose:** implement compose v2 with improved typing ([#426](https://github.com/testcontainers/testcontainers-python/issues/426)) ([5356caf](https://github.com/testcontainers/testcontainers-python/commit/5356caf2de056313a5b3f2805ed80e6a23b027a8)) +* **core:** add support for `tc.host` and de-prioritise `docker:dind` ([#388](https://github.com/testcontainers/testcontainers-python/issues/388)) ([2db8e6d](https://github.com/testcontainers/testcontainers-python/commit/2db8e6d123d42b57309408dd98ba9a06acc05c4b)) +* **redis:** support AsyncRedisContainer ([#442](https://github.com/testcontainers/testcontainers-python/issues/442)) ([cc4cb37](https://github.com/testcontainers/testcontainers-python/commit/cc4cb3762802dc75b0801727d8b1f1a1c56b7f50)) +* **release:** automate release via release-please ([#429](https://github.com/testcontainers/testcontainers-python/issues/429)) ([30f859e](https://github.com/testcontainers/testcontainers-python/commit/30f859eb1535acd6e93c331213426e1319ee9a47)) + + +### Bug Fixes + +* Added URLError to exceptions to wait for in elasticsearch ([0f9ad24](https://github.com/testcontainers/testcontainers-python/commit/0f9ad24f2c0df362ee15b81ce8d7d36b9f98e6e1)) +* **build:** add `pre-commit` as a dev dependency to simplify local dev and CI ([#438](https://github.com/testcontainers/testcontainers-python/issues/438)) ([1223583](https://github.com/testcontainers/testcontainers-python/commit/1223583d8fc3a1ab95441d82c7e1ece57f026fbf)) +* **build:** early exit strategy for modules ([#437](https://github.com/testcontainers/testcontainers-python/issues/437)) ([7358b49](https://github.com/testcontainers/testcontainers-python/commit/7358b4919c1010315a384a8f0fe2860e5a0ca6b4)) +* changed files breaks on main ([#422](https://github.com/testcontainers/testcontainers-python/issues/422)) ([3271357](https://github.com/testcontainers/testcontainers-python/commit/32713578dcf07f672a87818e00562b58874b4a52)) +* flaky garbage collection resulting in testing errors ([#423](https://github.com/testcontainers/testcontainers-python/issues/423)) ([b535ea2](https://github.com/testcontainers/testcontainers-python/commit/b535ea255bcaaa546f8cda7b2b17718c1cc7f3ca)) +* rabbitmq readiness probe ([#375](https://github.com/testcontainers/testcontainers-python/issues/375)) ([71cb75b](https://github.com/testcontainers/testcontainers-python/commit/71cb75b281df55ece4d5caf5d487059a7f38c34f)) +* **release:** prove that the release process updates the version ([#444](https://github.com/testcontainers/testcontainers-python/issues/444)) ([87b5873](https://github.com/testcontainers/testcontainers-python/commit/87b5873c1ec3a3e4e74742417d6068fa86cf1762)) +* test linting issue ([427c9b8](https://github.com/testcontainers/testcontainers-python/commit/427c9b841c2f6f516ec6cb74d5bd2839cb1939f4)) + + +### Documentation + +* Sphinx - Add title to each doc page ([#443](https://github.com/testcontainers/testcontainers-python/issues/443)) ([750e12a](https://github.com/testcontainers/testcontainers-python/commit/750e12a41172ce4aaf045c61dec33d318dc3c2f6)) diff --git a/Dockerfile b/Dockerfile index fb4ad5d24..c86c9e2df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -ARG version=3.8 -FROM python:${version} +ARG PYTHON_VERSION +FROM python:${version}-slim-bookworm WORKDIR /workspace RUN pip install --upgrade pip \ @@ -7,8 +7,10 @@ RUN pip install --upgrade pip \ && apt-get install -y \ freetds-dev \ && rm -rf /var/lib/apt/lists/* -ARG version=3.8 -COPY requirements/${version}.txt requirements.txt -COPY setup.py README.rst ./ + +# install requirements we exported from poetry +COPY build/requirements.txt requirements.txt RUN pip install -r requirements.txt + +# copy project source COPY . . diff --git a/Dockerfile.diagnostics b/Dockerfile.diagnostics index 83bc6621c..687b447c5 100644 --- a/Dockerfile.diagnostics +++ b/Dockerfile.diagnostics @@ -2,6 +2,6 @@ ARG version=3.8 FROM python:${version} WORKDIR /workspace -COPY setup.py README.rst ./ -RUN pip install -e . -COPY . . +COPY core core +RUN pip install --no-cache-dir -e core +COPY scripts/diagnostics.py . diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ceeea233f..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include VERSION diff --git a/Makefile b/Makefile index 1309ebe2b..9a4fd6f94 100644 --- a/Makefile +++ b/Makefile @@ -1,43 +1,78 @@ -PYTHON_VERSIONS = 3.7 3.8 3.9 3.10 -REQUIREMENTS = $(addprefix requirements/,${PYTHON_VERSIONS:=.txt}) -TESTS = $(addprefix tests/,${PYTHON_VERSIONS}) -IMAGES = $(addprefix image/,${PYTHON_VERSIONS}) -RUN = docker run --rm -it +.DEFAULT_GOAL := help -.PHONY : docs -# Default target +PYTHON_VERSION ?= 3.10 +IMAGE = testcontainers-python:${PYTHON_VERSION} +PACKAGES = core $(addprefix modules/,$(notdir $(wildcard modules/*))) -default : tests/3.8 +UPLOAD = $(addsuffix /upload,${PACKAGES}) +TESTS = $(addsuffix /tests,$(filter-out meta,${PACKAGES})) +TESTS_DIND = $(addsuffix -dind,${TESTS}) +DOCTESTS = $(addsuffix /doctests,$(filter-out modules/README.md,${PACKAGES})) -# Targets to build requirement files +install: ## Set up the project for development + poetry install --all-extras + poetry run pre-commit install -requirements : ${REQUIREMENTS} +build: ## Build the python package + poetry build && poetry run twine check dist/* -${REQUIREMENTS} : requirements/%.txt : requirements.in setup.py - mkdir -p $(dir $@) - ${RUN} -w /workspace -v `pwd`:/workspace python:$* bash -c \ - "pip install pip-tools && pip-compile -v --upgrade -o $@ $<" +tests: ${TESTS} ## Run tests for each package +${TESTS}: %/tests: + poetry run pytest -v --cov=testcontainers.$* $*/tests +coverage: ## Target to combine and report coverage. + poetry run coverage combine + poetry run coverage report + poetry run coverage xml + poetry run coverage html -# Targets to build docker images +lint: ## Lint all files in the project, which we also run in pre-commit + poetry run pre-commit run -a -images : ${IMAGES} +image: ## Make the docker image for dind tests + poetry export -f requirements.txt -o build/requirements.txt + docker build --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE} . -${IMAGES} : image/% : requirements/%.txt - docker build --build-arg version=$* -t testcontainers-python:$* . +DOCKER_RUN = docker run --rm -v /var/run/docker.sock:/var/run/docker.sock +tests-dind: ${TESTS_DIND} ## Run the tests in docker containers to test `dind` +${TESTS_DIND}: %/tests-dind: image + ${DOCKER_RUN} ${IMAGE} \ + bash -c "make $*/tests" -# Targets to run tests in docker containers +docs: ## Build the docs for the project + poetry run sphinx-build -nW . docs/_build -tests : ${TESTS} +# Target to build docs watching for changes as per https://stackoverflow.com/a/21389615 +docs-watch : + poetry run sphinx-autobuild . docs/_build # requires 'pip install sphinx-autobuild' -${TESTS} : tests/% : image/% - ${RUN} -v /var/run/docker.sock:/var/run/docker.sock testcontainers-python:$* \ - bash -c "flake8 && pytest -v ${ARGS}" +doctests: ${DOCTESTS} ## Run doctests found across the documentation. + poetry run sphinx-build -b doctest . docs/_build -# Target to build the documentation +${DOCTESTS}: %/doctests: ## Run doctests found for a module. + poetry run sphinx-build -b doctest -c doctests $* docs/_build -docs : - sphinx-build -nW docs docs/_build/html + +clean: ## Remove generated files. + rm -rf docs/_build + rm -rf build + rm -rf dist + rm -rf */*.egg-info + +clean-all: clean ## Remove all generated files and reset the local virtual environment + rm -rf .venv + +# Targets that do not generate file-level artifacts. +.PHONY: clean docs doctests image tests ${TESTS} + + +# Implements this pattern for autodocumenting Makefiles: +# https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html +# +# Picks up all comments that start with a ## and are at the end of a target definition line. +.PHONY: help +help: ## Display command usage + @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md new file mode 100644 index 000000000..43d5d2aa6 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +[![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +![PyPI - Version](https://img.shields.io/pypi/v/testcontainers) +[![PyPI - License](https://img.shields.io/pypi/l/testcontainers.svg)](https://github.com/testcontainers/testcontainers-python/blob/main/LICENSE) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/testcontainers.svg)](https://pypi.python.org/pypi/testcontainers) +[![codecov](https://codecov.io/gh/testcontainers/testcontainers-python/branch/master/graph/badge.svg)](https://codecov.io/gh/testcontainers/testcontainers-python) +![Core Tests](https://github.com/testcontainers/testcontainers-python/actions/workflows/ci-core.yml/badge.svg) +![Community Tests](https://github.com/testcontainers/testcontainers-python/actions/workflows/ci-community.yml/badge.svg) +[![Docs](https://readthedocs.org/projects/testcontainers-python/badge/?version=latest)](http://testcontainers-python.readthedocs.io/en/latest/?badge=latest) + +[![Codespace](https://github.com/codespaces/badge.svg)](https://codespaces.new/testcontainers/testcontainers-python) + +# Testcontainers Python + +`testcontainers-python` facilitates the use of Docker containers for functional and integration testing. + +For more information, see [the docs][readthedocs]. + +[readthedocs]: https://testcontainers-python.readthedocs.io/en/latest/ + +## Getting Started + +```pycon +>>> from testcontainers.postgres import PostgresContainer +>>> import sqlalchemy + +>>> with PostgresContainer("postgres:16") as postgres: +... engine = sqlalchemy.create_engine(postgres.get_connection_url()) +... with engine.begin() as connection: +... result = connection.execute(sqlalchemy.text("select version()")) +... version, = result.fetchone() +>>> version +'PostgreSQL 16...' +``` + +The snippet above will spin up a postgres database in a container. The `get_connection_url()` convenience method returns a `sqlalchemy` compatible url we use to connect to the database and retrieve the database version. + +## Contributing / Development / Release + +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more details. + +## Configuration + +| Env Variable | Example | Description | +| --------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------- | +| `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` | `/var/run/docker.sock` | Path to Docker's socket used by ryuk | +| `TESTCONTAINERS_RYUK_PRIVILEGED` | `false` | Run ryuk as a privileged container | +| `TESTCONTAINERS_RYUK_DISABLED` | `false` | Disable ryuk | +| `RYUK_CONTAINER_IMAGE` | `testcontainers/ryuk:0.8.1` | Custom image for ryuk | +| `RYUK_RECONNECTION_TIMEOUT` | `10s` | Reconnection timeout for Ryuk TCP socket before Ryuk reaps all dangling containers | diff --git a/README.rst b/README.rst deleted file mode 100644 index ddc4272c1..000000000 --- a/README.rst +++ /dev/null @@ -1,90 +0,0 @@ -testcontainers-python -===================== - -.. image:: https://github.com/testcontainers/testcontainers-python/workflows/testcontainers-python/badge.svg - :target: https://github.com/testcontainers/testcontainers-python/actions/workflows/main.yml -.. image:: https://img.shields.io/pypi/v/testcontainers.svg?style=flat-square - :target: https://pypi.python.org/pypi/testcontainers -.. image:: https://readthedocs.org/projects/testcontainers-python/badge/?version=latest - :target: http://testcontainers-python.readthedocs.io/en/latest/?badge=latest - -Python port for testcontainers-java that allows using docker containers for functional and integration testing. Testcontainers-python provides capabilities to spin up docker containers (such as a database, Selenium web browser, or any other container) for testing. - -Currently available features: - -* Selenium Grid containers -* Selenium Standalone containers -* MySql Db container -* MariaDb container -* Neo4j container -* OracleDb container -* PostgreSQL Db container -* ClickHouse container -* Microsoft SQL Server container -* Generic docker containers -* LocalStack -* RabbitMQ -* Keycloak - -Installation ------------- - -The testcontainers package is available from `PyPI `_, and it can be installed using :code:`pip`. Depending on which containers are needed, you can specify additional dependencies as `extras `_: - -.. code-block:: bash - - # Install without extras - pip install testcontainers - # Install with one or more extras - pip install testcontainers[mysql] - pip install testcontainers[mysql,oracle] - -Basic usage ------------ - -.. code-block:: python - - import sqlalchemy - from testcontainers.mysql import MySqlContainer - - with MySqlContainer('mysql:5.7.17') as mysql: - engine = sqlalchemy.create_engine(mysql.get_connection_url()) - version, = engine.execute("select version()").fetchone() - print(version) # 5.7.17 - -The snippet above will spin up a MySql database in a container. The :code:`get_connection_url()` convenience method returns a :code:`sqlalchemy` compatible url we use to connect to the database and retrieve the database version. - -More extensive documentation can be found at `Read The Docs `_. - -Usage within Docker (i.e. in a CI) ----------------------------------- - -When trying to launch a testcontainer from within a Docker container two things have to be provided: - -1. The container has to provide a docker client installation. Either use an image that has docker pre-installed (e.g. the `official docker images `_) or install the client from within the `Dockerfile` specification. -2. The container has to have access to the docker daemon which can be achieved by mounting `/var/run/docker.sock` or setting the `DOCKER_HOST` environment variable as part of your `docker run` command. - - -Setting up a development environment ------------------------------------- - -We recommend you use a `virtual environment `_ for development. Note that a python version :code:`>=3.6` is required. After setting up your virtual environment, you can install all dependencies and test the installation by running the following snippet. - -.. code-block:: bash - - pip install -r requirements/$(python -c 'import sys; print("%d.%d" % sys.version_info[:2])').txt - pytest -s - -Adding requirements -^^^^^^^^^^^^^^^^^^^ - -We use :code:`pip-tools` to resolve and manage dependencies. If you need to add a dependency to testcontainers or one of the extras, modify the :code:`setup.py` as well as the :code:`requirements.in` accordingly and then run :code:`pip install pip-tools` followed by :code:`make requirements` to update the requirements files. - -Contributing a new container -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -You can contribute a new container in three steps: - -1. Create a new module at :code:`testcontainers/[my fancy container].py` that implements the new functionality. -2. Create a new test module at :code:`tests/test_[my fancy container].py` that tests the new functionality. -3. Add :code:`[my fancy container]` to the list of test components in the GitHub Action configuration at :code:`.github/workflows/main.yml`. diff --git a/docs/conf.py b/conf.py similarity index 76% rename from docs/conf.py rename to conf.py index 7270d20d5..b310e939b 100644 --- a/docs/conf.py +++ b/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # testcontainers documentation build configuration file, created by # sphinx-quickstart on Tue Mar 21 21:09:48 2017. # @@ -31,50 +29,56 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", ] +# Configure autodoc to avoid excessively long fully-qualified names. +add_module_names = False +autodoc_typehints_format = "short" + # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'testcontainers' -copyright = u'2017, Sergey Pirogov' -author = u'Sergey Pirogov' +project = "testcontainers" +copyright = "2017-2024, Sergey Pirogov and Testcontainers Python contributors" # noqa: A001 +author = "Sergey Pirogov" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'2.0.0' +version = "2.0.0" # The full version, including alpha/beta/rc tags. -release = u'2.0.0' +release = "2.0.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ".venv", ".git"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -85,7 +89,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -102,7 +106,7 @@ # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'testcontainersdoc' +htmlhelp_basename = "testcontainersdoc" # -- Options for LaTeX output --------------------------------------------- @@ -111,15 +115,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -129,8 +130,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'testcontainers.tex', u'testcontainers Documentation', - u'Sergey Pirogov', 'manual'), + (master_doc, "testcontainers.tex", "testcontainers Documentation", "Sergey Pirogov", "manual"), ] @@ -138,10 +138,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'testcontainers', u'testcontainers Documentation', - [author], 1) -] +man_pages = [(master_doc, "testcontainers", "testcontainers Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -150,7 +147,18 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'testcontainers', u'testcontainers Documentation', - author, 'testcontainers', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "testcontainers", + "testcontainers Documentation", + author, + "testcontainers", + "One line description of project.", + "Miscellaneous", + ), ] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "selenium": ("https://seleniumhq.github.io/selenium/docs/api/py/", None), +} diff --git a/core/README.rst b/core/README.rst new file mode 100644 index 000000000..8cc9a2780 --- /dev/null +++ b/core/README.rst @@ -0,0 +1,32 @@ +Testcontainers Core +=================== + +:code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments. + +.. autoclass:: testcontainers.core.container.DockerContainer + +.. autoclass:: testcontainers.core.image.DockerImage + +.. autoclass:: testcontainers.core.generic.DbContainer + +.. raw:: html + +
+ +Examples +-------- + +Using `DockerContainer` and `DockerImage` to create a container: + +.. doctest:: + + >>> from testcontainers.core.container import DockerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image: + ... with DockerContainer(str(image)) as container: + ... delay = wait_for_logs(container, "Test Sample Image") + +The `DockerImage` class is used to build the image from the specified path and tag. +The `DockerContainer` class is then used to create a container from the image. diff --git a/core/testcontainers/compose/__init__.py b/core/testcontainers/compose/__init__.py new file mode 100644 index 000000000..9af994f30 --- /dev/null +++ b/core/testcontainers/compose/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +from testcontainers.compose.compose import ( + ContainerIsNotRunning, + NoSuchPortExposed, + PublishedPort, + ComposeContainer, + DockerCompose, +) diff --git a/core/testcontainers/compose/compose.py b/core/testcontainers/compose/compose.py new file mode 100644 index 000000000..564eda8fb --- /dev/null +++ b/core/testcontainers/compose/compose.py @@ -0,0 +1,450 @@ +from dataclasses import asdict, dataclass, field, fields +from functools import cached_property +from json import loads +from logging import warning +from os import PathLike +from platform import system +from re import split +from subprocess import CompletedProcess +from subprocess import run as subprocess_run +from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed +from testcontainers.core.waiting_utils import wait_container_is_ready + +_IPT = TypeVar("_IPT") +_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"} + + +def _ignore_properties(cls: type[_IPT], dict_: any) -> _IPT: + """omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true) + + https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5""" + if isinstance(dict_, cls): + return dict_ + class_fields = {f.name for f in fields(cls)} + filtered = {k: v for k, v in dict_.items() if k in class_fields} + return cls(**filtered) + + +@dataclass +class PublishedPort: + """ + Class that represents the response we get from compose when inquiring status + via `DockerCompose.get_running_containers()`. + """ + + URL: Optional[str] = None + TargetPort: Optional[str] = None + PublishedPort: Optional[str] = None + Protocol: Optional[str] = None + + def normalize(self): + url_not_usable = system() == "Windows" and self.URL == "0.0.0.0" + if url_not_usable: + self_dict = asdict(self) + self_dict.update({"URL": "127.0.0.1"}) + return PublishedPort(**self_dict) + return self + + +OT = TypeVar("OT") + + +def get_only_element_or_raise(array: list[OT], exception: Callable[[], Exception]) -> OT: + if len(array) != 1: + e = exception() + raise e + return array[0] + + +@dataclass +class ComposeContainer: + """ + A container class that represents a container managed by compose. + It is not a true testcontainers.core.container.DockerContainer, + but you can use the id with DockerClient to get that one too. + """ + + ID: Optional[str] = None + Name: Optional[str] = None + Command: Optional[str] = None + Project: Optional[str] = None + Service: Optional[str] = None + State: Optional[str] = None + Health: Optional[str] = None + ExitCode: Optional[str] = None + Publishers: list[PublishedPort] = field(default_factory=list) + + def __post_init__(self): + if self.Publishers: + self.Publishers = [_ignore_properties(PublishedPort, p) for p in self.Publishers] + + def get_publisher( + self, + by_port: Optional[int] = None, + by_host: Optional[str] = None, + prefer_ip_version: Literal["IPV4", "IPv6"] = "IPv4", + ) -> PublishedPort: + remaining_publishers = self.Publishers + + remaining_publishers = [r for r in remaining_publishers if self._matches_protocol(prefer_ip_version, r)] + + if by_port: + remaining_publishers = [item for item in remaining_publishers if by_port == item.TargetPort] + if by_host: + remaining_publishers = [item for item in remaining_publishers if by_host == item.URL] + if len(remaining_publishers) == 0: + raise NoSuchPortExposed(f"Could not find publisher for for service {self.Service}") + return get_only_element_or_raise( + remaining_publishers, + lambda: NoSuchPortExposed( + "get_publisher failed because there is " + f"not exactly 1 publisher for service {self.Service}" + f" when filtering by_port={by_port}, by_host={by_host}" + f" (but {len(remaining_publishers)})" + ), + ) + + @staticmethod + def _matches_protocol(prefer_ip_version, r): + return (":" in r.URL) is (prefer_ip_version == "IPv6") + + +@dataclass +class DockerCompose: + """ + Manage docker compose environments. + + Args: + context: + The docker context. It corresponds to the directory containing + the docker compose configuration file. + compose_file_name: + Optional. File name of the docker compose configuration file. + If specified, you need to also specify the overrides if any. + pull: + Pull images before launching environment. + build: + Run `docker compose build` before running the environment. + wait: + Wait for the services to be healthy + (as per healthcheck definitions in the docker compose configuration) + env_file: + Path to an '.env' file containing environment variables + to pass to docker compose. + services: + The list of services to use from this DockerCompose. + client_args: + arguments to pass to docker.from_env() + + Example: + + This example spins up chrome and firefox containers using docker compose. + + .. doctest:: + + >>> from testcontainers.compose import DockerCompose + + >>> compose = DockerCompose("compose/tests", compose_file_name="docker-compose-4.yml", + ... pull=True) + >>> with compose: + ... stdout, stderr = compose.get_logs() + >>> b"Hello from Docker!" in stdout + True + + .. code-block:: yaml + + services: + hello-world: + image: "hello-world" + """ + + context: Union[str, PathLike] + compose_file_name: Optional[Union[str, list[str]]] = None + pull: bool = False + build: bool = False + wait: bool = True + keep_volumes: bool = False + env_file: Optional[str] = None + services: Optional[list[str]] = None + docker_command_path: Optional[str] = None + + def __post_init__(self): + if isinstance(self.compose_file_name, str): + self.compose_file_name = [self.compose_file_name] + + def __enter__(self) -> "DockerCompose": + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.stop(not self.keep_volumes) + + def docker_compose_command(self) -> list[str]: + """ + Returns command parts used for the docker compose commands + + Returns: + cmd: Docker compose command parts. + """ + return self.compose_command_property + + @cached_property + def compose_command_property(self) -> list[str]: + docker_compose_cmd = [self.docker_command_path or "docker", "compose"] + if self.compose_file_name: + for file in self.compose_file_name: + docker_compose_cmd += ["-f", file] + if self.env_file: + docker_compose_cmd += ["--env-file", self.env_file] + return docker_compose_cmd + + def start(self) -> None: + """ + Starts the docker compose environment. + """ + base_cmd = self.compose_command_property or [] + + # pull means running a separate command before starting + if self.pull: + pull_cmd = [*base_cmd, "pull"] + self._run_command(cmd=pull_cmd) + + up_cmd = [*base_cmd, "up"] + + # build means modifying the up command + if self.build: + up_cmd.append("--build") + + if self.wait: + up_cmd.append("--wait") + else: + # we run in detached mode instead of blocking + up_cmd.append("--detach") + + if self.services: + up_cmd.extend(self.services) + + self._run_command(cmd=up_cmd) + + def stop(self, down=True) -> None: + """ + Stops the docker compose environment. + """ + down_cmd = self.compose_command_property[:] + if down: + down_cmd += ["down", "--volumes"] + else: + down_cmd += ["stop"] + + if self.services: + down_cmd.extend(self.services) + + self._run_command(cmd=down_cmd) + + def get_logs(self, *services: str) -> tuple[str, str]: + """ + Returns all log output from stdout and stderr of a specific container. + + :param services: which services to get the logs for (or omit, for all) + + Returns: + stdout: Standard output stream. + stderr: Standard error stream. + """ + logs_cmd = [*self.compose_command_property, "logs", *services] + + result = self._run_command(cmd=logs_cmd) + return result.stdout.decode("utf-8"), result.stderr.decode("utf-8") + + def get_config( + self, *, path_resolution: bool = True, normalize: bool = True, interpolate: bool = True + ) -> dict[str, Any]: + """ + Parse, resolve and returns compose file via `docker config --format json`. + In case of multiple compose files, the returned value will be a merge of all files. + + See: https://docs.docker.com/reference/cli/docker/compose/config/ for more details + + :param path_resolution: whether to resolve file paths + :param normalize: whether to normalize compose model + :param interpolate: whether to interpolate environment variables + + Returns: + Compose file + + """ + if "DOCKER_COMPOSE_GET_CONFIG" in _WARNINGS: + warning(_WARNINGS.pop("DOCKER_COMPOSE_GET_CONFIG")) + config_cmd = [*self.compose_command_property, "config", "--format", "json"] + if not path_resolution: + config_cmd.append("--no-path-resolution") + if not normalize: + config_cmd.append("--no-normalize") + if not interpolate: + config_cmd.append("--no-interpolate") + + cmd_output = self._run_command(cmd=config_cmd).stdout + return cast(dict[str, Any], loads(cmd_output)) + + def get_containers(self, include_all=False) -> list[ComposeContainer]: + """ + Fetch information about running containers via `docker compose ps --format json`. + Available only in V2 of compose. + + Returns: + The list of running containers. + + """ + + cmd = [*self.compose_command_property, "ps", "--format", "json"] + if include_all: + cmd = [*cmd, "-a"] + result = self._run_command(cmd=cmd) + stdout = split(r"\r?\n", result.stdout.decode("utf-8")) + + containers = [] + # one line per service in docker 25, single array for docker 24.0.2 + for line in stdout: + if not line: + continue + data = loads(line) + if isinstance(data, list): + containers += [_ignore_properties(ComposeContainer, d) for d in data] + else: + containers.append(_ignore_properties(ComposeContainer, data)) + + return containers + + def get_container( + self, + service_name: Optional[str] = None, + include_all: bool = False, + ) -> ComposeContainer: + if not service_name: + containers = self.get_containers(include_all=include_all) + return get_only_element_or_raise( + containers, + lambda: ContainerIsNotRunning( + "get_container failed because no service_name given " + f"and there is not exactly 1 container (but {len(containers)})" + ), + ) + + matching_containers = [ + item for item in self.get_containers(include_all=include_all) if item.Service == service_name + ] + + if not matching_containers: + raise ContainerIsNotRunning(f"{service_name} is not running in the compose context") + + return matching_containers[0] + + def exec_in_container( + self, + command: list[str], + service_name: Optional[str] = None, + ) -> tuple[str, str, int]: + """ + Executes a command in the container of one of the services. + + Args: + service_name: Name of the docker compose service to run the command in. + command: Command to execute. + + :param service_name: specify the service name + :param command: the command to run in the container + + Returns: + stdout: Standard output stream. + stderr: Standard error stream. + exit_code: The command's exit code. + """ + if not service_name: + service_name = self.get_container().Service + exec_cmd = [*self.compose_command_property, "exec", "-T", service_name, *command] + result = self._run_command(cmd=exec_cmd) + + return (result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), result.returncode) + + def _run_command( + self, + cmd: Union[str, list[str]], + context: Optional[str] = None, + ) -> CompletedProcess[bytes]: + context = context or self.context + return subprocess_run( + cmd, + capture_output=True, + check=True, + cwd=context, + ) + + def get_service_port( + self, + service_name: Optional[str] = None, + port: Optional[int] = None, + ): + """ + Returns the mapped port for one of the services. + + Parameters + ---------- + service_name: str + Name of the docker compose service + port: int + The internal port to get the mapping for + + Returns + ------- + str: + The mapped port on the host + """ + return self.get_container(service_name).get_publisher(by_port=port).normalize().PublishedPort + + def get_service_host( + self, + service_name: Optional[str] = None, + port: Optional[int] = None, + ): + """ + Returns the host for one of the services. + + Parameters + ---------- + service_name: str + Name of the docker compose service + port: int + The internal port to get the host for + + Returns + ------- + str: + The hostname for the service + """ + return self.get_container(service_name).get_publisher(by_port=port).normalize().URL + + def get_service_host_and_port( + self, + service_name: Optional[str] = None, + port: Optional[int] = None, + ): + publisher = self.get_container(service_name).get_publisher(by_port=port).normalize() + return publisher.URL, publisher.PublishedPort + + @wait_container_is_ready(HTTPError, URLError) + def wait_for(self, url: str) -> "DockerCompose": + """ + Waits for a response from a given URL. This is typically used to block until a service in + the environment has started and is responding. Note that it does not assert any sort of + return code, only check that the connection was successful. + + Args: + url: URL from one of the services in the environment to use to wait on. + """ + with urlopen(url) as response: + response.read() + return self diff --git a/testcontainers/__init__.py b/core/testcontainers/core/__init__.py similarity index 100% rename from testcontainers/__init__.py rename to core/testcontainers/core/__init__.py diff --git a/core/testcontainers/core/auth.py b/core/testcontainers/core/auth.py new file mode 100644 index 000000000..906dca1d5 --- /dev/null +++ b/core/testcontainers/core/auth.py @@ -0,0 +1,88 @@ +import base64 as base64 +import json as json +from collections import namedtuple +from logging import warning +from typing import Optional + +DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"]) + +_AUTH_WARNINGS = { + "credHelpers": "DOCKER_AUTH_CONFIG is experimental, credHelpers not supported yet", + "credsStore": "DOCKER_AUTH_CONFIG is experimental, credsStore not supported yet", +} + + +def process_docker_auth_config_encoded(auth_config_dict: dict) -> list[DockerAuthInfo]: + """ + Process the auths config. + + Example: + { + "auths": { + "https://index.docker.io/v1/": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + } + } + } + + Returns a list of DockerAuthInfo objects. + """ + auth_info: list[DockerAuthInfo] = [] + + auths = auth_config_dict.get("auths") + for registry, auth in auths.items(): + auth_str = auth.get("auth") + auth_str = base64.b64decode(auth_str).decode("utf-8") + username, password = auth_str.split(":") + auth_info.append(DockerAuthInfo(registry, username, password)) + + return auth_info + + +def process_docker_auth_config_cred_helpers(auth_config_dict: dict) -> None: + """ + Process the credHelpers config. + + Example: + { + "credHelpers": { + ".dkr.ecr..amazonaws.com": "ecr-login" + } + } + + This is not supported yet. + """ + if "credHelpers" in _AUTH_WARNINGS: + warning(_AUTH_WARNINGS.pop("credHelpers")) + + +def process_docker_auth_config_store(auth_config_dict: dict) -> None: + """ + Process the credsStore config. + + Example: + { + "credsStore": "ecr-login" + } + + This is not supported yet. + """ + if "credsStore" in _AUTH_WARNINGS: + warning(_AUTH_WARNINGS.pop("credsStore")) + + +def parse_docker_auth_config(auth_config: str) -> Optional[list[DockerAuthInfo]]: + """Parse the docker auth config from a string and handle the different formats.""" + try: + auth_config_dict: dict = json.loads(auth_config) + if "credHelpers" in auth_config: + process_docker_auth_config_cred_helpers(auth_config_dict) + if "credsStore" in auth_config: + process_docker_auth_config_store(auth_config_dict) + if "auths" in auth_config: + return process_docker_auth_config_encoded(auth_config_dict) + + except (json.JSONDecodeError, KeyError, ValueError) as exp: + raise ValueError("Could not parse docker auth config") from exp + + return None diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py new file mode 100644 index 000000000..7b7279511 --- /dev/null +++ b/core/testcontainers/core/config.py @@ -0,0 +1,97 @@ +from dataclasses import dataclass, field +from logging import warning +from os import environ +from os.path import exists +from pathlib import Path +from typing import Optional, Union + +MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120)) +SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1)) +TIMEOUT = MAX_TRIES * SLEEP_TIME + +RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1") +RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true" +RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true" +RYUK_DOCKER_SOCKET: str = environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/run/docker.sock") +RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s") +TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE")) + +TC_FILE = ".testcontainers.properties" +TC_GLOBAL = Path.home() / TC_FILE + + +def read_tc_properties() -> dict[str, str]: + """ + Read the .testcontainers.properties for settings. (see the Java implementation for details) + Currently we only support the ~/.testcontainers.properties but may extend to per-project variables later. + + :return: the merged properties from the sources. + """ + tc_files = [item for item in [TC_GLOBAL] if exists(item)] + if not tc_files: + return {} + settings = {} + + for file in tc_files: + with open(file) as contents: + tuples = [line.split("=") for line in contents.readlines() if "=" in line] + settings = {**settings, **{item[0].strip(): item[1].strip() for item in tuples}} + return settings + + +_WARNINGS = {"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566"} + + +@dataclass +class TestcontainersConfiguration: + max_tries: int = MAX_TRIES + sleep_time: int = SLEEP_TIME + ryuk_image: str = RYUK_IMAGE + ryuk_privileged: bool = RYUK_PRIVILEGED + ryuk_disabled: bool = RYUK_DISABLED + ryuk_docker_socket: str = RYUK_DOCKER_SOCKET + ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT + tc_properties: dict[str, str] = field(default_factory=read_tc_properties) + _docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG")) + tc_host_override: Optional[str] = TC_HOST_OVERRIDE + """ + https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644 + if os env TC_HOST is set, use it + """ + + @property + def docker_auth_config(self): + config = self._docker_auth_config + if config and "DOCKER_AUTH_CONFIG" in _WARNINGS: + warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG")) + return config + + @docker_auth_config.setter + def docker_auth_config(self, value: str): + if "DOCKER_AUTH_CONFIG" in _WARNINGS: + warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG")) + self._docker_auth_config = value + + def tc_properties_get_tc_host(self) -> Union[str, None]: + return self.tc_properties.get("tc.host") + + @property + def timeout(self): + return self.max_tries * self.sleep_time + + +testcontainers_config = TestcontainersConfiguration() + +__all__ = [ + # the public API of this module + "testcontainers_config", + # and all the legacy things that are deprecated: + "MAX_TRIES", + "SLEEP_TIME", + "TIMEOUT", + "RYUK_IMAGE", + "RYUK_PRIVILEGED", + "RYUK_DISABLED", + "RYUK_DOCKER_SOCKET", + "RYUK_RECONNECTION_TIMEOUT", +] diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py new file mode 100644 index 000000000..e9415441a --- /dev/null +++ b/core/testcontainers/core/container.py @@ -0,0 +1,267 @@ +import contextlib +from platform import system +from socket import socket +from typing import TYPE_CHECKING, Optional + +import docker.errors +from docker import version +from docker.types import EndpointConfig +from typing_extensions import Self + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.exceptions import ContainerStartException +from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID +from testcontainers.core.network import Network +from testcontainers.core.utils import inside_container, is_arm, setup_logger +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +if TYPE_CHECKING: + from docker.models.containers import Container + +logger = setup_logger(__name__) + + +class DockerContainer: + """ + Basic container object to spin up Docker instances. + + .. doctest:: + + >>> from testcontainers.core.container import DockerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + + >>> with DockerContainer("hello-world") as container: + ... delay = wait_for_logs(container, "Hello from Docker!") + """ + + def __init__( + self, + image: str, + docker_client_kw: Optional[dict] = None, + **kwargs, + ) -> None: + self.env = {} + self.ports = {} + self.volumes = {} + self.image = image + self._docker = DockerClient(**(docker_client_kw or {})) + self._container = None + self._command = None + self._name = None + self._network: Optional[Network] = None + self._network_aliases: Optional[list[str]] = None + self._kwargs = kwargs + + def with_env(self, key: str, value: str) -> Self: + self.env[key] = value + return self + + def with_bind_ports(self, container: int, host: Optional[int] = None) -> Self: + self.ports[container] = host + return self + + def with_exposed_ports(self, *ports: int) -> Self: + for port in ports: + self.ports[port] = None + return self + + def with_network(self, network: Network) -> Self: + self._network = network + return self + + def with_network_aliases(self, *aliases) -> Self: + self._network_aliases = aliases + return self + + def with_kwargs(self, **kwargs) -> Self: + self._kwargs = kwargs + return self + + def maybe_emulate_amd64(self) -> Self: + if is_arm(): + return self.with_kwargs(platform="linux/amd64") + return self + + def start(self) -> Self: + if not c.ryuk_disabled and self.image != c.ryuk_image: + logger.debug("Creating Ryuk container") + Reaper.get_instance() + logger.info("Pulling image %s", self.image) + docker_client = self.get_docker_client() + self._configure() + + network_kwargs = ( + { + "network": self._network.name, + "networking_config": { + self._network.name: EndpointConfig(version.__version__, aliases=self._network_aliases) + }, + } + if self._network + else {} + ) + + self._container = docker_client.run( + self.image, + command=self._command, + detach=True, + environment=self.env, + ports=self.ports, + name=self._name, + volumes=self.volumes, + **network_kwargs, + **self._kwargs, + ) + + logger.info("Container started: %s", self._container.short_id) + return self + + def stop(self, force=True, delete_volume=True) -> None: + if self._container: + self._container.remove(force=force, v=delete_volume) + self.get_docker_client().client.close() + + def __enter__(self) -> Self: + return self.start() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.stop() + + def get_container_host_ip(self) -> str: + # infer from docker host + host = self.get_docker_client().host() + if not host: + return "localhost" + # see https://github.com/testcontainers/testcontainers-python/issues/415 + if host == "localnpipe" and system() == "Windows": + return "localhost" + + # # check testcontainers itself runs inside docker container + # if inside_container() and not os.getenv("DOCKER_HOST") and not host.startswith("http://"): + # # If newly spawned container's gateway IP address from the docker + # # "bridge" network is equal to detected host address, we should use + # # container IP address, otherwise fall back to detected host + # # address. Even it's inside container, we need to double check, + # # because docker host might be set to docker:dind, usually in CI/CD environment + # gateway_ip = self.get_docker_client().gateway_ip(self._container.id) + + # if gateway_ip == host: + # return self.get_docker_client().bridge_ip(self._container.id) + # return gateway_ip + return host + + @wait_container_is_ready() + def get_exposed_port(self, port: int) -> str: + mapped_port = self.get_docker_client().port(self._container.id, port) + if inside_container(): + gateway_ip = self.get_docker_client().gateway_ip(self._container.id) + host = self.get_docker_client().host() + + if gateway_ip == host: + return port + return mapped_port + + def with_command(self, command: str) -> Self: + self._command = command + return self + + def with_name(self, name: str) -> Self: + self._name = name + return self + + def with_volume_mapping(self, host: str, container: str, mode: str = "ro") -> Self: + mapping = {"bind": container, "mode": mode} + self.volumes[host] = mapping + return self + + def get_wrapped_container(self) -> "Container": + return self._container + + def get_docker_client(self) -> DockerClient: + return self._docker + + def get_logs(self) -> tuple[bytes, bytes]: + if not self._container: + raise ContainerStartException("Container should be started before getting logs") + return self._container.logs(stderr=False), self._container.logs(stdout=False) + + def exec(self, command) -> tuple[int, bytes]: + if not self._container: + raise ContainerStartException("Container should be started before executing a command") + return self._container.exec_run(command) + + def _configure(self) -> None: + # placeholder if subclasses want to define this and use the default start method + pass + + +class Reaper: + _instance: "Optional[Reaper]" = None + _container: Optional[DockerContainer] = None + _socket: Optional[socket] = None + + @classmethod + def get_instance(cls) -> "Reaper": + if not Reaper._instance: + Reaper._instance = Reaper._create_instance() + + return Reaper._instance + + @classmethod + def delete_instance(cls) -> None: + if Reaper._socket is not None: + Reaper._socket.close() + Reaper._socket = None + + if Reaper._container is not None and Reaper._container._container is not None: + with contextlib.suppress(docker.errors.NotFound): + Reaper._container.stop() + Reaper._container = None + + if Reaper._instance is not None: + Reaper._instance = None + + @classmethod + def _create_instance(cls) -> "Reaper": + logger.debug(f"Creating new Reaper for session: {SESSION_ID}") + + Reaper._container = ( + DockerContainer(c.ryuk_image) + .with_name(f"testcontainers-ryuk-{SESSION_ID}") + .with_exposed_ports(8080) + .with_volume_mapping(c.ryuk_docker_socket, "/var/run/docker.sock", "rw") + .with_kwargs(privileged=c.ryuk_privileged, auto_remove=True) + .with_env("RYUK_RECONNECTION_TIMEOUT", c.ryuk_reconnection_timeout) + .start() + ) + wait_for_logs(Reaper._container, r".* Started!") + + container_host = Reaper._container.get_container_host_ip() + container_port = int(Reaper._container.get_exposed_port(8080)) + + last_connection_exception: Optional[Exception] = None + for _ in range(50): + try: + Reaper._socket = socket() + Reaper._socket.connect((container_host, container_port)) + last_connection_exception = None + break + except (ConnectionRefusedError, OSError) as e: + if Reaper._socket is not None: + with contextlib.suppress(Exception): + Reaper._socket.close() + Reaper._socket = None + last_connection_exception = e + + from time import sleep + + sleep(0.5) + if last_connection_exception: + raise last_connection_exception + + Reaper._socket.send(f"label={LABEL_SESSION_ID}={SESSION_ID}\r\n".encode()) + + Reaper._instance = Reaper() + + return Reaper._instance diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py new file mode 100644 index 000000000..c540b9f7f --- /dev/null +++ b/core/testcontainers/core/docker_client.py @@ -0,0 +1,227 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import functools as ft +import importlib.metadata +import ipaddress +import os +import urllib +import urllib.parse +from collections.abc import Iterable +from typing import Callable, Optional, TypeVar, Union + +import docker +from docker.models.containers import Container, ContainerCollection +from docker.models.images import Image, ImageCollection +from typing_extensions import ParamSpec + +from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.labels import SESSION_ID, create_labels +from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger + +LOGGER = setup_logger(__name__) + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def _wrapped_container_collection(function: Callable[_P, _T]) -> Callable[_P, _T]: + @ft.wraps(ContainerCollection.run) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + return function(*args, **kwargs) + + return wrapper + + +def _wrapped_image_collection(function: Callable[_P, _T]) -> Callable[_P, _T]: + @ft.wraps(ImageCollection.build) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + return function(*args, **kwargs) + + return wrapper + + +class DockerClient: + """ + Thin wrapper around :class:`docker.DockerClient` for a more functional interface. + """ + + def __init__(self, **kwargs) -> None: + docker_host = get_docker_host() + + if docker_host: + LOGGER.info(f"using host {docker_host}") + os.environ["DOCKER_HOST"] = docker_host + self.client = docker.from_env(**kwargs) + else: + self.client = docker.from_env(**kwargs) + self.client.api.headers["x-tc-sid"] = SESSION_ID + self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers") + + # Verify if we have a docker auth config and login if we do + if docker_auth_config := get_docker_auth_config(): + LOGGER.debug(f"DOCKER_AUTH_CONFIG found: {docker_auth_config}") + if auth_config := parse_docker_auth_config(docker_auth_config): + self.login(auth_config[0]) # Only using the first auth config) + + @_wrapped_container_collection + def run( + self, + image: str, + command: Optional[Union[str, list[str]]] = None, + environment: Optional[dict] = None, + ports: Optional[dict] = None, + labels: Optional[dict[str, str]] = None, + detach: bool = False, + stdout: bool = True, + stderr: bool = False, + remove: bool = False, + **kwargs, + ) -> Container: + # If the user has specified a network, we'll assume the user knows best + if "network" not in kwargs and not get_docker_host(): + # Otherwise we'll try to find the docker host for dind usage. + host_network = self.find_host_network() + if host_network: + kwargs["network"] = host_network + container = self.client.containers.run( + image, + command=command, + stdout=stdout, + stderr=stderr, + remove=remove, + detach=detach, + environment=environment, + ports=ports, + labels=create_labels(image, labels), + **kwargs, + ) + return container + + @_wrapped_image_collection + def build(self, path: str, tag: str, rm: bool = True, **kwargs) -> tuple[Image, Iterable[dict]]: + """ + Build a Docker image from a directory containing the Dockerfile. + + :return: A tuple containing the image object and the build logs. + """ + image_object, image_logs = self.client.images.build(path=path, tag=tag, rm=rm, **kwargs) + + return image_object, image_logs + + def find_host_network(self) -> Optional[str]: + """ + Try to find the docker host network. + + :return: The network name if found, None if not set. + """ + # If we're docker in docker running on a custom network, we need to inherit the + # network settings, so we can access the resulting container. + try: + docker_host = ipaddress.IPv4Address(self.host()) + # See if we can find the host on our networks + for network in self.client.networks.list(filters={"type": "custom"}): + if "IPAM" in network.attrs: + for config in network.attrs["IPAM"]["Config"]: + try: + subnet = ipaddress.IPv4Network(config["Subnet"]) + except ipaddress.AddressValueError: + continue + if docker_host in subnet: + return network.name + except ipaddress.AddressValueError: + pass + return None + + def port(self, container_id: str, port: int) -> int: + """ + Lookup the public-facing port that is NAT-ed to :code:`port`. + """ + port_mappings = self.client.api.port(container_id, port) + if not port_mappings: + raise ConnectionError(f"Port mapping for container {container_id} and port {port} is " "not available") + return port_mappings[0]["HostPort"] + + def get_container(self, container_id: str) -> Container: + """ + Get the container with a given identifier. + """ + containers = self.client.api.containers(filters={"id": container_id}) + if not containers: + raise RuntimeError(f"Could not get container with id {container_id}") + return containers[0] + + def bridge_ip(self, container_id: str) -> str: + """ + Get the bridge ip address for a container. + """ + container = self.get_container(container_id) + network_name = self.network_name(container_id) + return container["NetworkSettings"]["Networks"][network_name]["IPAddress"] + + def network_name(self, container_id: str) -> str: + """ + Get the name of the network this container runs on + """ + container = self.get_container(container_id) + name = container["HostConfig"]["NetworkMode"] + if name == "default": + return "bridge" + return name + + def gateway_ip(self, container_id: str) -> str: + """ + Get the gateway ip address for a container. + """ + container = self.get_container(container_id) + network_name = self.network_name(container_id) + return container["NetworkSettings"]["Networks"][network_name]["Gateway"] + + def host(self) -> str: + """ + Get the hostname or ip address of the docker host. + """ + host = c.tc_host_override + if host: + return host + try: + url = urllib.parse.urlparse(self.client.api.base_url) + + except ValueError: + return "localhost" + if "http" in url.scheme or "tcp" in url.scheme: + return url.hostname + if inside_container() and ("unix" in url.scheme or "npipe" in url.scheme): + ip_address = default_gateway_ip() + if ip_address: + return ip_address + return "localhost" + + def login(self, auth_config: DockerAuthInfo) -> None: + """ + Login to a docker registry using the given auth config. + """ + login_info = self.client.login(**auth_config._asdict()) + LOGGER.debug(f"logged in using {login_info}") + + def client_networks_create(self, name: str, param: dict): + labels = create_labels("", param.get("labels")) + return self.client.networks.create(name, **{**param, "labels": labels}) + + +def get_docker_host() -> Optional[str]: + return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST") + + +def get_docker_auth_config() -> Optional[str]: + return c.docker_auth_config diff --git a/testcontainers/core/exceptions.py b/core/testcontainers/core/exceptions.py similarity index 78% rename from testcontainers/core/exceptions.py rename to core/testcontainers/core/exceptions.py index b7e8447cd..6694e598b 100644 --- a/testcontainers/core/exceptions.py +++ b/core/testcontainers/core/exceptions.py @@ -12,17 +12,13 @@ # under the License. -class ContainerStartException(Exception): +class ContainerStartException(RuntimeError): pass -class TimeoutException(Exception): +class ContainerIsNotRunning(RuntimeError): pass -class NoSuchBrowserException(Exception): - pass - - -class NoSuchPortExposed(Exception): +class NoSuchPortExposed(RuntimeError): pass diff --git a/testcontainers/core/generic.py b/core/testcontainers/core/generic.py similarity index 50% rename from testcontainers/core/generic.py rename to core/testcontainers/core/generic.py index 65c02f5fa..b2cd3010d 100644 --- a/testcontainers/core/generic.py +++ b/core/testcontainers/core/generic.py @@ -10,56 +10,74 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +from typing import Optional +from urllib.parse import quote from testcontainers.core.container import DockerContainer +from testcontainers.core.exceptions import ContainerStartException +from testcontainers.core.utils import raise_for_deprecated_parameter from testcontainers.core.waiting_utils import wait_container_is_ready -from deprecation import deprecated + ADDITIONAL_TRANSIENT_ERRORS = [] try: from sqlalchemy.exc import DBAPIError + ADDITIONAL_TRANSIENT_ERRORS.append(DBAPIError) except ImportError: pass class DbContainer(DockerContainer): - def __init__(self, image, **kwargs): - super(DbContainer, self).__init__(image, **kwargs) + """ + **DEPRECATED (for removal)** + + Generic database container. + """ @wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS) - def _connect(self): + def _connect(self) -> None: import sqlalchemy + engine = sqlalchemy.create_engine(self.get_connection_url()) - engine.connect() + try: + engine.connect() + finally: + engine.dispose() - def get_connection_url(self): + def get_connection_url(self) -> str: raise NotImplementedError - def _create_connection_url(self, dialect, username, password, - host=None, port=None, db_name=None): + def _create_connection_url( + self, + dialect: str, + username: str, + password: str, + host: Optional[str] = None, + port: Optional[int] = None, + dbname: Optional[str] = None, + **kwargs, + ) -> str: + if raise_for_deprecated_parameter(kwargs, "db_name", "dbname"): + raise ValueError(f"Unexpected arguments: {','.join(kwargs)}") if self._container is None: - raise RuntimeError("container has not been started") - if not host: - host = self.get_container_host_ip() + raise ContainerStartException("container has not been started") + host = host or self.get_container_host_ip() port = self.get_exposed_port(port) - url = "{dialect}://{username}:{password}@{host}:{port}".format( - dialect=dialect, username=username, password=password, host=host, port=port - ) - if db_name: - url += '/' + db_name + quoted_password = quote(password, safe=" +") + url = f"{dialect}://{username}:{quoted_password}@{host}:{port}" + if dbname: + url = f"{url}/{dbname}" return url - def start(self): + def start(self) -> "DbContainer": self._configure() super().start() + self._transfer_seed() self._connect() return self - def _configure(self): + def _configure(self) -> None: raise NotImplementedError - -class GenericContainer(DockerContainer): - @deprecated(details="Use `DockerContainer`.") - def __init__(self, image): - super(GenericContainer, self).__init__(image) + def _transfer_seed(self) -> None: + pass diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py new file mode 100644 index 000000000..6d793f83e --- /dev/null +++ b/core/testcontainers/core/image.py @@ -0,0 +1,98 @@ +from os import PathLike +from typing import TYPE_CHECKING, Optional, Union + +from typing_extensions import Self + +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.utils import setup_logger + +if TYPE_CHECKING: + from docker.models.containers import Image + +logger = setup_logger(__name__) + + +class DockerImage: + """ + Basic image object to build Docker images. + + .. doctest:: + + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image: + ... logs = image.get_logs() + + :param tag: Tag for the image to be built (default: None) + :param path: Path to the build context + :param dockerfile_path: Path to the Dockerfile within the build context path (default: Dockerfile) + :param no_cache: Bypass build cache; CLI's --no-cache + """ + + def __init__( + self, + path: Union[str, PathLike], + docker_client_kw: Optional[dict] = None, + tag: Optional[str] = None, + clean_up: bool = True, + dockerfile_path: Union[str, PathLike] = "Dockerfile", + no_cache: bool = False, + **kwargs, + ) -> None: + self.tag = tag + self.path = path + self._docker = DockerClient(**(docker_client_kw or {})) + self.clean_up = clean_up + self._kwargs = kwargs + self._image = None + self._logs = None + self._dockerfile_path = dockerfile_path + self._no_cache = no_cache + + def build(self, **kwargs) -> Self: + logger.info(f"Building image from {self.path}") + docker_client = self.get_docker_client() + self._image, self._logs = docker_client.build( + path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **kwargs + ) + logger.info(f"Built image {self.short_id} with tag {self.tag}") + return self + + @property + def short_id(self) -> str: + """ + The ID of the image truncated to 12 characters, without the ``sha256:`` prefix. + """ + if self._image.id.startswith("sha256:"): + return self._image.id.split(":")[1][:12] + return self._image.id[:12] + + def remove(self, force=True, noprune=False) -> None: + """ + Remove the image. + + :param force: Remove the image even if it is in use + :param noprune: Do not delete untagged parent images + """ + if self._image and self.clean_up: + logger.info(f"Removing image {self.short_id}") + self._image.remove(force=force, noprune=noprune) + self.get_docker_client().client.close() + + def __str__(self) -> str: + return f"{self.tag if self.tag else self.short_id}" + + def __enter__(self) -> Self: + return self.build() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.remove() + + def get_wrapped_image(self) -> "Image": + return self._image + + def get_docker_client(self) -> DockerClient: + return self._docker + + def get_logs(self) -> list[dict]: + return list(self._logs) diff --git a/core/testcontainers/core/labels.py b/core/testcontainers/core/labels.py new file mode 100644 index 000000000..0570b22cb --- /dev/null +++ b/core/testcontainers/core/labels.py @@ -0,0 +1,32 @@ +import importlib +from typing import Optional +from uuid import uuid4 + +from testcontainers.core.config import testcontainers_config as c + +SESSION_ID: str = str(uuid4()) +TESTCONTAINERS_NAMESPACE = "org.testcontainers" + +LABEL_TESTCONTAINERS = TESTCONTAINERS_NAMESPACE +LABEL_SESSION_ID = "org.testcontainers.session-id" +LABEL_VERSION = "org.testcontainers.version" +LABEL_LANG = "org.testcontainers.lang" + + +def create_labels(image: str, labels: Optional[dict[str, str]]) -> dict[str, str]: + if labels is None: + labels = {} + else: + for k in labels: + if k.startswith(TESTCONTAINERS_NAMESPACE): + raise ValueError("The org.testcontainers namespace is reserved for internal use") + + labels[LABEL_LANG] = "python" + labels[LABEL_TESTCONTAINERS] = "true" + labels[LABEL_VERSION] = importlib.metadata.version("testcontainers") + + if image == c.ryuk_image: + return labels + + labels[LABEL_SESSION_ID] = SESSION_ID + return labels diff --git a/core/testcontainers/core/network.py b/core/testcontainers/core/network.py new file mode 100644 index 000000000..d149d5e48 --- /dev/null +++ b/core/testcontainers/core/network.py @@ -0,0 +1,44 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import uuid +from typing import Optional + +from testcontainers.core.docker_client import DockerClient + + +class Network: + """ + Network context manager for programmatically connecting containers. + """ + + def __init__(self, docker_client_kw: Optional[dict] = None, docker_network_kw: Optional[dict] = None) -> None: + self.name = str(uuid.uuid4()) + self._docker = DockerClient(**(docker_client_kw or {})) + self._docker_network_kw = docker_network_kw or {} + + def connect(self, container_id: str, network_aliases: Optional[list] = None): + self._network.connect(container_id, aliases=network_aliases) + + def remove(self) -> None: + self._network.remove() + + def create(self) -> "Network": + self._network = self._docker.client_networks_create(self.name, self._docker_network_kw) + self.id = self._network.id + return self + + def __enter__(self) -> "Network": + return self.create() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.remove() diff --git a/testcontainers/core/utils.py b/core/testcontainers/core/utils.py similarity index 57% rename from testcontainers/core/utils.py rename to core/testcontainers/core/utils.py index 47fd1de27..5ca1c2f7d 100644 --- a/testcontainers/core/utils.py +++ b/core/testcontainers/core/utils.py @@ -1,15 +1,15 @@ +import logging import os import platform -import sys import subprocess -import logging +import sys LINUX = "linux" MAC = "mac" WIN = "win" -def setup_logger(name): +def setup_logger(name: str) -> logging.Logger: logger = logging.getLogger(name) logger.setLevel(logging.INFO) handler = logging.StreamHandler() @@ -18,7 +18,7 @@ def setup_logger(name): return logger -def os_name(): +def os_name() -> str: pl = sys.platform if pl == "linux" or pl == "linux2": return LINUX @@ -28,32 +28,32 @@ def os_name(): return WIN -def is_mac(): - return MAC == os_name() +def is_mac() -> bool: + return os_name() == MAC -def is_linux(): - return LINUX == os_name() +def is_linux() -> bool: + return os_name() == LINUX -def is_windows(): - return WIN == os_name() +def is_windows() -> bool: + return os_name() == WIN -def is_arm(): - return platform.machine() in ('arm64', 'aarch64') +def is_arm() -> bool: + return platform.machine() in ("arm64", "aarch64") -def inside_container(): +def inside_container() -> bool: """ Returns true if we are running inside a container. https://github.com/docker/docker/blob/a9fa38b1edf30b23cae3eade0be48b3d4b1de14b/daemon/initlayer/setup_unix.go#L25 """ - return os.path.exists('/.dockerenv') + return os.path.exists("/.dockerenv") -def default_gateway_ip(): +def default_gateway_ip() -> str: """ Returns gateway IP address of the host that testcontainer process is running on @@ -62,10 +62,18 @@ def default_gateway_ip(): """ cmd = ["sh", "-c", "ip route|awk '/default/ { print $3 }'"] try: - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) ip_address = process.communicate()[0] if ip_address and process.returncode == 0: - return ip_address.decode('utf-8').strip().strip('\n') + return ip_address.decode("utf-8").strip().strip("\n") except subprocess.SubprocessError: return None + + +def raise_for_deprecated_parameter(kwargs: dict, name: str, replacement: str) -> dict: + """ + Raise an error if a dictionary of keyword arguments contains a key and suggest the replacement. + """ + if kwargs.pop(name, None): + raise ValueError(f"Use `{replacement}` instead of `{name}`") + return kwargs diff --git a/core/testcontainers/core/version.py b/core/testcontainers/core/version.py new file mode 100644 index 000000000..cac51fc18 --- /dev/null +++ b/core/testcontainers/core/version.py @@ -0,0 +1,30 @@ +from typing import Callable + +from packaging.version import Version + + +class ComparableVersion: + def __init__(self, version): + self.version = Version(version) + + def __lt__(self, other: str): + return self._apply_op(other, lambda x, y: x < y) + + def __le__(self, other: str): + return self._apply_op(other, lambda x, y: x <= y) + + def __eq__(self, other: str): + return self._apply_op(other, lambda x, y: x == y) + + def __ne__(self, other: str): + return self._apply_op(other, lambda x, y: x != y) + + def __gt__(self, other: str): + return self._apply_op(other, lambda x, y: x > y) + + def __ge__(self, other: str): + return self._apply_op(other, lambda x, y: x >= y) + + def _apply_op(self, other: str, op: Callable[[Version, Version], bool]): + other = Version(other) + return op(self.version, other) diff --git a/core/testcontainers/core/waiting_utils.py b/core/testcontainers/core/waiting_utils.py new file mode 100644 index 000000000..82ea1f15f --- /dev/null +++ b/core/testcontainers/core/waiting_utils.py @@ -0,0 +1,120 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import re +import time +import traceback +from typing import TYPE_CHECKING, Any, Callable, Union + +import wrapt + +from testcontainers.core.config import testcontainers_config as config +from testcontainers.core.utils import setup_logger + +if TYPE_CHECKING: + from testcontainers.core.container import DockerContainer + +logger = setup_logger(__name__) + +# Get a tuple of transient exceptions for which we'll retry. Other exceptions will be raised. +TRANSIENT_EXCEPTIONS = (TimeoutError, ConnectionError) + + +def wait_container_is_ready(*transient_exceptions) -> Callable: + """ + Wait until container is ready. + + Function that spawn container should be decorated by this method Max wait is configured by + config. Default is 120 sec. Polling interval is 1 sec. + + Args: + *transient_exceptions: Additional transient exceptions that should be retried if raised. Any + non-transient exceptions are fatal, and the exception is re-raised immediately. + """ + transient_exceptions = TRANSIENT_EXCEPTIONS + tuple(transient_exceptions) + + @wrapt.decorator + def wrapper(wrapped: Callable, instance: Any, args: list, kwargs: dict) -> Any: + from testcontainers.core.container import DockerContainer + + if isinstance(instance, DockerContainer): + logger.info("Waiting for container %s with image %s to be ready ...", instance._container, instance.image) + else: + logger.info("Waiting for %s to be ready ...", instance) + + exception = None + for attempt_no in range(config.max_tries): + try: + return wrapped(*args, **kwargs) + except transient_exceptions as e: + logger.debug( + f"Connection attempt '{attempt_no + 1}' of '{config.max_tries + 1}' " + f"failed: {traceback.format_exc()}" + ) + time.sleep(config.sleep_time) + exception = e + raise TimeoutError( + f"Wait time ({config.timeout}s) exceeded for {wrapped.__name__}(args: {args}, kwargs: " + f"{kwargs}). Exception: {exception}" + ) + + return wrapper + + +@wait_container_is_ready() +def wait_for(condition: Callable[..., bool]) -> bool: + return condition() + + +def wait_for_logs( + container: "DockerContainer", + predicate: Union[Callable, str], + timeout: float = config.timeout, + interval: float = 1, + predicate_streams_and: bool = False, + # +) -> float: + """ + Wait for the container to emit logs satisfying the predicate. + + Args: + container: Container whose logs to wait for. + predicate: Predicate that should be satisfied by the logs. If a string, then it is used as + the pattern for a multiline regular expression search. + timeout: Number of seconds to wait for the predicate to be satisfied. Defaults to wait + indefinitely. + interval: Interval at which to poll the logs. + predicate_streams_and: should the predicate be applied to both + + Returns: + duration: Number of seconds until the predicate was satisfied. + """ + if isinstance(predicate, str): + predicate = re.compile(predicate, re.MULTILINE).search + start = time.time() + while True: + duration = time.time() - start + stdout = container.get_logs()[0].decode() + stderr = container.get_logs()[1].decode() + predicate_result = ( + predicate(stdout) or predicate(stderr) + if predicate_streams_and is False + else predicate(stdout) and predicate(stderr) + # + ) + if predicate_result: + return duration + if duration > timeout: + raise TimeoutError(f"Container did not emit logs satisfying predicate in {timeout:.3f} " "seconds") + time.sleep(interval) diff --git a/core/tests/compose_fixtures/basic/docker-compose.yaml b/core/tests/compose_fixtures/basic/docker-compose.yaml new file mode 100644 index 000000000..f6fd6a2a1 --- /dev/null +++ b/core/tests/compose_fixtures/basic/docker-compose.yaml @@ -0,0 +1,8 @@ +services: + alpine: + image: alpine:latest + init: true + command: + - sh + - -c + - 'while true; do sleep 0.1 ; date -Ins; done' diff --git a/core/tests/compose_fixtures/basic_multiple/docker-compose.yaml b/core/tests/compose_fixtures/basic_multiple/docker-compose.yaml new file mode 100644 index 000000000..38bd92b94 --- /dev/null +++ b/core/tests/compose_fixtures/basic_multiple/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + alpine1: + image: alpine:latest + init: true + command: + - sh + - -c + - 'while true; do sleep 0.1 ; date -Ins; done' + alpine2: + image: alpine:latest + init: true + command: + - sh + - -c + - 'while true; do sleep 0.1 ; date -Ins; done' diff --git a/core/tests/compose_fixtures/basic_volume/docker-compose.yaml b/core/tests/compose_fixtures/basic_volume/docker-compose.yaml new file mode 100644 index 000000000..371ab9d38 --- /dev/null +++ b/core/tests/compose_fixtures/basic_volume/docker-compose.yaml @@ -0,0 +1,17 @@ +services: + alpine: + image: alpine:latest + init: true + command: + - sh + - -c + - 'while true; do sleep 0.1 ; date -Ins; done' + read_only: true + volumes: + - type: volume + source: my-data + target: /var/lib/example/data + read_only: false + +volumes: + my-data: {} diff --git a/core/tests/compose_fixtures/port_multiple/compose.yaml b/core/tests/compose_fixtures/port_multiple/compose.yaml new file mode 100644 index 000000000..e8e147bbd --- /dev/null +++ b/core/tests/compose_fixtures/port_multiple/compose.yaml @@ -0,0 +1,26 @@ +services: + alpine: + image: nginx:alpine-slim + init: true + ports: + - '81' + - '82' + - target: 80 + host_ip: 127.0.0.1 + protocol: tcp + command: + - sh + - -c + - 'd=/etc/nginx/conf.d; echo "server { listen 81; location / { return 202; } }" > $$d/81.conf && echo "server { listen 82; location / { return 204; } }" > $$d/82.conf && nginx -g "daemon off;"' + + alpine2: + image: nginx:alpine-slim + init: true + ports: + - target: 80 + host_ip: 127.0.0.1 + protocol: tcp + command: + - sh + - -c + - 'd=/etc/nginx/conf.d; echo "server { listen 81; location / { return 202; } }" > $$d/81.conf && echo "server { listen 82; location / { return 204; } }" > $$d/82.conf && nginx -g "daemon off;"' diff --git a/core/tests/compose_fixtures/port_single/compose.yaml b/core/tests/compose_fixtures/port_single/compose.yaml new file mode 100644 index 000000000..88c19ab61 --- /dev/null +++ b/core/tests/compose_fixtures/port_single/compose.yaml @@ -0,0 +1,12 @@ +services: + alpine: + image: nginx:alpine-slim + init: true + ports: + - target: 80 + host_ip: 127.0.0.1 + protocol: tcp + command: + - sh + - -c + - 'nginx -g "daemon off;"' diff --git a/core/tests/conftest.py b/core/tests/conftest.py new file mode 100644 index 000000000..4f69565f4 --- /dev/null +++ b/core/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from typing import Callable +from testcontainers.core.container import DockerClient + + +@pytest.fixture +def check_for_image() -> Callable[[str, bool], None]: + """Warp the check_for_image function in a fixture""" + + def _check_for_image(image_short_id: str, cleaned: bool) -> None: + """ + Validates if the image is present or not. + + :param image_short_id: The short id of the image + :param cleaned: True if the image should not be present, False otherwise + """ + client = DockerClient() + images = client.client.images.list() + found = any(image.short_id.endswith(image_short_id) for image in images) + assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}' + + return _check_for_image diff --git a/tests/test_core/Dockerfile b/core/tests/image_fixtures/busybox/Dockerfile similarity index 100% rename from tests/test_core/Dockerfile rename to core/tests/image_fixtures/busybox/Dockerfile diff --git a/core/tests/image_fixtures/sample/Dockerfile b/core/tests/image_fixtures/sample/Dockerfile new file mode 100644 index 000000000..d7d786035 --- /dev/null +++ b/core/tests/image_fixtures/sample/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +CMD echo "Test Sample Image" diff --git a/core/tests/test_auth.py b/core/tests/test_auth.py new file mode 100644 index 000000000..a7581f42e --- /dev/null +++ b/core/tests/test_auth.py @@ -0,0 +1,84 @@ +import json +import pytest + +from testcontainers.core.auth import parse_docker_auth_config, DockerAuthInfo + + +def test_parse_docker_auth_config_encoded(): + auth_config_json = '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}' + auth_info = parse_docker_auth_config(auth_config_json) + assert len(auth_info) == 1 + assert auth_info[0] == DockerAuthInfo( + registry="https://index.docker.io/v1/", + username="username", + password="password", + ) + + +def test_parse_docker_auth_config_cred_helpers(): + auth_dict = {"credHelpers": {".dkr.ecr..amazonaws.com": "ecr-login"}} + auth_config_json = json.dumps(auth_dict) + assert parse_docker_auth_config(auth_config_json) is None + + +def test_parse_docker_auth_config_store(): + auth_dict = {"credsStore": "ecr-login"} + auth_config_json = json.dumps(auth_dict) + assert parse_docker_auth_config(auth_config_json) is None + + +def test_parse_docker_auth_config_encoded_multiple(): + auth_dict = { + "auths": { + "localhost:5000": {"auth": "dXNlcjE6cGFzczE=="}, + "https://example.com": {"auth": "dXNlcl9uZXc6cGFzc19uZXc=="}, + "example2.com": {"auth": "YWJjOjEyMw==="}, + } + } + auth_config_json = json.dumps(auth_dict) + auth_info = parse_docker_auth_config(auth_config_json) + assert len(auth_info) == 3 + assert auth_info[0] == DockerAuthInfo( + registry="localhost:5000", + username="user1", + password="pass1", + ) + assert auth_info[1] == DockerAuthInfo( + registry="https://example.com", + username="user_new", + password="pass_new", + ) + assert auth_info[2] == DockerAuthInfo( + registry="example2.com", + username="abc", + password="123", + ) + + +def test_parse_docker_auth_config_unknown(): + auth_config_str = '{"key": "value"}' + assert parse_docker_auth_config(auth_config_str) is None + + +def test_parse_docker_auth_config_error(): + auth_config_str = "bad//string" + with pytest.raises(ValueError): + parse_docker_auth_config(auth_config_str) + + +def test_parse_docker_auth_all(): + test_dict = { + "auths": { + "localhost:5000": {"auth": "dXNlcjE6cGFzczE=="}, + }, + "credHelpers": {".dkr.ecr..amazonaws.com": "ecr-login"}, + "credsStore": "ecr-login", + } + auth_config_json = json.dumps(test_dict) + assert parse_docker_auth_config(auth_config_json) == [ + DockerAuthInfo( + registry="localhost:5000", + username="user1", + password="pass1", + ) + ] diff --git a/core/tests/test_compose.py b/core/tests/test_compose.py new file mode 100644 index 000000000..b43da28c5 --- /dev/null +++ b/core/tests/test_compose.py @@ -0,0 +1,354 @@ +import subprocess +from pathlib import Path +from re import split +from time import sleep +from typing import Union +from urllib.request import urlopen, Request + +import pytest +from pytest_mock import MockerFixture + +from testcontainers.compose import DockerCompose, ContainerIsNotRunning, NoSuchPortExposed + +FIXTURES = Path(__file__).parent.joinpath("compose_fixtures") + + +def test_compose_no_file_name(): + basic = DockerCompose(context=FIXTURES / "basic") + assert basic.compose_file_name is None + + +def test_compose_str_file_name(): + basic = DockerCompose(context=FIXTURES / "basic", compose_file_name="docker-compose.yaml") + assert basic.compose_file_name == ["docker-compose.yaml"] + + +def test_compose_list_file_name(): + basic = DockerCompose(context=FIXTURES / "basic", compose_file_name=["docker-compose.yaml"]) + assert basic.compose_file_name == ["docker-compose.yaml"] + + +def test_compose_stop(): + basic = DockerCompose(context=FIXTURES / "basic") + basic.stop() + + +def test_compose_start_stop(): + basic = DockerCompose(context=FIXTURES / "basic") + basic.start() + basic.stop() + + +def test_start_stop_multiple(): + """Start and stop multiple containers individually.""" + + # Create two DockerCompose instances from the same file, one service each. + dc_a = DockerCompose(context=FIXTURES / "basic_multiple", services=["alpine1"]) + dc_b = DockerCompose(context=FIXTURES / "basic_multiple", services=["alpine2"]) + + # After starting the first instance, alpine1 should be running + dc_a.start() + dc_a.get_container("alpine1") # Raises if it isn't running + dc_b.get_container("alpine1") # Raises if it isn't running + + # Both instances report the same number of containers + assert len(dc_a.get_containers()) == 1 + assert len(dc_b.get_containers()) == 1 + + # Although alpine1 is running, alpine2 has not started yet. + with pytest.raises(ContainerIsNotRunning): + dc_a.get_container("alpine2") + with pytest.raises(ContainerIsNotRunning): + dc_b.get_container("alpine2") + + # After starting the second instance, alpine2 should also be running + dc_b.start() + dc_a.get_container("alpine2") # No longer raises + dc_b.get_container("alpine2") # No longer raises + assert len(dc_a.get_containers()) == 2 + assert len(dc_b.get_containers()) == 2 + + # After stopping the first instance, alpine1 should no longer be running + dc_a.stop() + dc_a.get_container("alpine2") + dc_b.get_container("alpine2") + assert len(dc_a.get_containers()) == 1 + assert len(dc_b.get_containers()) == 1 + + # alpine1 no longer running + with pytest.raises(ContainerIsNotRunning): + dc_a.get_container("alpine1") + with pytest.raises(ContainerIsNotRunning): + dc_b.get_container("alpine1") + + # Stop the second instance + dc_b.stop() + + assert len(dc_a.get_containers()) == 0 + assert len(dc_b.get_containers()) == 0 + + +def test_compose(): + """stream-of-consciousness e2e test""" + basic = DockerCompose(context=FIXTURES / "basic") + try: + # first it does not exist + containers = basic.get_containers(include_all=True) + assert len(containers) == 0 + + # then we create it and it exists + basic.start() + containers = basic.get_containers(include_all=True) + assert len(containers) == 1 + containers = basic.get_containers() + assert len(containers) == 1 + + # test that get_container returns the same object, value assertions, etc + from_all = containers[0] + assert from_all.State == "running" + assert from_all.Service == "alpine" + + by_name = basic.get_container("alpine") + + assert by_name.Name == from_all.Name + assert by_name.Service == from_all.Service + assert by_name.State == from_all.State + assert by_name.ID == from_all.ID + + assert by_name.ExitCode == 0 + + # what if you want to get logs after it crashes: + basic.stop(down=False) + + with pytest.raises(ContainerIsNotRunning): + assert basic.get_container("alpine") is None + + # what it looks like after it exits + stopped = basic.get_container("alpine", include_all=True) + assert stopped.State == "exited" + finally: + basic.stop() + + +def test_compose_logs(): + basic = DockerCompose(context=FIXTURES / "basic") + with basic: + sleep(1) # generate some logs every 200ms + stdout, stderr = basic.get_logs() + container = basic.get_container() + + assert not stderr + assert stdout + lines = split(r"\r?\n", stdout) + + assert len(lines) > 5 # actually 10 + for line in lines[1:]: + # either the line is blank or the first column (|-separated) contains the service name + # this is a safe way to split the string + # docker changes the prefix between versions 24 and 25 + assert not line or container.Service in next(iter(line.split("|")), None) + + +def test_compose_volumes(): + _file_in_volume = "/var/lib/example/data/hello" + volumes = DockerCompose(context=FIXTURES / "basic_volume", keep_volumes=True) + with volumes: + stdout, stderr, exitcode = volumes.exec_in_container( + ["/bin/sh", "-c", f"echo hello > {_file_in_volume}"], "alpine" + ) + assert exitcode == 0 + + # execute another time to confirm the file is still there, but we're not keeping the volumes this time + volumes.keep_volumes = False + with volumes: + stdout, stderr, exitcode = volumes.exec_in_container(["cat", _file_in_volume], "alpine") + assert exitcode == 0 + assert "hello" in stdout + + # third time we expect the file to be missing + with volumes, pytest.raises(subprocess.CalledProcessError): + volumes.exec_in_container(["cat", _file_in_volume], "alpine") + + +# noinspection HttpUrlsUsage +def test_compose_ports(): + # fairly straight forward - can we get the right port to request it + single = DockerCompose(context=FIXTURES / "port_single") + with single: + host, port = single.get_service_host_and_port() + endpoint = f"http://{host}:{port}" + single.wait_for(endpoint) + code, response = fetch(Request(method="GET", url=endpoint)) + assert code == 200 + assert "

" in response + + +# noinspection HttpUrlsUsage +def test_compose_multiple_containers_and_ports(): + """test for the logic encapsulated in 'one' function + + assert correctness of multiple logic + """ + multiple = DockerCompose(context=FIXTURES / "port_multiple") + with multiple: + with pytest.raises(ContainerIsNotRunning) as e: + multiple.get_container() + e.match("get_container failed") + e.match("not exactly 1 container") + + assert multiple.get_container("alpine") + assert multiple.get_container("alpine2") + + a2p = multiple.get_service_port("alpine2") + assert a2p > 0 # > 1024 + + with pytest.raises(NoSuchPortExposed) as e: + multiple.get_service_port("alpine") + e.match("not exactly 1") + with pytest.raises(NoSuchPortExposed) as e: + multiple.get_container("alpine").get_publisher(by_host="example.com") + e.match("not exactly 1") + with pytest.raises(NoSuchPortExposed) as e: + multiple.get_container("alpine").get_publisher(by_host="localhost") + e.match("not exactly 1") + + try: + # this fails when ipv6 is enabled and docker is forwarding for both 4 + 6 + multiple.get_container(service_name="alpine").get_publisher(by_port=81, prefer_ip_version="IPv6") + except: # noqa + pass + + ports = [ + ( + 80, + multiple.get_service_host(service_name="alpine", port=80), + multiple.get_service_port(service_name="alpine", port=80), + ), + ( + 81, + multiple.get_service_host(service_name="alpine", port=81), + multiple.get_service_port(service_name="alpine", port=81), + ), + ( + 82, + multiple.get_service_host(service_name="alpine", port=82), + multiple.get_service_port(service_name="alpine", port=82), + ), + ] + + # test correctness of port lookup + for target, host, mapped in ports: + assert mapped, f"we have a mapped port for target port {target}" + url = f"http://{host}:{mapped}" + code, body = fetch(Request(method="GET", url=url)) + + expected_code = { + 80: 200, + 81: 202, + 82: 204, + }.get(code, None) + + if not expected_code: + continue + + message = f"response '{body}' ({code}) from url {url} should have code {expected_code}" + assert code == expected_code, message + + +# noinspection HttpUrlsUsage +def test_exec_in_container(): + """we test that we can manipulate a container via exec""" + single = DockerCompose(context=FIXTURES / "port_single") + with single: + url = f"http://{single.get_service_host()}:{single.get_service_port()}" + single.wait_for(url) + + # unchanged + code, body = fetch(url) + assert code == 200 + assert "test_exec_in_container" not in body + + # change it + single.exec_in_container( + command=["sh", "-c", 'echo "test_exec_in_container" > /usr/share/nginx/html/index.html'] + ) + + # and it is changed + code, body = fetch(url) + assert code == 200 + assert "test_exec_in_container" in body + + +# noinspection HttpUrlsUsage +def test_exec_in_container_multiple(): + """same as above, except we exec into a particular service""" + multiple = DockerCompose(context=FIXTURES / "port_multiple") + with multiple: + sn = "alpine2" # service name + host, port = multiple.get_service_host_and_port(service_name=sn) + url = f"http://{host}:{port}" + multiple.wait_for(url) + + # unchanged + code, body = fetch(url) + assert code == 200 + assert "test_exec_in_container" not in body + + # change it + multiple.exec_in_container( + command=["sh", "-c", 'echo "test_exec_in_container" > /usr/share/nginx/html/index.html'], service_name=sn + ) + + # and it is changed + code, body = fetch(url) + assert code == 200 + assert "test_exec_in_container" in body + + +CONTEXT_FIXTURES = [pytest.param(ctx, id=ctx.name) for ctx in FIXTURES.iterdir()] + + +@pytest.mark.parametrize("context", CONTEXT_FIXTURES) +def test_compose_config(context: Path, mocker: MockerFixture) -> None: + compose = DockerCompose(context) + run_command = mocker.spy(compose, "_run_command") + expected_cmd = [*compose.compose_command_property, "config", "--format", "json"] + + received_config = compose.get_config() + + assert received_config + assert isinstance(received_config, dict) + assert "services" in received_config + assert run_command.call_args.kwargs["cmd"] == expected_cmd + + +@pytest.mark.parametrize("context", CONTEXT_FIXTURES) +def test_compose_config_raw(context: Path, mocker: MockerFixture) -> None: + compose = DockerCompose(context) + run_command = mocker.spy(compose, "_run_command") + expected_cmd = [ + *compose.compose_command_property, + "config", + "--format", + "json", + "--no-path-resolution", + "--no-normalize", + "--no-interpolate", + ] + + received_config = compose.get_config(path_resolution=False, normalize=False, interpolate=False) + + assert received_config + assert isinstance(received_config, dict) + assert "services" in received_config + assert run_command.call_args.kwargs["cmd"] == expected_cmd + + +def fetch(req: Union[Request, str]): + if isinstance(req, str): + req = Request(method="GET", url=req) + with urlopen(req) as res: + body = res.read().decode("utf-8") + if 200 < res.getcode() >= 400: + raise Exception(f"HTTP Error: {res.getcode()} - {res.reason}: {body}") + return res.getcode(), body diff --git a/core/tests/test_core.py b/core/tests/test_core.py new file mode 100644 index 000000000..8d0c77944 --- /dev/null +++ b/core/tests/test_core.py @@ -0,0 +1,94 @@ +import pytest +import tempfile +import random +import os + +from pathlib import Path +from typing import Optional + +from testcontainers.core.container import DockerContainer +from testcontainers.core.image import DockerImage +from testcontainers.core.waiting_utils import wait_for_logs + + +def test_timeout_is_raised_when_waiting_for_logs(): + with pytest.raises(TimeoutError), DockerContainer("alpine").with_command("sleep 2") as container: + wait_for_logs(container, "Hello from Docker!", timeout=1e-3) + + +def test_garbage_collection_is_defensive(): + # For more info, see https://github.com/testcontainers/testcontainers-python/issues/399 + # we simulate garbage collection: start, stop, then call `del` + container = DockerContainer("postgres:latest") + container.start() + container.stop(force=True, delete_volume=True) + delattr(container, "_container") + del container + + +def test_wait_for_hello(): + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + + +def test_can_get_logs(): + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + stdout, stderr = container.get_logs() + assert isinstance(stdout, bytes) + assert isinstance(stderr, bytes) + assert stdout, "There should be something on stdout" + + +@pytest.mark.parametrize("test_cleanup", [True, False]) +@pytest.mark.parametrize("test_image_tag", [None, "test-image:latest"]) +def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_for_image): + with tempfile.TemporaryDirectory() as temp_directory: + # It's important to use a random string to avoid image caching + random_string = "Hello from Docker Image! " + str(random.randint(0, 1000)) + with open(f"{temp_directory}/Dockerfile", "w") as f: + f.write( + f""" + FROM alpine:latest + CMD echo "{random_string}" + """ + ) + with DockerImage(path=temp_directory, tag=test_image_tag, clean_up=test_cleanup) as image: + image_short_id = image.short_id + assert image.tag is test_image_tag, f"Expected {test_image_tag}, got {image.tag}" + assert image.short_id is not None, "Short ID should not be None" + logs = image.get_logs() + assert isinstance(logs, list), "Logs should be a list" + assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} + assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'} + with DockerContainer(str(image)) as container: + assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + assert container.get_logs() == ((random_string + "\n").encode(), b""), "Container logs mismatch" + + check_for_image(image_short_id, test_cleanup) + + +@pytest.mark.parametrize("dockerfile_path", [None, Path("subdir/my.Dockerfile")]) +def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path]): + with tempfile.TemporaryDirectory() as temp_directory: + temp_dir_path = Path(temp_directory) + if dockerfile_path: + os.makedirs(temp_dir_path / dockerfile_path.parent, exist_ok=True) + dockerfile_rel_path = dockerfile_path + dockerfile_kwargs = {"dockerfile_path": dockerfile_path} + else: + dockerfile_rel_path = Path("Dockerfile") # default + dockerfile_kwargs = {} + + with open(temp_dir_path / dockerfile_rel_path, "x") as f: + f.write( + f""" + FROM alpine:latest + CMD echo "Hello world!" + """ + ) + with DockerImage(path=temp_directory, tag="test", clean_up=True, no_cache=True, **dockerfile_kwargs) as image: + image_short_id = image.short_id + with DockerContainer(str(image)) as container: + assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + assert container.get_logs() == (("Hello world!\n").encode(), b""), "Container logs mismatch" diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py new file mode 100644 index 000000000..6bfe388de --- /dev/null +++ b/core/tests/test_docker_client.py @@ -0,0 +1,118 @@ +import os +import json +from collections import namedtuple +from unittest import mock +from unittest.mock import MagicMock, patch + +import docker + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.auth import parse_docker_auth_config +from testcontainers.core.image import DockerImage + +from pytest import mark + + +def test_docker_client_from_env(): + test_kwargs = {"test_kw": "test_value"} + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerClient(**test_kwargs) + + mock_docker.from_env.assert_called_with(**test_kwargs) + + +def test_docker_client_login_no_login(): + with patch.dict(os.environ, {}, clear=True): + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerClient() + + mock_docker.from_env.return_value.login.assert_not_called() + + +def test_docker_client_login(): + mock_docker = MagicMock(spec=docker) + mock_parse_docker_auth_config = MagicMock(spec=parse_docker_auth_config) + mock_utils = MagicMock() + mock_utils.parse_docker_auth_config = mock_parse_docker_auth_config + TestAuth = namedtuple("Auth", "value") + mock_parse_docker_auth_config.return_value = [TestAuth("test")] + + with ( + mock.patch.object(c, "_docker_auth_config", "test"), + patch("testcontainers.core.docker_client.docker", mock_docker), + patch("testcontainers.core.docker_client.parse_docker_auth_config", mock_parse_docker_auth_config), + ): + DockerClient() + + mock_docker.from_env.return_value.login.assert_called_with(**{"value": "test"}) + + +def test_docker_client_login_empty_get_docker_auth_config(): + mock_docker = MagicMock(spec=docker) + mock_get_docker_auth_config = MagicMock() + mock_get_docker_auth_config.return_value = None + + with ( + mock.patch.object(c, "_docker_auth_config", "test"), + patch("testcontainers.core.docker_client.docker", mock_docker), + patch("testcontainers.core.docker_client.get_docker_auth_config", mock_get_docker_auth_config), + ): + DockerClient() + + mock_docker.from_env.return_value.login.assert_not_called() + + +def test_docker_client_login_empty_parse_docker_auth_config(): + mock_docker = MagicMock(spec=docker) + mock_parse_docker_auth_config = MagicMock(spec=parse_docker_auth_config) + mock_utils = MagicMock() + mock_utils.parse_docker_auth_config = mock_parse_docker_auth_config + mock_parse_docker_auth_config.return_value = None + + with ( + mock.patch.object(c, "_docker_auth_config", "test"), + patch("testcontainers.core.docker_client.docker", mock_docker), + patch("testcontainers.core.docker_client.parse_docker_auth_config", mock_parse_docker_auth_config), + ): + DockerClient() + + mock_docker.from_env.return_value.login.assert_not_called() + + +# This is used to make sure we don't fail (nor try to login) when we have unsupported auth config +@mark.parametrize("auth_config_sample", [{"credHelpers": {"test": "login"}}, {"credsStore": "login"}]) +def test_docker_client_login_unsupported_auth_config(auth_config_sample): + mock_docker = MagicMock(spec=docker) + mock_get_docker_auth_config = MagicMock() + mock_get_docker_auth_config.return_value = json.dumps(auth_config_sample) + + with ( + mock.patch.object(c, "_docker_auth_config", "test"), + patch("testcontainers.core.docker_client.docker", mock_docker), + patch("testcontainers.core.docker_client.get_docker_auth_config", mock_get_docker_auth_config), + ): + DockerClient() + + mock_docker.from_env.return_value.login.assert_not_called() + + +def test_container_docker_client_kw(): + test_kwargs = {"test_kw": "test_value"} + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerContainer(image="", docker_client_kw=test_kwargs) + + mock_docker.from_env.assert_called_with(**test_kwargs) + + +def test_image_docker_client_kw(): + test_kwargs = {"test_kw": "test_value"} + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerImage(name="", path="", docker_client_kw=test_kwargs) + + mock_docker.from_env.assert_called_with(**test_kwargs) diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py new file mode 100644 index 000000000..6a424884b --- /dev/null +++ b/core/tests/test_docker_in_docker.py @@ -0,0 +1,87 @@ +import time +import socket +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.waiting_utils import wait_for_logs + + +def _wait_for_dind_return_ip(client, dind): + # get ip address for DOCKER_HOST + # avoiding DockerContainer class here to prevent code changes affecting the test + docker_host_ip = client.bridge_ip(dind.id) + # Wait for startup + timeout = 10 + start_wait = time.perf_counter() + while True: + try: + with socket.create_connection((docker_host_ip, 2375), timeout=timeout): + break + except ConnectionRefusedError: + if time.perf_counter() - start_wait > timeout: + raise RuntimeError("Docker in docker took longer than 10 seconds to start") + time.sleep(0.01) + return docker_host_ip + + +def test_wait_for_logs_docker_in_docker(): + # real dind isn't possible (AFAIK) in CI + # forwarding the socket to a container port is at least somewhat the same + client = DockerClient() + not_really_dind = client.run( + image="alpine/socat", + command="tcp-listen:2375,fork,reuseaddr unix-connect:/var/run/docker.sock", + volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock"}}, + detach=True, + ) + + not_really_dind.start() + docker_host_ip = _wait_for_dind_return_ip(client, not_really_dind) + docker_host = f"tcp://{docker_host_ip}:2375" + + with DockerContainer( + image="hello-world", + docker_client_kw={"environment": {"DOCKER_HOST": docker_host, "DOCKER_CERT_PATH": "", "DOCKER_TLS_VERIFY": ""}}, + ) as container: + assert container.get_container_host_ip() == docker_host_ip + wait_for_logs(container, "Hello from Docker!") + stdout, stderr = container.get_logs() + assert stdout, "There should be something on stdout" + + not_really_dind.stop() + not_really_dind.remove() + + +def test_dind_inherits_network(): + client = DockerClient() + try: + custom_network = client.client.networks.create("custom_network", driver="bridge", check_duplicate=True) + except Exception: + custom_network = client.client.networks.list(names=["custom_network"])[0] + not_really_dind = client.run( + image="alpine/socat", + command="tcp-listen:2375,fork,reuseaddr unix-connect:/var/run/docker.sock", + volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock"}}, + detach=True, + ) + + not_really_dind.start() + + docker_host_ip = _wait_for_dind_return_ip(client, not_really_dind) + docker_host = f"tcp://{docker_host_ip}:2375" + + with DockerContainer( + image="hello-world", + docker_client_kw={"environment": {"DOCKER_HOST": docker_host, "DOCKER_CERT_PATH": "", "DOCKER_TLS_VERIFY": ""}}, + ) as container: + assert container.get_container_host_ip() == docker_host_ip + # Check the gateways are the same, so they can talk to each other + assert container.get_docker_client().gateway_ip(container.get_wrapped_container().id) == client.gateway_ip( + not_really_dind.id + ) + wait_for_logs(container, "Hello from Docker!") + stdout, stderr = container.get_logs() + assert stdout, "There should be something on stdout" + + not_really_dind.stop() + not_really_dind.remove() + custom_network.remove() diff --git a/core/tests/test_labels.py b/core/tests/test_labels.py new file mode 100644 index 000000000..425aee7dd --- /dev/null +++ b/core/tests/test_labels.py @@ -0,0 +1,58 @@ +from testcontainers.core.labels import ( + LABEL_LANG, + LABEL_SESSION_ID, + LABEL_TESTCONTAINERS, + LABEL_VERSION, + create_labels, + TESTCONTAINERS_NAMESPACE, +) +import pytest +from testcontainers.core.config import RYUK_IMAGE + + +def assert_in_with_value(labels: dict, label: str, value: str, known_before_test_time: bool) -> None: + assert label in labels + if known_before_test_time: + assert labels[label] == value + + +testdata = [ + (LABEL_LANG, "python", True), + (LABEL_TESTCONTAINERS, "true", True), + (LABEL_SESSION_ID, "some", False), + (LABEL_VERSION, "some", False), +] + + +@pytest.mark.parametrize("label,value,known_before_test_time", testdata) +def test_containers_creates_expected_labels(label, value, known_before_test_time): + actual_labels = create_labels("not-ryuk", None) + assert_in_with_value(actual_labels, label, value, known_before_test_time) + + +def test_containers_throws_on_namespace_collision(): + with pytest.raises(ValueError): + create_labels("not-ryuk", {TESTCONTAINERS_NAMESPACE: "fake"}) + + +def test_containers_respect_custom_labels_if_no_collision(): + custom_namespace = "org.foo.bar" + value = "fake" + actual_labels = create_labels("not-ryuk", {custom_namespace: value}) + assert_in_with_value(actual_labels, custom_namespace, value, True) + + +def test_if_ryuk_no_session(): + actual_labels = create_labels(RYUK_IMAGE, None) + assert LABEL_SESSION_ID not in actual_labels + + +def test_session_are_module_import_scoped(): + """ + Asserts that sessions are a module-level variable and don't differ between invocation + """ + first_labels = create_labels("not-ryuk", None) + second_labels = create_labels("not-ryuk", None) + assert LABEL_SESSION_ID in first_labels + assert LABEL_SESSION_ID in second_labels + assert first_labels[LABEL_SESSION_ID] == second_labels[LABEL_SESSION_ID] diff --git a/core/tests/test_network.py b/core/tests/test_network.py new file mode 100644 index 000000000..868032e26 --- /dev/null +++ b/core/tests/test_network.py @@ -0,0 +1,95 @@ +from http import HTTPStatus +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.labels import LABEL_SESSION_ID +from testcontainers.core.network import Network + +import docker.errors +import pytest + +NGINX_ALPINE_SLIM_IMAGE = "nginx:1.25.4-alpine-slim" + + +def test_network_gets_created_and_cleaned_up(): + with Network() as network: + docker = DockerClient() + networks_list = docker.client.networks.list(network.name) + assert networks_list[0].name == network.name + assert networks_list[0].id == network.id + assert not docker.client.networks.list(network.name) + + +def test_network_create_wo_cm(): + network = Network() + network.create() + docker = DockerClient() + networks_list = docker.client.networks.list(network.name) + assert networks_list[0].name == network.name + assert networks_list[0].id == network.id + + network.remove() + assert not docker.client.networks.list(network.name) + + +def test_network_create_errors(): + network = Network() + network.create() + + # calling create the second time should raise an error + with pytest.raises(docker.errors.APIError) as excinfo: + network.create() + + assert excinfo.value.response.status_code == HTTPStatus.CONFLICT + excinfo.match(f"network with name {network.name} already exists") + network.remove() + + +def test_containers_can_communicate_over_network(): + with ( + Network() as network, + DockerContainer(NGINX_ALPINE_SLIM_IMAGE) + .with_name("alpine1") + .with_network_aliases("alpine1-alias-1", "alpine1-alias-2") + .with_network(network) as alpine1, + DockerContainer(NGINX_ALPINE_SLIM_IMAGE) + .with_name("alpine2") + .with_network_aliases("alpine2-alias-1", "alpine2-alias-2") + .with_network(network) as alpine2, + ): + assert_can_ping(alpine1, "alpine2") + assert_can_ping(alpine1, "alpine2-alias-1") + assert_can_ping(alpine1, "alpine2-alias-2") + + assert_can_ping(alpine2, "alpine1") + assert_can_ping(alpine2, "alpine1-alias-1") + assert_can_ping(alpine2, "alpine1-alias-2") + + assert_can_request(alpine1, "alpine2") + assert_can_request(alpine1, "alpine2-alias-1") + assert_can_request(alpine1, "alpine2-alias-2") + + assert_can_request(alpine2, "alpine1") + assert_can_request(alpine2, "alpine1-alias-1") + assert_can_request(alpine2, "alpine1-alias-2") + + +def assert_can_ping(container: DockerContainer, remote_name: str): + status, output = container.exec("ping -c 1 %s" % remote_name) + assert status == 0 + assert "64 bytes" in str(output) + + +def assert_can_request(container: DockerContainer, remote_name: str): + status, output = container.exec(f"wget -qO- http://{remote_name}") + assert status == 0 + assert "Welcome to nginx!" in output.decode() + + +def test_network_has_labels(): + network = Network() + try: + network.create() + network = network._docker.client.networks.get(network_id=network.id) + assert LABEL_SESSION_ID in network.attrs.get("Labels") + finally: + network.remove() diff --git a/core/tests/test_new_docker_api.py b/core/tests/test_new_docker_api.py new file mode 100644 index 000000000..936efc82b --- /dev/null +++ b/core/tests/test_new_docker_api.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from testcontainers.core.container import DockerContainer + + +def test_docker_custom_image(): + container = DockerContainer("mysql:5.7.17") + container.with_exposed_ports(3306) + container.with_env("MYSQL_ROOT_PASSWORD", "root") + + with container: + port = container.get_exposed_port(3306) + assert int(port) > 0 + + +def test_docker_kwargs(): + code_dir = Path(__file__).parent + container_first = DockerContainer("nginx:latest") + container_first.with_volume_mapping(code_dir, "/code") + + container_second = DockerContainer("nginx:latest") + + with container_first: + container_second.with_kwargs(volumes_from=[container_first._container.short_id]) + with container_second: + files_first = container_first.exec("ls /code").output.decode("utf-8").strip() + files_second = container_second.exec("ls /code").output.decode("utf-8").strip() + assert files_first == files_second diff --git a/core/tests/test_registry.py b/core/tests/test_registry.py new file mode 100644 index 000000000..384b06693 --- /dev/null +++ b/core/tests/test_registry.py @@ -0,0 +1,83 @@ +"""Integration test using login to a private registry. + +Note: Using the testcontainers-python library to test the Docker registry. +This could be considered a bad practice as it is not recommended to use the same library to test itself. +However, it is a very good use case for DockerRegistryContainer and allows us to test it thoroughly. +""" + +import json +import os +import base64 +import pytest + +from docker.errors import NotFound + +from testcontainers.core.config import testcontainers_config +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.waiting_utils import wait_container_is_ready + +from testcontainers.registry import DockerRegistryContainer + + +def test_missing_on_private_registry(monkeypatch): + username = "user" + password = "pass" + image = "hello-world" + tag = "test" + + with DockerRegistryContainer(username=username, password=password) as registry: + registry_url = registry.get_registry() + + # prepare auth config + creds: bytes = base64.b64encode(f"{username}:{password}".encode("utf-8")) + config = {"auths": {f"{registry_url}": {"auth": creds.decode("utf-8")}}} + monkeypatch.setattr(testcontainers_config, name="docker_auth_config", value=json.dumps(config)) + assert testcontainers_config.docker_auth_config, "docker_auth_config not set" + + with pytest.raises(NotFound): + # Test a container with image from private registry + with DockerContainer(f"{registry_url}/{image}:{tag}") as test_container: + wait_container_is_ready(test_container) + + +@pytest.mark.parametrize( + "image,tag,username,password", + [ + ("nginx", "test", "user", "pass"), + ("hello-world", "latest", "new_user", "new_pass"), + ("alpine", "3.12", None, None), + ], +) +def test_with_private_registry(image, tag, username, password, monkeypatch): + client = DockerClient().client + + with DockerRegistryContainer(username=username, password=password) as registry: + registry_url = registry.get_registry() + + # prepare image + _image = client.images.pull(image) + assert _image.tag(repository=f"{registry_url}/{image}", tag=tag), "Image not tagged" + + # login to private registry + client.login(registry=registry_url, username=username, password=password) + + # push image to private registry + client.images.push(f"{registry_url}/{image}") + + # clear local image so we will pull from private registry + client.images.remove(f"{registry_url}/{image}:{tag}") + + # prepare auth config + creds: bytes = base64.b64encode(f"{username}:{password}".encode("utf-8")) + config = {"auths": {f"{registry_url}": {"auth": creds.decode("utf-8")}}} + monkeypatch.setattr(testcontainers_config, name="docker_auth_config", value=json.dumps(config)) + assert testcontainers_config.docker_auth_config, "docker_auth_config not set" + + # Test a container with image from private registry + with DockerContainer(f"{registry_url}/{image}:{tag}") as test_container: + wait_container_is_ready(test_container) + + # cleanup + client.images.remove(f"{registry_url}/{image}:{tag}") + client.close() diff --git a/core/tests/test_ryuk.py b/core/tests/test_ryuk.py new file mode 100644 index 000000000..e081d2c07 --- /dev/null +++ b/core/tests/test_ryuk.py @@ -0,0 +1,58 @@ +from time import sleep +import pytest +from pytest import MonkeyPatch + +from docker import DockerClient +from docker.errors import NotFound + +from testcontainers.core.config import testcontainers_config +from testcontainers.core.container import Reaper +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +def test_wait_for_reaper(monkeypatch: MonkeyPatch): + Reaper.delete_instance() + monkeypatch.setattr(testcontainers_config, "ryuk_reconnection_timeout", "0.1s") + docker_client = DockerClient() + container = DockerContainer("hello-world").start() + + container_id = container.get_wrapped_container().short_id + reaper_id = Reaper._container.get_wrapped_container().short_id + + assert docker_client.containers.get(container_id) is not None + assert docker_client.containers.get(reaper_id) is not None + + wait_for_logs(container, "Hello from Docker!") + + Reaper._socket.close() + + sleep(0.6) # Sleep until Ryuk reaps all dangling containers. 0.5 extra seconds for good measure. + + with pytest.raises(NotFound): + docker_client.containers.get(container_id) + with pytest.raises(NotFound): + docker_client.containers.get(reaper_id) + + # Cleanup Ryuk class fields after manual Ryuk shutdown + Reaper.delete_instance() + + +def test_container_without_ryuk(monkeypatch: MonkeyPatch): + Reaper.delete_instance() + monkeypatch.setattr(testcontainers_config, "ryuk_disabled", True) + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + assert Reaper._instance is None + + +def test_ryuk_is_reused_in_same_process(): + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + reaper_instance = Reaper._instance + + assert reaper_instance is not None + + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + assert reaper_instance is Reaper._instance diff --git a/core/tests/test_version.py b/core/tests/test_version.py new file mode 100644 index 000000000..397cd0523 --- /dev/null +++ b/core/tests/test_version.py @@ -0,0 +1,78 @@ +import pytest +from packaging.version import InvalidVersion + +from testcontainers.core.version import ComparableVersion + + +@pytest.fixture +def version(): + return ComparableVersion("1.0.0") + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", False), ("1.1.0", True)]) +def test_lt(version, other_version, expected): + assert (version < other_version) == expected + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", True), ("1.1.0", True)]) +def test_le(version, other_version, expected): + assert (version <= other_version) == expected + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", True), ("1.1.0", False)]) +def test_eq(version, other_version, expected): + assert (version == other_version) == expected + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", False), ("1.1.0", True)]) +def test_ne(version, other_version, expected): + assert (version != other_version) == expected + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", False), ("1.1.0", False)]) +def test_gt(version, other_version, expected): + assert (version > other_version) == expected + + +@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", True), ("1.1.0", False)]) +def test_ge(version, other_version, expected): + assert (version >= other_version) == expected + + +@pytest.mark.parametrize( + "invalid_version", + [ + "invalid", + "1..0", + ], +) +def test_invalid_version_raises_error(invalid_version): + with pytest.raises(InvalidVersion): + ComparableVersion(invalid_version) + + +@pytest.mark.parametrize( + "invalid_version", + [ + "invalid", + "1..0", + ], +) +def test_comparison_with_invalid_version_raises_error(version, invalid_version): + with pytest.raises(InvalidVersion): + assert version < invalid_version + + with pytest.raises(InvalidVersion): + assert version <= invalid_version + + with pytest.raises(InvalidVersion): + assert version == invalid_version + + with pytest.raises(InvalidVersion): + assert version != invalid_version + + with pytest.raises(InvalidVersion): + assert version > invalid_version + + with pytest.raises(InvalidVersion): + assert version >= invalid_version diff --git a/diagnostics.py b/diagnostics.py deleted file mode 100644 index 79d306fba..000000000 --- a/diagnostics.py +++ /dev/null @@ -1,23 +0,0 @@ -import json -from testcontainers.core import utils -from testcontainers.core.container import DockerContainer - - -result = { - 'is_linux': utils.is_linux(), - 'is_mac': utils.is_mac(), - 'is_windows': utils.is_windows(), - 'inside_container': utils.inside_container(), - 'default_gateway_ip': utils.default_gateway_ip(), -} - -with DockerContainer('alpine:latest') as container: - client = container.get_docker_client() - result.update({ - 'container_host_ip': container.get_container_host_ip(), - 'docker_client_gateway_ip': client.gateway_ip(container._container.id), - 'docker_client_bridge_ip': client.bridge_ip(container._container.id), - 'docker_client_host': client.host(), - }) - -print(json.dumps(result, indent=2)) diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 8177779fc..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = testcontainers -SOURCEDIR = . -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) diff --git a/docs/compose.rst b/docs/compose.rst deleted file mode 100644 index 191fb3591..000000000 --- a/docs/compose.rst +++ /dev/null @@ -1,2 +0,0 @@ -.. automodule:: testcontainers.compose - :members: DockerCompose diff --git a/docs/database.rst b/docs/database.rst deleted file mode 100644 index be9843706..000000000 --- a/docs/database.rst +++ /dev/null @@ -1,14 +0,0 @@ -Database containers -=================== - -Allows to spin up database images such as MySQL, PostgreSQL, MariaDB, Oracle XE, MongoDb, ClickHouse or Neo4j. - -.. autoclass:: testcontainers.mysql.MySqlContainer -.. autoclass:: testcontainers.mysql.MariaDbContainer -.. autoclass:: testcontainers.postgres.PostgresContainer -.. autoclass:: testcontainers.oracle.OracleDbContainer -.. autoclass:: testcontainers.elasticsearch.ElasticSearchContainer -.. autoclass:: testcontainers.mongodb.MongoDbContainer -.. autoclass:: testcontainers.mssql.SqlServerContainer -.. autoclass:: testcontainers.clickhouse.ClickHouseContainer -.. autoclass:: testcontainers.neo4j.Neo4jContainer diff --git a/docs/google-cloud-emulators.rst b/docs/google-cloud-emulators.rst deleted file mode 100644 index 15c134558..000000000 --- a/docs/google-cloud-emulators.rst +++ /dev/null @@ -1,2 +0,0 @@ -.. automodule:: testcontainers.google - :members: PubSubContainer diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 7c90a57bc..000000000 --- a/docs/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. python-testcontainers documentation master file, created by - sphinx-quickstart on Mon Aug 22 13:39:46 2016. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -.. include:: ../README.rst - -Usage modes ------------ - -.. toctree:: - :maxdepth: 2 - - Database containers - Selenium containers - Docker Compose - Google Cloud Emulators diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 17111716a..000000000 --- a/docs/make.bat +++ /dev/null @@ -1,36 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build -set SPHINXPROJ=testcontainers - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/docs/selenium.rst b/docs/selenium.rst deleted file mode 100644 index ac368333b..000000000 --- a/docs/selenium.rst +++ /dev/null @@ -1,2 +0,0 @@ -.. automodule:: testcontainers.selenium - :members: BrowserWebDriverContainer diff --git a/doctests/conf.py b/doctests/conf.py new file mode 100644 index 000000000..0822df226 --- /dev/null +++ b/doctests/conf.py @@ -0,0 +1,5 @@ +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", +] +master_doc = "README" diff --git a/generate_version.py b/generate_version.py deleted file mode 100644 index b8e02c082..000000000 --- a/generate_version.py +++ /dev/null @@ -1,12 +0,0 @@ -import os - -# Automatically determine the version to push to pypi -github_ref = os.environ.get('GITHUB_REF', '') -prefix = 'refs/tags/v' -if github_ref.startswith(prefix): - version = github_ref[len(prefix):] - with open('VERSION', 'w') as fp: - fp.write(version) - print('Wrote version %s to VERSION file.' % version) -else: - raise ValueError('Could not identify version in %s.' % github_ref) diff --git a/index.rst b/index.rst new file mode 100644 index 000000000..307f934c0 --- /dev/null +++ b/index.rst @@ -0,0 +1,184 @@ +testcontainers-python +===================== + +.. image:: https://github.com/testcontainers/testcontainers-python/actions/workflows/ci-core.yml/badge.svg + :target: https://github.com/testcontainers/testcontainers-python/actions/workflows/ci-core.yml +.. image:: https://img.shields.io/pypi/v/testcontainers.svg + :target: https://pypi.python.org/pypi/testcontainers +.. image:: https://readthedocs.org/projects/testcontainers-python/badge/?version=latest + :target: http://testcontainers-python.readthedocs.io/en/latest/?badge=latest +.. image:: https://github.com/codespaces/badge.svg + :target: https://codespaces.new/testcontainers/testcontainers-python + +testcontainers-python facilitates the use of Docker containers for functional and integration testing. The collection of packages currently supports the following features. + +.. toctree:: + :maxdepth: 1 + + core/README + modules/index + +Getting Started +--------------- + +.. doctest:: + + >>> from testcontainers.postgres import PostgresContainer + >>> import sqlalchemy + + >>> with PostgresContainer("postgres:16") as postgres: + ... psql_url = postgres.get_connection_url() + ... engine = sqlalchemy.create_engine(psql_url) + ... with engine.begin() as connection: + ... version, = connection.execute(sqlalchemy.text("SELECT version()")).fetchone() + >>> version + 'PostgreSQL 16...' + +The snippet above will spin up the current latest version of a postgres database in a container. The :code:`get_connection_url()` convenience method returns a :code:`sqlalchemy` compatible url (using the :code:`psycopg2` driver per default) to connect to the database and retrieve the database version. + +.. doctest:: + + >>> from testcontainers.postgres import PostgresContainer + >>> import psycopg + + >>> with PostgresContainer("postgres:16", driver=None) as postgres: + ... psql_url = postgres.get_connection_url() + ... with psycopg.connect(psql_url) as connection: + ... with connection.cursor() as cursor: + ... version, = cursor.execute("SELECT version()").fetchone() + >>> version + 'PostgreSQL 16...' + +This snippet does the same, however using a specific version and the driver is set to None, to influence the :code:`get_connection_url()` convenience method to not include a driver in the URL (e.g. for compatibility with :code:`psycopg` v3). + +Note, that the :code:`sqlalchemy` and :code:`psycopg` packages are no longer a dependency of :code:`testcontainers[postgres]` and not needed to launch the Postgres container. Your project therefore needs to declare a dependency on the used driver and db access methods you use in your code. + +By default, Testcontainers will search for the container via the gateway IP. You can manually specify your own IP with the environment variable `TESTCONTAINERS_HOST_OVERRIDE`. + + +Installation +------------ + +The suite of testcontainers packages is available on `PyPI `_, +the package can be installed using :code:`pip`. + +Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsustainable to maintain ownership. + +Instead packages can be installed by specifying `extras `__, e.g., :code:`pip install testcontainers[postgres]`. + +Please note, that community modules are supported on a best-effort basis and breaking changes DO NOT create major versions in the package. +Therefore, only the package core is strictly following SemVer. If your workflow is broken by a minor update, please look at the changelogs for guidance. + + +Custom Containers +----------------- + +Crafting containers that are based on custom images is supported by the `core` module. Please check the `core documentation `_ for more information. + +This allows you to create containers from images that are not part of the modules provided by testcontainers-python. + +For common use cases, you can also use the generic containers provided by the `testcontainers-generic` module. Please check the `generic documentation `_ for more information. +(example: `ServerContainer` for running a FastAPI server) + + +Docker in Docker (DinD) +----------------------- + +When trying to launch Testcontainers from within a Docker container, e.g., in continuous integration testing, two things have to be provided: + +1. The container has to provide a docker client installation. Either use an image that has docker pre-installed (e.g. the `official docker images `_) or install the client from within the `Dockerfile` specification. +2. The container has to have access to the docker daemon which can be achieved by mounting `/var/run/docker.sock` or setting the `DOCKER_HOST` environment variable as part of your `docker run` command. + + +Private Docker registry +----------------------- + +Using a private docker registry requires the `DOCKER_AUTH_CONFIG` environment variable to be set. +`official documentation `_ + +The value of this variable should be a JSON string containing the authentication information for the registry. + +Example: + +.. code-block:: bash + + DOCKER_AUTH_CONFIG='{"auths": {"https://myregistry.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}}}' + +In order to generate the JSON string, you can use the following command: + +.. code-block:: bash + + echo -n '{"auths": {"": {"auth": "'$(echo -n ":" | base64 -w 0)'"}}}' + +Fetching passwords from cloud providers: + +.. code-block:: bash + + ECR_PASSWORD = $(aws ecr get-login-password --region eu-west-1) + GCP_PASSWORD = $(gcloud auth print-access-token) + AZURE_PASSWORD = $(az acr login --name --expose-token --output tsv) + + +Configuration +------------- + ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ +| Env Variable | Example | Description | ++===========================================+===================================================+==========================================+ +| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk | ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ +| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container | ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ +| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk | ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ +| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.8.1`` | Custom image for ryuk | ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ +| ``DOCKER_AUTH_CONFIG`` | ``{"auths": {"": {"auth": ""}}}`` | Custom registry auth config | ++-------------------------------------------+---------------------------------------------------+------------------------------------------+ + +Development and Contributing +---------------------------- + + +We recommend you use a `Poetry `_ for development. +After having installed `poetry`, you can run the following snippet to set up your local dev environment. + +.. code-block:: bash + + make install + +Package Structure +^^^^^^^^^^^^^^^^^ + +Testcontainers is a collection of `implicit namespace packages `__ +to decouple the development of different extensions, +e.g., :code:`testcontainers[mysql]` and :code:`testcontainers[postgres]` for MySQL and PostgreSQL database containers, respectively. + +The folder structure is as follows: + +.. code-block:: bash + + modules + # One folder per feature. + [feature name] + # Folder without __init__.py for implicit namespace packages. + testcontainers + # Implementation as namespace package with __init__.py. + [feature name] + __init__.py + # Other files for this + ... + # Tests for the feature. + tests + test_[some_aspect_for_the_feature].py + ... + # README for this feature. + README.rst + +Contributing a New Feature +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You want to contribute a new feature or container? Great! +- We recommend you first `open an issue `_ +- Then follow the suggestions from the team +- We also have a Pull Request `template `_ for new containers! diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 000000000..012c3f552 --- /dev/null +++ b/modules/README.md @@ -0,0 +1,3 @@ +# Modules + +The modules directory contains all the community-supported containers that see common use cases and merit their own easy-access container. diff --git a/modules/arangodb/README.rst b/modules/arangodb/README.rst new file mode 100644 index 000000000..d1a6cd255 --- /dev/null +++ b/modules/arangodb/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.arangodb.ArangoDbContainer +.. title:: testcontainers.arangodb.ArangoDbContainer diff --git a/modules/arangodb/testcontainers/arangodb/__init__.py b/modules/arangodb/testcontainers/arangodb/__init__.py new file mode 100644 index 000000000..9ea36f6ea --- /dev/null +++ b/modules/arangodb/testcontainers/arangodb/__init__.py @@ -0,0 +1,93 @@ +""" +ArangoDB container support. +""" + +import typing +from os import environ + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_for_logs + + +class ArangoDbContainer(DbContainer): + """ + ArangoDB container. + + Example: + + This example spins up an ArangoDB container. You may use the :code:`get_connection_url()` + method which returns a arangoclient-compatible url in format :code:`scheme://host:port`. As + of now, only a single host is supported (over HTTP). + + .. doctest:: + + >>> from testcontainers.arangodb import ArangoDbContainer + >>> from arango import ArangoClient + + >>> with ArangoDbContainer("arangodb:3.11.8") as arango: + ... client = ArangoClient(hosts=arango.get_connection_url()) + ... + ... # Connect + ... sys_db = client.db(username="root", password="passwd") + ... + ... # Create a new database named "test". + ... sys_db.create_database("test") + True + """ + + def __init__( + self, + image: str = "arangodb:latest", + port: int = 8529, + arango_root_password: str = "passwd", + arango_no_auth: typing.Optional[bool] = None, + arango_random_root_password: typing.Optional[bool] = None, + **kwargs, + ) -> None: + """ + Args: + image: Actual docker image/tag to pull. + port: Port the container needs to expose. + arango_root_password: Start ArangoDB with the given password for root. Defaults to the + environment variable `ARANGO_ROOT_PASSWORD` if `None`. + arango_no_auth: Disable authentication completely. Defaults to the environment variable + `ARANGO_NO_AUTH` if `None` or `False` if the environment variable is not available. + arango_random_root_password: Let ArangoDB generate a random root password. Defaults to + the environment variable `ARANGO_NO_AUTH` if `None` or `False` if the environment + variable is not available. + """ + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image=image, **kwargs) + self.port = port + self.with_exposed_ports(self.port) + + # See https://www.arangodb.com/docs/stable/deployment-single-instance-manual-start.html for + # details. We convert to int then to bool because Arango uses the string literal "1" to + # indicate flags. + self.arango_no_auth = bool(int(environ.get("ARANGO_NO_AUTH", 0) if arango_no_auth is None else arango_no_auth)) + self.arango_root_password = ( + environ.get("ARANGO_ROOT_PASSWORD") if arango_root_password is None else arango_root_password + ) + self.arango_random_root_password = bool( + int( + environ.get("ARANGO_RANDOM_ROOT_PASSWORD", 0) + if arango_random_root_password is None + else arango_random_root_password + ) + ) + + def _configure(self) -> None: + self.with_env("ARANGO_ROOT_PASSWORD", self.arango_root_password) + if self.arango_no_auth: + self.with_env("ARANGO_NO_AUTH", "1") + if self.arango_random_root_password: + self.with_env("ARANGO_RANDOM_ROOT_PASSWORD", "1") + + def get_connection_url(self) -> str: + port = self.get_exposed_port(self.port) + return f"http://{self.get_container_host_ip()}:{port}" + + def _connect(self) -> None: + wait_for_logs(self, predicate="is ready for business", timeout=c.timeout) diff --git a/modules/arangodb/tests/test_arangodb.py b/modules/arangodb/tests/test_arangodb.py new file mode 100644 index 000000000..01e4643a7 --- /dev/null +++ b/modules/arangodb/tests/test_arangodb.py @@ -0,0 +1,113 @@ +""" +ArangoDB Container Tests +""" + +import pytest +from arango import ArangoClient +from arango.exceptions import DatabaseCreateError, ServerVersionError + +from testcontainers.arangodb import ArangoDbContainer +import platform + +ARANGODB_IMAGE_NAME = "arangodb" +IMAGE_VERSION = "3.11.8" + + +def arango_test_ops(arango_client, expected_version, username="root", password=""): + """ + Basic ArangoDB operations to test DB really up and running. + """ + students_to_insert_cnt = 3 + + # Taken from https://github.com/ArangoDB-Community/python-arango/blob/main/README.md + # Connect to "_system" database as root user. + sys_db = arango_client.db("_system", username=username, password=password) + assert sys_db.version() == expected_version + + # Create a new database named "test". + sys_db.create_database("test") + + # Connect to "test" database as root user. + database = arango_client.db("test", username=username, password=password) + + # Create a new collection named "students". + students = database.create_collection("students") + + # Add a hash index to the collection. + students.add_hash_index(fields=["name"], unique=True) + + # Insert new documents into the collection. (students_to_insert_cnt) + students.insert({"name": "jane", "age": 39}) + students.insert({"name": "josh", "age": 18}) + students.insert({"name": "judy", "age": 21}) + + # Execute an AQL query and iterate through the result cursor. + cursor = database.aql.execute("FOR doc IN students RETURN doc") + student_names = [document["name"] for document in cursor] + + assert len(student_names) == students_to_insert_cnt + + +def test_docker_run_arango(): + """ + Test ArangoDB container with default settings. + """ + image = f"{ARANGODB_IMAGE_NAME}:{IMAGE_VERSION}" + arango_root_password = "passwd" + + with ArangoDbContainer(image) as arango: + client = ArangoClient(hosts=arango.get_connection_url()) + + # Test invalid auth + sys_db = client.db("_system", username="root", password="notTheRightPass") + with pytest.raises(DatabaseCreateError): + sys_db.create_database("test") + + arango_test_ops(arango_client=client, expected_version=IMAGE_VERSION, password=arango_root_password) + + +def test_docker_run_arango_without_auth(): + """ + Test ArangoDB container with ARANGO_NO_AUTH var set. + """ + image = f"{ARANGODB_IMAGE_NAME}:{IMAGE_VERSION}" + + with ArangoDbContainer(image, arango_no_auth=True) as arango: + client = ArangoClient(hosts=arango.get_connection_url()) + + arango_test_ops(arango_client=client, expected_version=IMAGE_VERSION, password="") + + +@pytest.mark.skipif(platform.processor() == "arm", reason="Test does not run on machines with ARM CPU") +def test_docker_run_arango_older_version(): + """ + Test ArangoDB container with older tag/version. + the idea behind it hides in the logic of arangodb._connect() -> + Where it waits the container to sign "ready for business" - + If someone will change the logic in the future + we must verify older image tags still supported. (without that logic - we'll face race issues + where we try to create & populate DB when ArangoDB not really ready. + """ + image_version = "3.1.7" + image = f"{ARANGODB_IMAGE_NAME}:{image_version}" + + with ArangoDbContainer(image, arango_no_auth=True) as arango: + client = ArangoClient(hosts=arango.get_connection_url()) + + arango_test_ops(arango_client=client, expected_version=image_version, password="") + + +def test_docker_run_arango_random_root_password(): + """ + Test ArangoDB container with ARANGO_RANDOM_ROOT_PASSWORD var set. + """ + image = f"{ARANGODB_IMAGE_NAME}:{IMAGE_VERSION}" + arango_root_password = "passwd" + + with ArangoDbContainer(image, arango_random_root_password=True) as arango: + client = ArangoClient(hosts=arango.get_connection_url()) + + # Test invalid auth (we don't know the password in random mode) + sys_db = client.db("_system", username="root", password=arango_root_password) + with pytest.raises(ServerVersionError): + assert sys_db.version() == IMAGE_VERSION diff --git a/modules/aws/README.rst b/modules/aws/README.rst new file mode 100644 index 000000000..a44dc856f --- /dev/null +++ b/modules/aws/README.rst @@ -0,0 +1,22 @@ +:code:`testcontainers-aws` is a set of AWS containers modules that can be used to create AWS containers. + +.. autoclass:: testcontainers.aws.AWSLambdaContainer +.. title:: testcontainers.aws.AWSLambdaContainer + +The following environment variables are used by the AWS Lambda container: + ++-------------------------------+--------------------------+------------------------------+ +| Env Variable | Default | Notes | ++===============================+==========================+==============================+ +| ``AWS_DEFAULT_REGION`` | ``us-west-1`` | Fetched from os environment | ++-------------------------------+--------------------------+------------------------------+ +| ``AWS_ACCESS_KEY_ID`` | ``testcontainers-aws`` | Fetched from os environment | ++-------------------------------+--------------------------+------------------------------+ +| ``AWS_SECRET_ACCESS_KEY`` | ``testcontainers-aws`` | Fetched from os environment | ++-------------------------------+--------------------------+------------------------------+ + + Each one of the environment variables is expected to be set in the host machine where the test is running. + +Make sure you are using an image based on :code:`public.ecr.aws/lambda/python` + +Please checkout https://docs.aws.amazon.com/lambda/latest/dg/python-image.html for more information on how to run AWS Lambda functions locally. diff --git a/modules/aws/testcontainers/aws/__init__.py b/modules/aws/testcontainers/aws/__init__.py new file mode 100644 index 000000000..f16705c86 --- /dev/null +++ b/modules/aws/testcontainers/aws/__init__.py @@ -0,0 +1 @@ +from .aws_lambda import AWSLambdaContainer # noqa: F401 diff --git a/modules/aws/testcontainers/aws/aws_lambda.py b/modules/aws/testcontainers/aws/aws_lambda.py new file mode 100644 index 000000000..30a1f0af9 --- /dev/null +++ b/modules/aws/testcontainers/aws/aws_lambda.py @@ -0,0 +1,53 @@ +import os +from typing import Union + +import httpx + +from testcontainers.core.image import DockerImage +from testcontainers.generic.server import ServerContainer + +RIE_PATH = "/2015-03-31/functions/function/invocations" +# AWS OS-only base images contain an Amazon Linux distribution and the runtime interface emulator (RIE) for Lambda. + + +class AWSLambdaContainer(ServerContainer): + """ + AWS Lambda container that is based on a custom image. + + Example: + + .. doctest:: + + >>> from testcontainers.aws import AWSLambdaContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./modules/aws/tests/lambda_sample", tag="test-lambda:latest") as image: + ... with AWSLambdaContainer(image=image, port=8080) as func: + ... response = func.send_request(data={'payload': 'some data'}) + ... assert response.status_code == 200 + ... assert "Hello from AWS Lambda using Python" in response.json() + ... delay = wait_for_logs(func, "START RequestId:") + + :param image: Docker image to be used for the container. + :param port: Port to be exposed on the container (default: 8080). + """ + + def __init__(self, image: Union[str, DockerImage], port: int = 8080) -> None: + super().__init__(port, str(image)) + self.with_env("AWS_DEFAULT_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-west-1")) + self.with_env("AWS_ACCESS_KEY_ID", os.environ.get("AWS_ACCESS_KEY_ID", "testcontainers-aws")) + self.with_env("AWS_SECRET_ACCESS_KEY", os.environ.get("AWS_SECRET_ACCESS_KEY", "testcontainers-aws")) + + def get_api_url(self) -> str: + return self._create_connection_url() + RIE_PATH + + def send_request(self, data: dict) -> httpx.Response: + """ + Send a request to the AWS Lambda function. + + :param data: Data to be sent to the AWS Lambda function. + :return: Response from the AWS Lambda function. + """ + client = self.get_client() + return client.post(self.get_api_url(), json=data) diff --git a/modules/aws/tests/lambda_sample/Dockerfile b/modules/aws/tests/lambda_sample/Dockerfile new file mode 100644 index 000000000..5d071c802 --- /dev/null +++ b/modules/aws/tests/lambda_sample/Dockerfile @@ -0,0 +1,10 @@ +FROM public.ecr.aws/lambda/python:3.9 + +RUN pip install boto3 + +COPY lambda_function.py ${LAMBDA_TASK_ROOT} + +EXPOSE 8080 + +# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) +CMD [ "lambda_function.handler" ] diff --git a/modules/aws/tests/lambda_sample/lambda_function.py b/modules/aws/tests/lambda_sample/lambda_function.py new file mode 100644 index 000000000..b253ed172 --- /dev/null +++ b/modules/aws/tests/lambda_sample/lambda_function.py @@ -0,0 +1,5 @@ +import sys + + +def handler(event, context): + return "Hello from AWS Lambda using Python" + sys.version + "!" diff --git a/modules/aws/tests/test_aws.py b/modules/aws/tests/test_aws.py new file mode 100644 index 000000000..873b87352 --- /dev/null +++ b/modules/aws/tests/test_aws.py @@ -0,0 +1,56 @@ +import re +import os + +import pytest +from unittest.mock import patch + +from testcontainers.core.image import DockerImage +from testcontainers.aws import AWSLambdaContainer +from testcontainers.aws.aws_lambda import RIE_PATH + +DOCKER_FILE_PATH = "./modules/aws/tests/lambda_sample" +IMAGE_TAG = "lambda:test" + + +def test_aws_lambda_container(): + with DockerImage(path=DOCKER_FILE_PATH, tag="test-lambda:latest") as image: + with AWSLambdaContainer(image=image, port=8080) as func: + assert func.get_container_host_ip() == "localhost" + assert func.internal_port == 8080 + assert func.env["AWS_DEFAULT_REGION"] == "us-west-1" + assert func.env["AWS_ACCESS_KEY_ID"] == "testcontainers-aws" + assert func.env["AWS_SECRET_ACCESS_KEY"] == "testcontainers-aws" + assert re.match(rf"http://localhost:\d+{RIE_PATH}", func.get_api_url()) + response = func.send_request(data={"payload": "test"}) + assert response.status_code == 200 + assert "Hello from AWS Lambda using Python" in response.json() + for log_str in ["START RequestId", "END RequestId", "REPORT RequestId"]: + assert log_str in func.get_stdout() + + +def test_aws_lambda_container_external_env_vars(): + vars = { + "AWS_DEFAULT_REGION": "region", + "AWS_ACCESS_KEY_ID": "id", + "AWS_SECRET_ACCESS_KEY": "key", + } + with patch.dict(os.environ, vars): + with DockerImage(path=DOCKER_FILE_PATH, tag="test-lambda-env-vars:latest") as image: + with AWSLambdaContainer(image=image, port=8080) as func: + assert func.env["AWS_DEFAULT_REGION"] == "region" + assert func.env["AWS_ACCESS_KEY_ID"] == "id" + assert func.env["AWS_SECRET_ACCESS_KEY"] == "key" + + +def test_aws_lambda_container_no_port(): + with DockerImage(path=DOCKER_FILE_PATH, tag="test-lambda-no-port:latest") as image: + with AWSLambdaContainer(image=image) as func: + response = func.send_request(data={"payload": "test"}) + assert response.status_code == 200 + + +def test_aws_lambda_container_no_path(): + with pytest.raises(TypeError): + with DockerImage(path=DOCKER_FILE_PATH, tag="test-lambda-no-path:latest") as image: + with AWSLambdaContainer() as func: # noqa: F841 + pass diff --git a/modules/azurite/README.rst b/modules/azurite/README.rst new file mode 100644 index 000000000..b6cf724c9 --- /dev/null +++ b/modules/azurite/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.azurite.AzuriteContainer +.. title:: testcontainers.azurite.AzuriteContainer diff --git a/modules/azurite/testcontainers/azurite/__init__.py b/modules/azurite/testcontainers/azurite/__init__.py new file mode 100644 index 000000000..969fcf35d --- /dev/null +++ b/modules/azurite/testcontainers/azurite/__init__.py @@ -0,0 +1,113 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +import socket +from typing import Optional + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class AzuriteContainer(DockerContainer): + """ + The example below spins up an Azurite container and + shows an example to create a Blob service client with the container. The method + :code:`get_connection_string` can be used to create a client for Blob service, Queue service + and Table service. + + Example: + + .. doctest:: + + >>> from testcontainers.azurite import AzuriteContainer + >>> from azure.storage.blob import BlobServiceClient + + >>> with AzuriteContainer() as azurite_container: + ... connection_string = azurite_container.get_connection_string() + ... client = BlobServiceClient.from_connection_string( + ... connection_string, + ... api_version="2019-12-12" + ... ) + """ + + def __init__( + self, + image: str = "mcr.microsoft.com/azure-storage/azurite:latest", + *, + blob_service_port: int = 10_000, + queue_service_port: int = 10_001, + table_service_port: int = 10_002, + account_name: Optional[str] = None, + account_key: Optional[str] = None, + **kwargs, + ) -> None: + """Constructs an AzuriteContainer. + + Args: + image: Expects an image with tag. + **kwargs: Keyword arguments passed to super class. + """ + super().__init__(image=image, **kwargs) + self.account_name = account_name or os.environ.get("AZURITE_ACCOUNT_NAME", "devstoreaccount1") + self.account_key = account_key or os.environ.get( + "AZURITE_ACCOUNT_KEY", + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/" "K1SZFPTOtr/KBHBeksoGMGw==", + ) + + raise_for_deprecated_parameter(kwargs, "ports_to_expose", "container.with_exposed_ports") + self.blob_service_port = blob_service_port + self.queue_service_port = queue_service_port + self.table_service_port = table_service_port + + self.with_exposed_ports(blob_service_port, queue_service_port, table_service_port) + self.with_env("AZURITE_ACCOUNTS", f"{self.account_name}:{self.account_key}") + + def get_connection_string(self) -> str: + host_ip = self.get_container_host_ip() + connection_string = ( + f"DefaultEndpointsProtocol=http;" f"AccountName={self.account_name};" f"AccountKey={self.account_key};" + ) + + if self.blob_service_port in self.ports: + connection_string += ( + f"BlobEndpoint=http://{host_ip}:" + f"{self.get_exposed_port(self.blob_service_port)}" + f"/{self.account_name};" + ) + + if self.queue_service_port in self.ports: + connection_string += ( + f"QueueEndpoint=http://{host_ip}:" + f"{self.get_exposed_port(self.queue_service_port)}" + f"/{self.account_name};" + ) + + if self.table_service_port in self.ports: + connection_string += ( + f"TableEndpoint=http://{host_ip}:" + f"{self.get_exposed_port(self.table_service_port)}" + f"/{self.account_name};" + ) + + return connection_string + + def start(self) -> "AzuriteContainer": + super().start() + self._connect() + return self + + @wait_container_is_ready(OSError) + def _connect(self) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((self.get_container_host_ip(), int(self.get_exposed_port(next(iter(self.ports)))))) diff --git a/modules/azurite/tests/test_azurite.py b/modules/azurite/tests/test_azurite.py new file mode 100644 index 000000000..74230ab14 --- /dev/null +++ b/modules/azurite/tests/test_azurite.py @@ -0,0 +1,12 @@ +from azure.storage.blob import BlobServiceClient + +from testcontainers.azurite import AzuriteContainer + + +def test_docker_run_azurite(): + with AzuriteContainer() as azurite_container: + blob_service_client = BlobServiceClient.from_connection_string( + azurite_container.get_connection_string(), api_version="2019-12-12" + ) + + blob_service_client.create_container("test-container") diff --git a/modules/cassandra/README.rst b/modules/cassandra/README.rst new file mode 100644 index 000000000..44216d6be --- /dev/null +++ b/modules/cassandra/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.cassandra.CassandraContainer +.. title:: testcontainers.cassandra.CassandraContainer diff --git a/modules/cassandra/testcontainers/cassandra/__init__.py b/modules/cassandra/testcontainers/cassandra/__init__.py new file mode 100644 index 000000000..4e6618b7b --- /dev/null +++ b/modules/cassandra/testcontainers/cassandra/__init__.py @@ -0,0 +1,62 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class CassandraContainer(DockerContainer): + """ + Cassandra database container. + + Example: + + .. doctest:: + + >>> from testcontainers.cassandra import CassandraContainer + >>> from cassandra.cluster import Cluster, DCAwareRoundRobinPolicy + + >>> with CassandraContainer("cassandra:4.1.4") as cassandra, Cluster( + ... cassandra.get_contact_points(), + ... load_balancing_policy=DCAwareRoundRobinPolicy(cassandra.get_local_datacenter()), + ... ) as cluster: + ... session = cluster.connect() + ... result = session.execute("SELECT release_version FROM system.local;") + ... result.one().release_version + '4.1.4' + """ + + CQL_PORT = 9042 + DEFAULT_LOCAL_DATACENTER = "datacenter1" + + def __init__(self, image: str = "cassandra:latest", **kwargs) -> None: + super().__init__(image=image, **kwargs) + self.with_exposed_ports(self.CQL_PORT) + self.with_env("JVM_OPTS", "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0") + self.with_env("HEAP_NEWSIZE", "128M") + self.with_env("MAX_HEAP_SIZE", "1024M") + self.with_env("CASSANDRA_ENDPOINT_SNITCH", "GossipingPropertyFileSnitch") + self.with_env("CASSANDRA_DC", self.DEFAULT_LOCAL_DATACENTER) + + def _connect(self): + wait_for_logs(self, "Startup complete") + + def start(self) -> "CassandraContainer": + super().start() + self._connect() + return self + + def get_contact_points(self) -> list[tuple[str, int]]: + return [(self.get_container_host_ip(), int(self.get_exposed_port(self.CQL_PORT)))] + + def get_local_datacenter(self) -> str: + return self.env.get("CASSANDRA_DC", self.DEFAULT_LOCAL_DATACENTER) diff --git a/modules/cassandra/tests/test_cassandra.py b/modules/cassandra/tests/test_cassandra.py new file mode 100644 index 000000000..1aa5858b7 --- /dev/null +++ b/modules/cassandra/tests/test_cassandra.py @@ -0,0 +1,14 @@ +from cassandra.cluster import Cluster, DCAwareRoundRobinPolicy + +from testcontainers.cassandra import CassandraContainer + + +def test_docker_run_cassandra(): + with CassandraContainer("cassandra:4.1.4") as cassandra: + cluster = Cluster( + cassandra.get_contact_points(), + load_balancing_policy=DCAwareRoundRobinPolicy(cassandra.get_local_datacenter()), + ) + session = cluster.connect() + result = session.execute("SELECT release_version FROM system.local;") + assert result.one().release_version == "4.1.4" diff --git a/modules/chroma/README.rst b/modules/chroma/README.rst new file mode 100644 index 000000000..f4e3199fe --- /dev/null +++ b/modules/chroma/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.chroma.ChromaContainer +.. title:: testcontainers.minio.ChromaContainer diff --git a/modules/chroma/testcontainers/chroma/__init__.py b/modules/chroma/testcontainers/chroma/__init__.py new file mode 100644 index 000000000..9e5744099 --- /dev/null +++ b/modules/chroma/testcontainers/chroma/__init__.py @@ -0,0 +1,81 @@ +from typing import TYPE_CHECKING + +from requests import ConnectionError, get + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + +if TYPE_CHECKING: + from requests import Response + + +class ChromaContainer(DockerContainer): + """ + The example below spins up a ChromaDB container, performs a healthcheck and creates a collection. + The method :code:`get_client` can be used to create a client for the Chroma Python Client. + + Example: + + .. doctest:: + + >>> import chromadb + >>> from testcontainers.chroma import ChromaContainer + + >>> with ChromaContainer() as chroma: + ... config = chroma.get_config() + ... client = chromadb.HttpClient(host=config["host"], port=config["port"]) + ... col = client.get_or_create_collection("test") + ... col.name + 'test' + """ + + def __init__( + self, + image: str = "chromadb/chroma:latest", + port: int = 8000, + **kwargs, + ) -> None: + """ + Args: + image: Docker image to use for the MinIO container. + port: Port to expose on the container. + access_key: Access key for client connections. + secret_key: Secret key for client connections. + """ + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + + self.with_exposed_ports(self.port) + # self.with_command(f"server /data --address :{self.port}") + + def get_config(self) -> dict: + """This method returns the configuration of the Chroma container, + including the endpoint. + + Returns: + dict: {`endpoint`: str} + """ + host_ip = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.port) + return { + "endpoint": f"{host_ip}:{exposed_port}", + "host": host_ip, + "port": exposed_port, + } + + @wait_container_is_ready(ConnectionError) + def _healthcheck(self) -> None: + """This is an internal method used to check if the Chroma container + is healthy and ready to receive requests.""" + url = f"http://{self.get_config()['endpoint']}/api/v1/heartbeat" + response: Response = get(url) + response.raise_for_status() + + def start(self) -> "ChromaContainer": + """This method starts the Chroma container and runs the healthcheck + to verify that the container is ready to use.""" + super().start() + self._healthcheck() + return self diff --git a/modules/chroma/tests/test_chroma.py b/modules/chroma/tests/test_chroma.py new file mode 100644 index 000000000..fee55b788 --- /dev/null +++ b/modules/chroma/tests/test_chroma.py @@ -0,0 +1,9 @@ +from testcontainers.chroma import ChromaContainer +import chromadb + + +def test_docker_run_chroma(): + with ChromaContainer(image="chromadb/chroma:0.4.24") as chroma: + client = chromadb.HttpClient(host=chroma.get_config()["host"], port=chroma.get_config()["port"]) + col = client.get_or_create_collection("test") + assert col.name == "test" diff --git a/modules/clickhouse/README.rst b/modules/clickhouse/README.rst new file mode 100644 index 000000000..0835d4c04 --- /dev/null +++ b/modules/clickhouse/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.clickhouse.ClickHouseContainer +.. title:: testcontainers.clickhouse.ClickHouseContainer diff --git a/modules/clickhouse/testcontainers/clickhouse/__init__.py b/modules/clickhouse/testcontainers/clickhouse/__init__.py new file mode 100644 index 000000000..fbc8fab65 --- /dev/null +++ b/modules/clickhouse/testcontainers/clickhouse/__init__.py @@ -0,0 +1,81 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class ClickHouseContainer(DbContainer): + """ + ClickHouse database container. + + Example: + + The example spins up a ClickHouse database and connects to it using the + :code:`clickhouse-driver`. + + .. doctest:: + + >>> import clickhouse_driver + >>> from testcontainers.clickhouse import ClickHouseContainer + + >>> with ClickHouseContainer("clickhouse/clickhouse-server:21.8") as clickhouse: + ... client = clickhouse_driver.Client.from_url(clickhouse.get_connection_url()) + ... client.execute("select 'working'") + [('working',)] + """ + + def __init__( + self, + image: str = "clickhouse/clickhouse-server:latest", + port: int = 9000, + username: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "user", "username") + super().__init__(image=image, **kwargs) + self.username = username or os.environ.get("CLICKHOUSE_USER", "test") + self.password = password or os.environ.get("CLICKHOUSE_PASSWORD", "test") + self.dbname = dbname or os.environ.get("CLICKHOUSE_DB", "test") + self.port = port + self.with_exposed_ports(self.port) + self.with_exposed_ports(8123) + + @wait_container_is_ready(HTTPError, URLError) + def _connect(self) -> None: + # noinspection HttpUrlsUsage + url = f"http://{self.get_container_host_ip()}:{self.get_exposed_port(8123)}" + with urlopen(url) as r: + assert b"Ok" in r.read() + + def _configure(self) -> None: + self.with_env("CLICKHOUSE_USER", self.username) + self.with_env("CLICKHOUSE_PASSWORD", self.password) + self.with_env("CLICKHOUSE_DB", self.dbname) + + def get_connection_url(self, host: Optional[str] = None) -> str: + return self._create_connection_url( + dialect="clickhouse", + username=self.username, + password=self.password, + dbname=self.dbname, + host=host, + port=self.port, + ) diff --git a/tests/test_clickhouse.py b/modules/clickhouse/tests/test_clickhouse.py similarity index 89% rename from tests/test_clickhouse.py rename to modules/clickhouse/tests/test_clickhouse.py index 32ac046f7..23e5f4686 100644 --- a/tests/test_clickhouse.py +++ b/modules/clickhouse/tests/test_clickhouse.py @@ -1,4 +1,5 @@ import clickhouse_driver + from testcontainers.clickhouse import ClickHouseContainer @@ -8,4 +9,4 @@ def test_docker_run_clickhouse(): client = clickhouse_driver.Client.from_url(clickhouse.get_connection_url()) result = client.execute("select 'working'") - assert result == [('working',)] + assert result == [("working",)] diff --git a/modules/cockroachdb/README.rst b/modules/cockroachdb/README.rst new file mode 100644 index 000000000..7b53fc336 --- /dev/null +++ b/modules/cockroachdb/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.cockroachdb.CockroachDBContainer +.. title:: testcontainers.cockroachdb.CockroachDBContainer diff --git a/modules/cockroachdb/testcontainers/cockroachdb/__init__.py b/modules/cockroachdb/testcontainers/cockroachdb/__init__.py new file mode 100644 index 000000000..13a17ed5c --- /dev/null +++ b/modules/cockroachdb/testcontainers/cockroachdb/__init__.py @@ -0,0 +1,100 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from os import environ +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class CockroachDBContainer(DbContainer): + """ + CockroachDB database container. + + Example: + + The example will spin up a CockroachDB database to which you can connect with the credentials + passed in the constructor. Alternatively, you may use the :code:`get_connection_url()` + method which returns a sqlalchemy-compatible url in format + :code:`dialect+driver://username:password@host:port/database`. + + .. doctest:: + + >>> import sqlalchemy + >>> from testcontainers.cockroachdb import CockroachDBContainer + + >>> with CockroachDBContainer('cockroachdb/cockroach:v24.1.1') as crdb: + ... engine = sqlalchemy.create_engine(crdb.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select version()")) + ... version, = result.fetchone() + + """ + + COCKROACH_DB_PORT: int = 26257 + COCKROACH_API_PORT: int = 8080 + + def __init__( + self, + image: str = "cockroachdb/cockroach:v24.1.1", + username: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + dialect="cockroachdb+psycopg2", + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + + self.with_exposed_ports(self.COCKROACH_DB_PORT, self.COCKROACH_API_PORT) + self.username = username or environ.get("COCKROACH_USER", "cockroach") + self.password = password or environ.get("COCKROACH_PASSWORD", "arthropod") + self.dbname = dbname or environ.get("COCKROACH_DATABASE", "roach") + self.dialect = dialect + + def _configure(self) -> None: + self.with_env("COCKROACH_DATABASE", self.dbname) + self.with_env("COCKROACH_USER", self.username) + self.with_env("COCKROACH_PASSWORD", self.password) + + cmd = "start-single-node" + if not self.password: + cmd += " --insecure" + self.with_command(cmd) + + @wait_container_is_ready(HTTPError, URLError) + def _connect(self) -> None: + host = self.get_container_host_ip() + url = f"http://{host}:{self.get_exposed_port(self.COCKROACH_API_PORT)}/health" + self._wait_for_health(url) + wait_for_logs(self, "finished creating default user*") + + @staticmethod + def _wait_for_health(url): + with urlopen(url) as response: + response.read() + + def get_connection_url(self) -> str: + conn_str = super()._create_connection_url( + dialect=self.dialect, + username=self.username, + password=self.password, + dbname=self.dbname, + port=self.COCKROACH_DB_PORT, + ) + + if self.password: + conn_str += "?sslmode=require" + + return conn_str diff --git a/modules/cockroachdb/tests/test_cockroachdb.py b/modules/cockroachdb/tests/test_cockroachdb.py new file mode 100644 index 000000000..af20fd580 --- /dev/null +++ b/modules/cockroachdb/tests/test_cockroachdb.py @@ -0,0 +1,14 @@ +import sqlalchemy + +from testcontainers.cockroachdb import CockroachDBContainer + + +def test_docker_run_mysql(): + config = CockroachDBContainer("cockroachdb/cockroach:v24.1.1") + with config as crdb: + engine = sqlalchemy.create_engine(crdb.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select version()")) + for row in result: + assert "CockroachDB" in row[0] + assert "v24.1.1" in row[0] diff --git a/modules/cosmosdb/README.rst b/modules/cosmosdb/README.rst new file mode 100644 index 000000000..802cffa4e --- /dev/null +++ b/modules/cosmosdb/README.rst @@ -0,0 +1,5 @@ +.. autoclass:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer +.. title:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer + +.. autoclass:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer +.. title:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer diff --git a/modules/cosmosdb/testcontainers/cosmosdb/__init__.py b/modules/cosmosdb/testcontainers/cosmosdb/__init__.py new file mode 100644 index 000000000..619ddb3b4 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/__init__.py @@ -0,0 +1,4 @@ +from .mongodb import CosmosDBMongoEndpointContainer +from .nosql import CosmosDBNoSQLEndpointContainer + +__all__ = ["CosmosDBMongoEndpointContainer", "CosmosDBNoSQLEndpointContainer"] diff --git a/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py b/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py new file mode 100644 index 000000000..161a01c29 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py @@ -0,0 +1,110 @@ +import os +import socket +import ssl +from collections.abc import Iterable +from distutils.util import strtobool +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from typing_extensions import Self + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +from . import _grab as grab + +__all__ = ["CosmosDBEmulatorContainer"] + +EMULATOR_PORT = 8081 + + +class CosmosDBEmulatorContainer(DockerContainer): + """ + Abstract class for CosmosDB Emulator endpoints. + + Concrete implementations for each endpoint is provided by a separate class: + NoSQLEmulatorContainer and MongoDBEmulatorContainer. + """ + + def __init__( + self, + image: str = os.getenv( + "AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest" + ), + partition_count: int = os.getenv("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", None), + enable_data_persistence: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false")), + key: str = os.getenv( + "AZURE_COSMOS_EMULATOR_KEY", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + ), + bind_ports: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_BIND_PORTS", "true")), + endpoint_ports: Iterable[int] = [], + **other_kwargs, + ): + super().__init__(image=image, **other_kwargs) + self.endpoint_ports = endpoint_ports + self.partition_count = partition_count + self.key = key + self.enable_data_persistence = enable_data_persistence + self.bind_ports = bind_ports + + @property + def host(self) -> str: + """ + Emulator host + """ + return self.get_container_host_ip() + + @property + def server_certificate_pem(self) -> bytes: + """ + PEM-encoded server certificate + """ + return self._cert_pem_bytes + + def start(self) -> Self: + self._configure() + super().start() + self._wait_until_ready() + self._cert_pem_bytes = self._download_cert() + return self + + def _configure(self) -> None: + all_ports = {EMULATOR_PORT, *self.endpoint_ports} + if self.bind_ports: + for port in all_ports: + self.with_bind_ports(port, port) + else: + self.with_exposed_ports(*all_ports) + + ( + self.with_env("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", str(self.partition_count)) + .with_env("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", socket.gethostbyname(socket.gethostname())) + .with_env("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", str(self.enable_data_persistence)) + .with_env("AZURE_COSMOS_EMULATOR_KEY", str(self.key)) + ) + + def _wait_until_ready(self) -> Self: + wait_for_logs(container=self, predicate="Started\\s*$") + + if self.bind_ports: + self._wait_for_url(f"https://{self.host}:{EMULATOR_PORT}/_explorer/index.html") + self._wait_for_query_success() + + return self + + def _download_cert(self) -> bytes: + with grab.file( + self.get_wrapped_container(), + "/tmp/cosmos/appdata/.system/profiles/Client/AppData/Local/CosmosDBEmulator/emulator.pem", + ) as cert: + return cert.read() + + @wait_container_is_ready(HTTPError, URLError) + def _wait_for_url(self, url: str) -> Self: + with urlopen(url, context=ssl._create_unverified_context()) as response: + response.read() + return self + + def _wait_for_query_success(self) -> None: + pass diff --git a/modules/cosmosdb/testcontainers/cosmosdb/_grab.py b/modules/cosmosdb/testcontainers/cosmosdb/_grab.py new file mode 100644 index 000000000..e1895019a --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/_grab.py @@ -0,0 +1,26 @@ +import tarfile +import tempfile +from contextlib import contextmanager +from os import path +from pathlib import Path + +from docker.models.containers import Container + + +@contextmanager +def file(container: Container, target: str): + target_path = Path(target) + assert target_path.is_absolute(), "target must be an absolute path" + + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / "grabbed.tar" + + # download from container as tar archive + with open(archive, "wb") as f: + tar_bits, _ = container.get_archive(target) + for chunk in tar_bits: + f.write(chunk) + + # extract target file from tar archive + with tarfile.TarFile(archive) as tar: + yield tar.extractfile(path.basename(target)) diff --git a/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py b/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py new file mode 100644 index 000000000..82e8c096b --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py @@ -0,0 +1,47 @@ +import os + +from ._emulator import CosmosDBEmulatorContainer + +__all__ = ["CosmosDBMongoEndpointContainer"] + +ENDPOINT_PORT = 10255 + + +class CosmosDBMongoEndpointContainer(CosmosDBEmulatorContainer): + """ + CosmosDB MongoDB enpoint Emulator. + + Example: + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBMongoEndpointContainer + + >>> with CosmosDBMongoEndpointContainer(mongodb_version="4.0") as emulator: + ... print(f"Point your MongoDB client at {emulator.host}:{emulator.port} using key {emulator.key}") + ... print(f"and eiher disable TLS server auth or trust the server's self signed cert (emulator.server_certificate_pem)") + + """ + + def __init__( + self, + mongodb_version: str, + image: str = os.getenv( + "AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:mongodb" + ), + **other_kwargs, + ): + super().__init__(image=image, endpoint_ports=[ENDPOINT_PORT], **other_kwargs) + assert mongodb_version is not None, "A MongoDB version is required to use the MongoDB Endpoint" + self.mongodb_version = mongodb_version + + @property + def port(self) -> str: + """ + The exposed port to the MongoDB endpoint + """ + return self.get_exposed_port(ENDPOINT_PORT) + + def _configure(self) -> None: + super()._configure() + self.with_env("AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT", self.mongodb_version) diff --git a/modules/cosmosdb/testcontainers/cosmosdb/nosql.py b/modules/cosmosdb/testcontainers/cosmosdb/nosql.py new file mode 100644 index 000000000..f78469674 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/nosql.py @@ -0,0 +1,69 @@ +from azure.core.exceptions import ServiceRequestError +from azure.cosmos import CosmosClient as SyncCosmosClient +from azure.cosmos.aio import CosmosClient as AsyncCosmosClient + +from testcontainers.core.waiting_utils import wait_container_is_ready + +from ._emulator import CosmosDBEmulatorContainer + +__all__ = ["CosmosDBNoSQLEndpointContainer"] + +NOSQL_PORT = 8081 + + +class CosmosDBNoSQLEndpointContainer(CosmosDBEmulatorContainer): + """ + CosmosDB NoSQL enpoint Emulator. + + Example: + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + >>> with CosmosDBNoSQLEndpointContainer() as emulator: + ... db = emulator.insecure_sync_client().create_database_if_not_exists("test") + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + >>> from azure.cosmos import CosmosClient + + >>> with CosmosDBNoSQLEndpointContainer() as emulator: + ... client = CosmosClient(url=emulator.url, credential=emulator.key, connection_verify=False) + ... db = client.create_database_if_not_exists("test") + + """ + + def __init__(self, **kwargs): + super().__init__(endpoint_ports=[NOSQL_PORT], **kwargs) + + @property + def port(self) -> str: + """ + The exposed port to the NoSQL endpoint + """ + return self.get_exposed_port(NOSQL_PORT) + + @property + def url(self) -> str: + """ + The url to the NoSQL endpoint + """ + return f"https://{self.host}:{self.port}" + + def insecure_async_client(self): + """ + Returns an asynchronous CosmosClient instance + """ + return AsyncCosmosClient(url=self.url, credential=self.key, connection_verify=False) + + def insecure_sync_client(self): + """ + Returns a synchronous CosmosClient instance + """ + return SyncCosmosClient(url=self.url, credential=self.key, connection_verify=False) + + @wait_container_is_ready(ServiceRequestError) + def _wait_for_query_success(self) -> None: + with self.insecure_sync_client() as c: + list(c.list_databases()) diff --git a/modules/cosmosdb/tests/test_emulator.py b/modules/cosmosdb/tests/test_emulator.py new file mode 100644 index 000000000..542ddd11c --- /dev/null +++ b/modules/cosmosdb/tests/test_emulator.py @@ -0,0 +1,8 @@ +import pytest +from testcontainers.cosmosdb._emulator import CosmosDBEmulatorContainer + + +def test_runs(): + with CosmosDBEmulatorContainer(partition_count=1, bind_ports=False) as emulator: + assert emulator.server_certificate_pem is not None + assert emulator.get_exposed_port(8081) is not None diff --git a/modules/cosmosdb/tests/test_mongodb.py b/modules/cosmosdb/tests/test_mongodb.py new file mode 100644 index 000000000..a50ee82ea --- /dev/null +++ b/modules/cosmosdb/tests/test_mongodb.py @@ -0,0 +1,16 @@ +import pytest +from testcontainers.cosmosdb import CosmosDBMongoEndpointContainer + + +def test_requires_a_version(): + with pytest.raises(AssertionError, match="A MongoDB version is required"): + CosmosDBMongoEndpointContainer(mongodb_version=None) + + # instanciates + CosmosDBMongoEndpointContainer(mongodb_version="4.0") + + +def test_runs(): + with CosmosDBMongoEndpointContainer(mongodb_version="4.0", partition_count=1, bind_ports=False) as emulator: + assert emulator.env["AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT"] == "4.0" + assert emulator.get_exposed_port(10255) is not None, "The MongoDB endpoint's port should be exposed" diff --git a/modules/cosmosdb/tests/test_nosql.py b/modules/cosmosdb/tests/test_nosql.py new file mode 100644 index 000000000..a9460a1b0 --- /dev/null +++ b/modules/cosmosdb/tests/test_nosql.py @@ -0,0 +1,7 @@ +import pytest +from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + + +def test_runs(): + with CosmosDBNoSQLEndpointContainer(partition_count=1, bind_ports=False) as emulator: + assert emulator.get_exposed_port(8081) is not None, "The NoSQL endpoint's port should be exposed" diff --git a/modules/db2/README.rst b/modules/db2/README.rst new file mode 100644 index 000000000..1afd1f6d7 --- /dev/null +++ b/modules/db2/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.db2.Db2Container +.. title:: testcontainers.db2.Db2Container diff --git a/modules/db2/testcontainers/db2/__init__.py b/modules/db2/testcontainers/db2/__init__.py new file mode 100644 index 000000000..b17c0efef --- /dev/null +++ b/modules/db2/testcontainers/db2/__init__.py @@ -0,0 +1,61 @@ +from os import environ +from typing import Optional + +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class Db2Container(DbContainer): + """ + IBM Db2 database container. + + Example: + + .. doctest:: + + >>> import sqlalchemy + >>> from testcontainers.db2 import Db2Container + + >>> with Db2Container("icr.io/db2_community/db2:latest") as db2: + ... engine = sqlalchemy.create_engine(db2.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select service_level from sysibmadm.env_inst_info")) + """ + + def __init__( + self, + image: str = "icr.io/db2_community/db2:latest", + username: str = "db2inst1", + password: Optional[str] = None, + port: int = 50000, + dbname: str = "testdb", + dialect: str = "db2+ibm_db", + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + + self.port = port + self.with_exposed_ports(self.port) + + self.password = password or environ.get("DB2_PASSWORD", "password") + self.username = username + self.dbname = dbname + self.dialect = dialect + + def _configure(self) -> None: + self.with_env("LICENSE", "accept") + self.with_env("DB2INSTANCE", self.username) + self.with_env("DB2INST1_PASSWORD", self.password) + self.with_env("DBNAME", self.dbname) + self.with_env("ARCHIVE_LOGS", "false") + self.with_env("AUTOCONFIG", "false") + self.with_kwargs(privileged=True) + + @wait_container_is_ready() + def _connect(self) -> None: + wait_for_logs(self, predicate="Setup has completed") + + def get_connection_url(self) -> str: + return super()._create_connection_url( + dialect=self.dialect, username=self.username, password=self.password, dbname=self.dbname, port=self.port + ) diff --git a/modules/db2/tests/test_db2.py b/modules/db2/tests/test_db2.py new file mode 100644 index 000000000..7b6ea844a --- /dev/null +++ b/modules/db2/tests/test_db2.py @@ -0,0 +1,43 @@ +from unittest import mock + +import pytest +import sqlalchemy + +from testcontainers.core.utils import is_arm +from testcontainers.db2 import Db2Container + + +@pytest.mark.skipif(is_arm(), reason="db2 container not available for ARM") +@pytest.mark.parametrize("version", ["11.5.9.0", "11.5.8.0"]) +def test_docker_run_db2(version: str): + with Db2Container(f"icr.io/db2_community/db2:{version}", password="password") as db2: + engine = sqlalchemy.create_engine(db2.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select service_level from sysibmadm.env_inst_info")) + for row in result: + assert row[0] == f"DB2 v{version}" + + +# This is a feature in the generic DbContainer class +# but it can't be tested on its own +# so is tested in various database modules: +# - mysql / mariadb +# - postgresql +# - sqlserver +# - mongodb +# - db2 +def test_quoted_password(): + user = "db2inst1" + dbname = "testdb" + password = "p@$%25+0&%rd :/!=?" + quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F" + kwargs = { + "username": user, + "password": password, + "dbname": dbname, + } + with Db2Container("icr.io/db2_community/db2:11.5.9.0", **kwargs) as container: + port = container.get_exposed_port(50000) + host = container.get_container_host_ip() + expected_url = f"db2+ibm_db://{user}:{quoted_password}@{host}:{port}/{dbname}" + assert expected_url == container.get_connection_url() diff --git a/modules/elasticsearch/README.rst b/modules/elasticsearch/README.rst new file mode 100644 index 000000000..c2ce1930f --- /dev/null +++ b/modules/elasticsearch/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.elasticsearch.ElasticSearchContainer +.. title:: testcontainers.elasticsearch.ElasticSearchContainer diff --git a/modules/elasticsearch/testcontainers/elasticsearch/__init__.py b/modules/elasticsearch/testcontainers/elasticsearch/__init__.py new file mode 100644 index 000000000..1b943916a --- /dev/null +++ b/modules/elasticsearch/testcontainers/elasticsearch/__init__.py @@ -0,0 +1,103 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import logging +import re +import urllib +from urllib.error import URLError + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + +_FALLBACK_VERSION = 8 +"""This version is used when no version could be detected from the image name.""" + + +def _major_version_from_image_name(image_name: str) -> int: + """Returns the major version from a container name like 'elasticsearch:8.1.0' + If the major version could not be determined, it will use the most recent + one (8 at the time of writing 2022-08-11). + """ + version_string = image_name.split(":")[-1] + regex_match = re.compile(r"(\d+)\.\d+\.\d+").match(version_string) + if not regex_match: + logging.warning( + "Could not determine major version from image name '%s'. Will use %s", image_name, _FALLBACK_VERSION + ) + return _FALLBACK_VERSION + else: + return int(regex_match.group(1)) + + +def _environment_by_version(version: int) -> dict[str, str]: + """Returns environment variables required for each major version to work.""" + if version == 6: + # This setting is needed to avoid the check for the kernel parameter + # vm.max_map_count in the BootstrapChecks + return {"discovery.zen.minimum_master_nodes": "1"} + elif version == 7: + return {} + elif version == 8: + # Elasticsearch uses https now by default. However, our readiness + # check uses http, which does not work. Hence we disable security + # which should not be an issue for our context + return {"xpack.security.enabled": "false"} + else: + raise ValueError(f"Unknown elasticsearch version given: {version}") + + +class ElasticSearchContainer(DockerContainer): + """ + ElasticSearch container. + + Example: + + .. doctest:: + + >>> import json + >>> import urllib + >>> from testcontainers.elasticsearch import ElasticSearchContainer + + >>> with ElasticSearchContainer(f'elasticsearch:8.3.3', mem_limit='3G') as es: + ... resp = urllib.request.urlopen(es.get_url()) + ... json.loads(resp.read().decode())['version']['number'] + '8.3.3' + """ + + def __init__(self, image: str = "elasticsearch", port: int = 9200, **kwargs) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.with_exposed_ports(self.port) + self.with_env("transport.host", "127.0.0.1") + self.with_env("http.host", "0.0.0.0") + + major_version = _major_version_from_image_name(image) + for key, value in _environment_by_version(major_version).items(): + self.with_env(key, value) + + @wait_container_is_ready(URLError) + def _connect(self) -> None: + res = urllib.request.urlopen(self.get_url()) + if res.status != 200: + raise Exception() + + def get_url(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + return f"http://{host}:{port}" + + def start(self) -> "ElasticSearchContainer": + super().start() + self._connect() + return self diff --git a/modules/elasticsearch/tests/test_elasticsearch.py b/modules/elasticsearch/tests/test_elasticsearch.py new file mode 100644 index 000000000..661a550c6 --- /dev/null +++ b/modules/elasticsearch/tests/test_elasticsearch.py @@ -0,0 +1,14 @@ +import json +import urllib.request + +import pytest + +from testcontainers.elasticsearch import ElasticSearchContainer + + +# The versions below should reflect the latest stable releases +@pytest.mark.parametrize("version", ["7.17.18", "8.12.2"]) +def test_docker_run_elasticsearch(version): + with ElasticSearchContainer(f"elasticsearch:{version}", mem_limit="3G") as es: + resp = urllib.request.urlopen(es.get_url()) + assert json.loads(resp.read().decode())["version"]["number"] == version diff --git a/modules/generic/README.rst b/modules/generic/README.rst new file mode 100644 index 000000000..7e12da700 --- /dev/null +++ b/modules/generic/README.rst @@ -0,0 +1,20 @@ +:code:`testcontainers-generic` is a set of generic containers modules that can be used to creat containers. + +.. autoclass:: testcontainers.generic.ServerContainer +.. title:: testcontainers.generic.ServerContainer + +FastAPI container that is using :code:`ServerContainer` + +.. doctest:: + + >>> from testcontainers.generic import ServerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + + >>> with DockerImage(path="./modules/generic/tests/samples/fastapi", tag="fastapi-test:latest") as image: + ... with ServerContainer(port=80, image=image) as fastapi_server: + ... delay = wait_for_logs(fastapi_server, "Uvicorn running on http://0.0.0.0:80") + ... fastapi_server.get_api_url = lambda: fastapi_server._create_connection_url() + "/api/v1/" + ... client = fastapi_server.get_client() + ... response = client.get("/") + ... assert response.status_code == 200 + ... assert response.json() == {"Status": "Working"} diff --git a/modules/generic/testcontainers/generic/__init__.py b/modules/generic/testcontainers/generic/__init__.py new file mode 100644 index 000000000..f239a80c6 --- /dev/null +++ b/modules/generic/testcontainers/generic/__init__.py @@ -0,0 +1 @@ +from .server import ServerContainer # noqa: F401 diff --git a/modules/generic/testcontainers/generic/server.py b/modules/generic/testcontainers/generic/server.py new file mode 100644 index 000000000..03a546772 --- /dev/null +++ b/modules/generic/testcontainers/generic/server.py @@ -0,0 +1,80 @@ +from typing import Union +from urllib.error import HTTPError +from urllib.request import urlopen + +import httpx + +from testcontainers.core.container import DockerContainer +from testcontainers.core.exceptions import ContainerStartException +from testcontainers.core.image import DockerImage +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class ServerContainer(DockerContainer): + """ + Container for a generic server that is based on a custom image. + + Example: + + .. doctest:: + + >>> import httpx + >>> from testcontainers.generic import ServerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest") as image: + ... with ServerContainer(port=9000, image=image) as srv: + ... url = srv._create_connection_url() + ... response = httpx.get(f"{url}", timeout=5) + ... assert response.status_code == 200, "Response status code is not 200" + ... delay = wait_for_logs(srv, "GET / HTTP/1.1") + + + :param path: Path to the Dockerfile to build the image + :param tag: Tag for the image to be built (default: None) + """ + + def __init__(self, port: int, image: Union[str, DockerImage]) -> None: + super().__init__(str(image)) + self.internal_port = port + self.with_exposed_ports(self.internal_port) + + @wait_container_is_ready(HTTPError) + def _connect(self) -> None: + # noinspection HttpUrlsUsage + url = self._create_connection_url() + try: + with urlopen(url) as r: + assert b"" in r.read() + except HTTPError as e: + # 404 is expected, as the server may not have the specific endpoint we are looking for + if e.code == 404: + pass + else: + raise + + def get_api_url(self) -> str: + raise NotImplementedError + + def _create_connection_url(self) -> str: + if self._container is None: + raise ContainerStartException("container has not been started") + host = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.internal_port) + url = f"http://{host}:{exposed_port}" + return url + + def start(self) -> "ServerContainer": + super().start() + self._connect() + return self + + def stop(self, force=True, delete_volume=True) -> None: + super().stop(force, delete_volume) + + def get_client(self) -> httpx.Client: + return httpx.Client(base_url=self.get_api_url()) + + def get_stdout(self) -> str: + return self.get_logs()[0].decode("utf-8") diff --git a/modules/generic/tests/conftest.py b/modules/generic/tests/conftest.py new file mode 100644 index 000000000..4f69565f4 --- /dev/null +++ b/modules/generic/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from typing import Callable +from testcontainers.core.container import DockerClient + + +@pytest.fixture +def check_for_image() -> Callable[[str, bool], None]: + """Warp the check_for_image function in a fixture""" + + def _check_for_image(image_short_id: str, cleaned: bool) -> None: + """ + Validates if the image is present or not. + + :param image_short_id: The short id of the image + :param cleaned: True if the image should not be present, False otherwise + """ + client = DockerClient() + images = client.client.images.list() + found = any(image.short_id.endswith(image_short_id) for image in images) + assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}' + + return _check_for_image diff --git a/modules/generic/tests/samples/fastapi/Dockerfile b/modules/generic/tests/samples/fastapi/Dockerfile new file mode 100644 index 000000000..f56288cd5 --- /dev/null +++ b/modules/generic/tests/samples/fastapi/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.9 + +WORKDIR /app + +RUN pip install fastapi + +COPY ./app /app + +EXPOSE 80 + +CMD ["fastapi", "run", "main.py", "--port", "80"] diff --git a/testcontainers/core/__init__.py b/modules/generic/tests/samples/fastapi/app/__init__.py similarity index 100% rename from testcontainers/core/__init__.py rename to modules/generic/tests/samples/fastapi/app/__init__.py diff --git a/modules/generic/tests/samples/fastapi/app/main.py b/modules/generic/tests/samples/fastapi/app/main.py new file mode 100644 index 000000000..f96073d9f --- /dev/null +++ b/modules/generic/tests/samples/fastapi/app/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/api/v1/") +def read_root(): + return {"Status": "Working"} diff --git a/modules/generic/tests/samples/python_server/Dockerfile b/modules/generic/tests/samples/python_server/Dockerfile new file mode 100644 index 000000000..844acf2b3 --- /dev/null +++ b/modules/generic/tests/samples/python_server/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3-alpine +EXPOSE 9000 +CMD ["python", "-m", "http.server", "9000"] diff --git a/modules/generic/tests/test_generic.py b/modules/generic/tests/test_generic.py new file mode 100644 index 000000000..5943b4a4d --- /dev/null +++ b/modules/generic/tests/test_generic.py @@ -0,0 +1,53 @@ +import re +from pathlib import Path +from typing import Optional + +import pytest +from httpx import get + +from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.core.image import DockerImage +from testcontainers.generic import ServerContainer + +TEST_DIR = Path(__file__).parent + + +@pytest.mark.parametrize("test_image_cleanup", [True, False]) +@pytest.mark.parametrize("test_image_tag", [None, "custom-image:test"]) +def test_server_container(test_image_tag: Optional[str], test_image_cleanup: bool, check_for_image, port=9000): + with ( + DockerImage( + path=TEST_DIR / "samples/python_server", + tag=test_image_tag, + clean_up=test_image_cleanup, + # + ) as docker_image, + ServerContainer(port=port, image=docker_image) as srv, + ): + image_short_id = docker_image.short_id + image_build_logs = docker_image.get_logs() + # check if dict is in any of the logs + assert {"stream": f"Step 2/3 : EXPOSE {port}"} in image_build_logs, "Image logs mismatch" + assert (port, None) in srv.ports.items(), "Port mismatch" + with pytest.raises(NotImplementedError): + srv.get_api_url() + test_url = srv._create_connection_url() + assert re.match(r"http://localhost:\d+", test_url), "Connection URL mismatch" + + check_for_image(image_short_id, test_image_cleanup) + + +def test_server_container_no_port(): + with pytest.raises(TypeError): + with ServerContainer(path="./modules/generic/tests/samples/python_server", tag="test-srv:latest"): + pass + + +def test_like_doctest(): + with DockerImage(path=TEST_DIR / "samples/python_server", tag="test-srv:latest") as image: + with ServerContainer(port=9000, image=image) as srv: + url = srv._create_connection_url() + response = get(f"{url}", timeout=5) + assert response.status_code == 200, "Response status code is not 200" + delay = wait_for_logs(srv, "GET / HTTP/1.1") + print(delay) diff --git a/modules/google/README.rst b/modules/google/README.rst new file mode 100644 index 000000000..903c3c4a1 --- /dev/null +++ b/modules/google/README.rst @@ -0,0 +1,4 @@ +.. autoclass:: testcontainers.google.DatastoreContainer +.. title:: testcontainers.google.DatastoreContainer +.. autoclass:: testcontainers.google.PubSubContainer +.. title:: testcontainers.google.PubSubContainer diff --git a/modules/google/testcontainers/google/__init__.py b/modules/google/testcontainers/google/__init__.py new file mode 100644 index 000000000..92c782efc --- /dev/null +++ b/modules/google/testcontainers/google/__init__.py @@ -0,0 +1,2 @@ +from .datastore import DatastoreContainer # noqa: F401 +from .pubsub import PubSubContainer # noqa: F401 diff --git a/modules/google/testcontainers/google/datastore.py b/modules/google/testcontainers/google/datastore.py new file mode 100644 index 000000000..24edbdcd7 --- /dev/null +++ b/modules/google/testcontainers/google/datastore.py @@ -0,0 +1,68 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from unittest.mock import patch + +from google.cloud import datastore +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class DatastoreContainer(DockerContainer): + """ + Datastore container for testing managed message queues. + + Example: + + The example will spin up a Google Cloud Datastore emulator that you can use for integration + tests. The :code:`datastore` instance provides convenience methods :code:`get_datastore_client` to + connect to the emulator without having to set the environment variable :code:`DATASTORE_EMULATOR_HOST`. + + .. doctest:: + + >>> from testcontainers.google import DatastoreContainer + + >>> config = DatastoreContainer() + >>> with config as datastore: + ... datastore_client = datastore.get_datastore_client() + """ + + def __init__( + self, + image: str = "google/cloud-sdk:emulators", + project: str = "test-project", + port: int = 8081, + **kwargs, + ) -> None: + super().__init__(image=image, **kwargs) + self.project = project + self.port = port + self.with_exposed_ports(self.port) + self.with_command( + f"gcloud beta emulators datastore start --no-store-on-disk --project={project} --host-port=0.0.0.0:{port}" + ) + + def get_datastore_emulator_host(self) -> str: + return f"{self.get_container_host_ip()}:{self.get_exposed_port(self.port)}" + + def get_datastore_client(self, **kwargs) -> datastore.Client: + wait_for_logs(self, "Dev App Server is now running.", timeout=30.0) + env_vars = { + "DATASTORE_DATASET": self.project, + "DATASTORE_EMULATOR_HOST": self.get_datastore_emulator_host(), + "DATASTORE_EMULATOR_HOST_PATH": f"{self.get_datastore_emulator_host()}/datastore", + "DATASTORE_HOST": f"http://{self.get_datastore_emulator_host()}", + "DATASTORE_PROJECT_ID": self.project, + } + with patch.dict(os.environ, env_vars): + return datastore.Client(**kwargs) diff --git a/modules/google/testcontainers/google/pubsub.py b/modules/google/testcontainers/google/pubsub.py new file mode 100644 index 000000000..706030599 --- /dev/null +++ b/modules/google/testcontainers/google/pubsub.py @@ -0,0 +1,70 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from unittest.mock import patch + +from google.cloud import pubsub +from testcontainers.core.container import DockerContainer + + +class PubSubContainer(DockerContainer): + """ + PubSub container for testing managed message queues. + + Example: + + The example will spin up a Google Cloud PubSub emulator that you can use for integration + tests. The :code:`pubsub` instance provides convenience methods :code:`get_publisher` and + :code:`get_subscriber` to connect to the emulator without having to set the environment + variable :code:`PUBSUB_EMULATOR_HOST`. + + .. doctest:: + + >>> from testcontainers.google import PubSubContainer + + >>> config = PubSubContainer() + >>> with config as pubsub: + ... publisher = pubsub.get_publisher_client() + ... topic_path = publisher.topic_path(pubsub.project, "my-topic") + ... topic = publisher.create_topic(name=topic_path) + """ + + def __init__( + self, image: str = "google/cloud-sdk:emulators", project: str = "test-project", port: int = 8432, **kwargs + ) -> None: + super().__init__(image=image, **kwargs) + self.project = project + self.port = port + self.with_exposed_ports(self.port) + self.with_command(f"gcloud beta emulators pubsub start --project={project} --host-port=0.0.0.0:{port}") + + def get_pubsub_emulator_host(self) -> str: + return f"{self.get_container_host_ip()}:{self.get_exposed_port(self.port)}" + + def _get_client(self, cls: type, **kwargs) -> dict: + with patch.dict(os.environ, PUBSUB_EMULATOR_HOST=self.get_pubsub_emulator_host()): + return cls(**kwargs) + + def get_publisher_client(self, **kwargs) -> pubsub.PublisherClient: + from google.auth import credentials + + kwargs["client_options"] = {"api_endpoint": self.get_pubsub_emulator_host()} + kwargs["credentials"] = credentials.AnonymousCredentials() + return self._get_client(pubsub.PublisherClient, **kwargs) + + def get_subscriber_client(self, **kwargs) -> pubsub.SubscriberClient: + from google.auth import credentials + + kwargs["client_options"] = {"api_endpoint": self.get_pubsub_emulator_host()} + kwargs["credentials"] = credentials.AnonymousCredentials() + return self._get_client(pubsub.SubscriberClient, **kwargs) diff --git a/modules/google/tests/test_google.py b/modules/google/tests/test_google.py new file mode 100644 index 000000000..0c412d706 --- /dev/null +++ b/modules/google/tests/test_google.py @@ -0,0 +1,76 @@ +from queue import Queue +from google.cloud.datastore import Entity + +from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.google import PubSubContainer, DatastoreContainer + + +def test_pubsub_container(): + pubsub: PubSubContainer + with PubSubContainer() as pubsub: + wait_for_logs(pubsub, r"Server started, listening on \d+", timeout=60) + # Create a new topic + publisher = pubsub.get_publisher_client() + topic_path = publisher.topic_path(pubsub.project, "my-topic") + publisher.create_topic(name=topic_path) + + # Create a subscription + subscriber = pubsub.get_subscriber_client() + subscription_path = subscriber.subscription_path(pubsub.project, "my-subscription") + subscriber.create_subscription(name=subscription_path, topic=topic_path) + + # Publish a message + publisher.publish(topic_path, b"Hello world!") + + # Receive the message + queue = Queue() + subscriber.subscribe(subscription_path, queue.put) + message = queue.get(timeout=1) + assert message.data == b"Hello world!" + message.ack() + + +def test_datastore_container_creation(): + # Initialize the Datastore emulator container + with DatastoreContainer() as datastore: + # Obtain a datastore client configured to connect to the emulator + client = datastore.get_datastore_client() + + # Define a unique key for a test entity to ensure test isolation + key = client.key("TestKind", "test_id_1") + + # Create and insert a new entity + entity = Entity(key=key) + entity.update({"foo": "bar"}) + client.put(entity) + + # Fetch the just-inserted entity directly + fetched_entity = client.get(key) + + # Assert that the fetched entity matches what was inserted + assert fetched_entity is not None, "Entity was not found in the datastore." + assert fetched_entity["foo"] == "bar", "Entity attribute 'foo' did not match expected value 'bar'." + + +def test_datastore_container_isolation(): + # Initialize the Datastore emulator container + with DatastoreContainer() as datastore: + # Obtain a datastore client configured to connect to the emulator + client = datastore.get_datastore_client() + + # Define a unique key for a test entity to ensure test isolation + key = client.key("TestKind", "test_id_1") + + # Create and insert a new entity + entity = Entity(key=key) + entity.update({"foo": "bar"}) + client.put(entity) + + # Create a second container and try to fetch the entity to makesure its a different container + with DatastoreContainer() as datastore2: + assert ( + datastore.get_datastore_emulator_host() != datastore2.get_datastore_emulator_host() + ), "Datastore containers use the same port." + client2 = datastore2.get_datastore_client() + fetched_entity2 = client2.get(key) + assert fetched_entity2 is None, "Entity was found in the datastore." diff --git a/modules/index.rst b/modules/index.rst new file mode 100644 index 000000000..d2a67a3d4 --- /dev/null +++ b/modules/index.rst @@ -0,0 +1,11 @@ +Community Modules +================= + +.. + glob: + https://stackoverflow.com/a/44572883/4971476 + +.. toctree:: + :glob: + + */README diff --git a/modules/influxdb/README.rst b/modules/influxdb/README.rst new file mode 100644 index 000000000..8424ebea3 --- /dev/null +++ b/modules/influxdb/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.influxdb.InfluxDbContainer +.. title:: testcontainers.influxdb.InfluxDbContainer diff --git a/modules/influxdb/testcontainers/influxdb.py b/modules/influxdb/testcontainers/influxdb.py new file mode 100644 index 000000000..d8956e992 --- /dev/null +++ b/modules/influxdb/testcontainers/influxdb.py @@ -0,0 +1,100 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +testcontainers/influxdb provides means to spawn an InfluxDB instance within a Docker container. + +- this influxdb.py module provides the common mechanism to spawn an InfluxDB container. + You are not likely to use this module directly. +- import the InfluxDb1Container class from the influxdb1/__init__.py module to spawn + a container for an InfluxDB 1.x instance +- import the InfluxDb2Container class from the influxdb2/__init__.py module to spawn + a container for an InfluxDB 2.x instance + +The 2 containers are separated in different modules for 2 reasons: +- because the Docker images are not designed to be used in the same way +- because the InfluxDB clients are different for 1.x and 2.x versions, + so you won't have to install dependencies that you do not need +""" + +from typing import Optional + +from requests import get +from requests.exceptions import ConnectionError, ReadTimeout + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class InfluxDbContainer(DockerContainer): + """ + Abstract class for Docker containers of InfluxDB v1 and v2. + + Concrete implementations for InfluxDB 1.x and 2.x are separated iun different packages + because their respective clients rely on different Python libraries which we don't want + to import at the same time. + """ + + def __init__( + self, + # Docker image name + image: str, + # in the container, the default port for influxdb is often 8086 and not likely to change + container_port: int = 8086, + # specifies the port on the host machine where influxdb is exposed; a random available port otherwise + host_port: Optional[int] = None, + **docker_client_kw, + ): + super().__init__(image=image, **docker_client_kw) + self.container_port = container_port + self.host_port = host_port + self.with_bind_ports(self.container_port, self.host_port) + + def get_url(self) -> str: + """ + Returns the url to interact with the InfluxDB container (health check, REST API, etc.) + """ + host = self.get_container_host_ip() + port = self.get_exposed_port(self.container_port) + + return f"http://{host}:{port}" + + @wait_container_is_ready(ConnectionError, ReadTimeout) + def _health_check(self) -> dict: + """ + Performs a health check on the running InfluxDB container. + The call is retried until it works thanks to the @wait_container_is_ready decorator. + See its documentation for the max number of retries or the timeout. + """ + + url = self.get_url() + response = get(f"{url}/health", timeout=1) + response.raise_for_status() + + return response.json() + + def get_influxdb_version(self) -> str: + """ + Returns the version of the InfluxDB service, as returned by the healthcheck. + """ + + return self._health_check().get("version") + + def start(self) -> "InfluxDbContainer": + """ + Spawns a container of the InfluxDB Docker image, ready to be used. + """ + super().start() + self._health_check() + + return self diff --git a/modules/influxdb/testcontainers/influxdb1/__init__.py b/modules/influxdb/testcontainers/influxdb1/__init__.py new file mode 100644 index 000000000..81f21c163 --- /dev/null +++ b/modules/influxdb/testcontainers/influxdb1/__init__.py @@ -0,0 +1,65 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Optional + +from influxdb import InfluxDBClient + +from testcontainers.influxdb import InfluxDbContainer + + +class InfluxDb1Container(InfluxDbContainer): + """ + Docker container for InfluxDB 1.x. + Official Docker images for InfluxDB are hosted at https://hub.docker.com/_/influxdb/. + + Example: + + .. doctest:: + + >>> from testcontainers.influxdb1 import InfluxDbContainer + + >>> with InfluxDbContainer() as influxdb: + ... version = influxdb.get_version() + """ + + def __init__( + self, + image: str = "influxdb:1.8", + # in the container, the default port for influxdb is often 8086 and not likely to change + container_port: int = 8086, + # specifies the port on the host machine where influxdb is exposed; a random available port otherwise + host_port: Optional[int] = None, + **docker_client_kw, + ): + super().__init__(image, container_port, host_port, **docker_client_kw) + + def get_client(self, **client_kwargs): + """ + Returns an instance of the influxdb client, for InfluxDB 1.x versions. + Note that this client is not maintained anymore, but it is the only + official client available for 1.x InfluxDB versions: + - https://github.com/influxdata/influxdb-python + - https://pypi.org/project/influxdb/ + + To some extent, you can use the v2 client with InfluxDB v1.8+: + - https://github.com/influxdata/influxdb-client-python#influxdb-18-api-compatibility + """ + + return InfluxDBClient(self.get_container_host_ip(), self.get_exposed_port(self.container_port), **client_kwargs) + + def start(self) -> "InfluxDb1Container": + """ + Overridden for better typing reason + """ + return super().start() diff --git a/modules/influxdb/testcontainers/influxdb2/__init__.py b/modules/influxdb/testcontainers/influxdb2/__init__.py new file mode 100644 index 000000000..bd33d7fc4 --- /dev/null +++ b/modules/influxdb/testcontainers/influxdb2/__init__.py @@ -0,0 +1,106 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from os import getenv +from typing import Optional + +from influxdb_client import InfluxDBClient, Organization + +from testcontainers.influxdb import InfluxDbContainer + + +class InfluxDb2Container(InfluxDbContainer): + """ + Docker container for InfluxDB 2.x. + Official Docker images for InfluxDB are hosted at https://hub.docker.com/_/influxdb/. + + Example: + + .. doctest:: + + >>> from testcontainers.influxdb2 import InfluxDb2Container + + >>> with InfluxDb2Container() as influxdb2: + ... version = influxdb2.get_version() + """ + + def __init__( + self, + image: str = "influxdb:latest", + # in the container, the default port for influxdb is often 8086 and not likely to change + container_port: int = 8086, + # specifies the port on the host machine where influxdb is exposed; a random available port otherwise + host_port: Optional[int] = None, + # parameters used by the InfluxDSB 2.x Docker container when spawned in setup mode + # (which is likely what you want). In setup mode, init_mode should be "setup" and all + # the other parameters should be set (via this constructor or their respective + # environment variables); retention does not need to be explicitely set. + init_mode: Optional[str] = None, + admin_token: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + org_name: Optional[str] = None, + bucket: Optional[str] = None, + retention: Optional[str] = None, + **docker_client_kw, + ): + super().__init__(image, container_port, host_port, **docker_client_kw) + + configuration = { + "DOCKER_INFLUXDB_INIT_MODE": init_mode, + "DOCKER_INFLUXDB_INIT_ADMIN_TOKEN": admin_token, + "DOCKER_INFLUXDB_INIT_USERNAME": username, + "DOCKER_INFLUXDB_INIT_PASSWORD": password, + "DOCKER_INFLUXDB_INIT_ORG": org_name, + "DOCKER_INFLUXDB_INIT_BUCKET": bucket, + "DOCKER_INFLUXDB_INIT_RETENTION": retention, + } + for env_key, constructor_param in configuration.items(): + env_value = constructor_param or getenv(env_key) + if env_value: + self.with_env(env_key, env_value) + + def start(self) -> "InfluxDb2Container": + """ + Overridden for better typing reason + """ + return super().start() + + def get_client( + self, token: Optional[str] = None, org_name: Optional[str] = None, **influxdb_client_kwargs + ) -> tuple[InfluxDBClient, Organization]: + """ + Returns an instance of the influxdb client with the associated test organization created + when the container is spawn; for InfluxDB 2.x versions. + - https://github.com/influxdata/influxdb-client-python + - https://pypi.org/project/influxdb-client/ + + This InfluxDB client requires to specify the organization when using most of the API's endpoints, + as an Organisation instance rather than its name or id (deprecated). As a convenience, this + client getter can also retrieve and return the organization instance along with the client. + Otherwise, None is returned in place of the organization instance. + + This organization is created when spawning the container in setup mode (which is likely what you + want) by giving its name to the 'org_name' parameter constructor. + """ + + influxclient = InfluxDBClient(self.get_url(), token=token, **influxdb_client_kwargs) + + if org_name is None: + return influxclient, None + + orgs = influxclient.organizations_api().find_organizations(org=org_name) + if len(orgs) == 0: + raise ValueError(f"Could not retrieved the Organization corresponding to name '{org_name}'") + + return influxclient, orgs[0] diff --git a/tests/__init__.py b/modules/influxdb/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to modules/influxdb/tests/__init__.py diff --git a/modules/influxdb/tests/test_influxdb.py b/modules/influxdb/tests/test_influxdb.py new file mode 100644 index 000000000..62144a3c3 --- /dev/null +++ b/modules/influxdb/tests/test_influxdb.py @@ -0,0 +1,138 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from datetime import datetime +from typing import Type + +from influxdb.resultset import ResultSet +from influxdb_client import Bucket +from influxdb_client.client.write_api import SYNCHRONOUS +from pytest import mark + +from testcontainers.influxdb import InfluxDbContainer +from testcontainers.influxdb1 import InfluxDb1Container +from testcontainers.influxdb2 import InfluxDb2Container + + +@mark.parametrize( + ["image", "influxdb_container_class", "exposed_port"], + [ + ("influxdb:2.7", InfluxDb1Container, 8086), + ("influxdb:1.8", InfluxDb2Container, 8086), + ], +) +def test_influxdbcontainer_get_url(image: str, influxdb_container_class: Type[InfluxDbContainer], exposed_port: int): + with influxdb_container_class(image, host_port=exposed_port) as influxdb_container: + connection_url = influxdb_container.get_url() + assert str(exposed_port) in connection_url + + +@mark.parametrize( + ["image", "influxdb_container_class", "expected_version"], + [ + ("influxdb:1.8", InfluxDb1Container, "1.8.10"), + ("influxdb:1.8.10", InfluxDb1Container, "1.8.10"), + ("influxdb:2.7.4", InfluxDb2Container, "v2.7.4"), + ("influxdb:2.7", InfluxDb2Container, "v2.7"), + ], +) +def test_influxdbcontainer_get_influxdb_version( + image: str, influxdb_container_class: Type[InfluxDbContainer], expected_version: str +): + with influxdb_container_class(image) as influxdb_container: + assert influxdb_container.get_influxdb_version().startswith(expected_version) + + +def test_influxdb1container_get_client(): + """ + This is a test example showing how you could use testcontainers/influxdb for InfluxDB 1.x versions + """ + with InfluxDb1Container("influxdb:1.8") as influxdb1_container: + influxdb1_client = influxdb1_container.get_client() + databases = influxdb1_client.get_list_database() + assert len(databases) == 0, "the InfluxDB container starts with no database at all" + + # creates a database and inserts some datapoints + influxdb1_client.create_database("testcontainers") + databases = influxdb1_client.get_list_database() + assert len(databases) == 1, "the InfluxDB container now contains one database" + assert databases[0] == {"name": "testcontainers"} + + influxdb1_client.write_points( + [ + {"measurement": "influxdbcontainer", "time": "1978-11-30T09:30:00Z", "fields": {"ratio": 0.42}}, + {"measurement": "influxdbcontainer", "time": "1978-12-25T10:30:00Z", "fields": {"ratio": 0.55}}, + ], + database="testcontainers", + ) + + # retrieves the inserted datapoints + datapoints_set: ResultSet = influxdb1_client.query( + "select ratio from influxdbcontainer;", database="testcontainers" + ) + datapoints = list(datapoints_set.get_points()) + assert len(datapoints) == 2, "2 datapoints are retrieved" + + datapoint = datapoints[0] + assert datapoint["time"] == "1978-11-30T09:30:00Z" + assert datapoint["ratio"] == 0.42 + + datapoint = datapoints[1] + assert datapoint["time"] == "1978-12-25T10:30:00Z" + assert datapoint["ratio"] == 0.55 + + +def test_influxdb2container_get_client(): + """ + This is a test example showing how you could use testcontainers/influxdb for InfluxDB 2.x versions with the Flux query language + """ + with InfluxDb2Container( + "influxdb:2.7", + init_mode="setup", + username="root", + password="secret-password", + org_name="testcontainers-org", + bucket="my-init-bucket", + admin_token="secret-token", + ) as influxdb2_container: + influxdb2_client, test_org = influxdb2_container.get_client(token="secret-token", org_name="testcontainers-org") + assert influxdb2_client.ping(), "the client can connect to the InfluxDB instance" + + # ensures that the bucket does not exist yet + buckets_api = influxdb2_client.buckets_api() + bucket: Bucket = buckets_api.find_bucket_by_name("testcontainers") + assert bucket is None, "the test bucket does not exist yet" + + # creates a test bucket and insert a point + buckets_api.create_bucket(bucket_name="testcontainers", org=test_org) + bucket: Bucket = buckets_api.find_bucket_by_name("testcontainers") + assert bucket.name == "testcontainers", "the test bucket now exists" + + write_api = influxdb2_client.write_api(write_options=SYNCHRONOUS) + write_api.write( + "testcontainers", + "testcontainers-org", + [ + {"measurement": "influxdbcontainer", "time": "1978-11-30T09:30:00Z", "fields": {"ratio": 0.42}}, + {"measurement": "influxdbcontainer", "time": "1978-12-25T10:30:00Z", "fields": {"ratio": 0.55}}, + ], + ) + + # retrieves the inserted datapoints + query_api = influxdb2_client.query_api() + tables = query_api.query('from(bucket: "testcontainers") |> range(start: 1978-11-01T22:00:00Z)', org=test_org) + results = tables.to_values(["_measurement", "_field", "_time", "_value"]) + + assert len(results) == 2, "2 datapoints were retrieved" + assert results[0] == ["influxdbcontainer", "ratio", datetime.fromisoformat("1978-11-30T09:30:00+00:00"), 0.42] + assert results[1] == ["influxdbcontainer", "ratio", datetime.fromisoformat("1978-12-25T10:30:00+00:00"), 0.55] diff --git a/modules/k3s/README.rst b/modules/k3s/README.rst new file mode 100644 index 000000000..11f7adc05 --- /dev/null +++ b/modules/k3s/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.k3s.K3SContainer +.. title:: testcontainers.k3s.K3SContainer diff --git a/modules/k3s/testcontainers/k3s/__init__.py b/modules/k3s/testcontainers/k3s/__init__.py new file mode 100644 index 000000000..6e5354175 --- /dev/null +++ b/modules/k3s/testcontainers/k3s/__init__.py @@ -0,0 +1,70 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from testcontainers.core.config import testcontainers_config +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class K3SContainer(DockerContainer): + """ + K3S container. + + Example: + + .. doctest:: + + >>> import yaml + >>> from testcontainers.k3s import K3SContainer + >>> from kubernetes import client, config + + >>> with K3SContainer() as k3s: + ... config.load_kube_config_from_dict(yaml.safe_load(k3s.config_yaml())) + ... pod = client.CoreV1Api().list_pod_for_all_namespaces(limit=1) + ... assert len(pod.items) > 0, "Unable to get running nodes from k3s cluster" + """ + + KUBE_SECURE_PORT = 6443 + RANCHER_WEBHOOK_PORT = 8443 + + def __init__(self, image="rancher/k3s:latest", enable_cgroup_mount=True, **kwargs) -> None: + super().__init__(image, **kwargs) + self.with_exposed_ports(self.KUBE_SECURE_PORT, self.RANCHER_WEBHOOK_PORT) + self.with_env("K3S_URL", f"https://{self.get_container_host_ip()}:{self.KUBE_SECURE_PORT}") + self.with_command("server --disable traefik --tls-san=" + self.get_container_host_ip()) + self.with_kwargs(privileged=True, tmpfs={"/run": "", "/var/run": ""}) + if enable_cgroup_mount: + self.with_volume_mapping("/sys/fs/cgroup", "/sys/fs/cgroup", "rw") + else: + logging.warning("'enable_cgroup_mount' is experimental, see testcontainers/testcontainers-python#591)") + + def _connect(self) -> None: + wait_for_logs(self, predicate="Node controller sync successful", timeout=testcontainers_config.timeout) + + def start(self) -> "K3SContainer": + super().start() + self._connect() + return self + + def config_yaml(self) -> str: + """This function returns the kubernetes config yaml which can be used + to initialise k8s client + """ + execution = self.get_wrapped_container().exec_run(["cat", "/etc/rancher/k3s/k3s.yaml"]) + config_yaml = execution.output.decode("utf-8").replace( + f"https://127.0.0.1:{self.KUBE_SECURE_PORT}", + f"https://{self.get_container_host_ip()}:" f"{self.get_exposed_port(self.KUBE_SECURE_PORT)}", + ) + return config_yaml diff --git a/modules/k3s/tests/test_k3s.py b/modules/k3s/tests/test_k3s.py new file mode 100644 index 000000000..edff1c6d8 --- /dev/null +++ b/modules/k3s/tests/test_k3s.py @@ -0,0 +1,12 @@ +# The versions below were the current supported versions at time of writing (2022-08-11) +import yaml +from kubernetes import client, config + +from testcontainers.k3s import K3SContainer + + +def test_docker_run_k3s(): + with K3SContainer() as k3s: + config.load_kube_config_from_dict(yaml.safe_load(k3s.config_yaml())) + pod = client.CoreV1Api().list_pod_for_all_namespaces(limit=1) + assert len(pod.items) > 0, "Unable to get running nodes from k3s cluster" diff --git a/modules/kafka/README.rst b/modules/kafka/README.rst new file mode 100644 index 000000000..a54107a02 --- /dev/null +++ b/modules/kafka/README.rst @@ -0,0 +1,3 @@ +.. autoclass:: testcontainers.kafka.KafkaContainer +.. title:: testcontainers.kafka.KafkaContainer +.. autoclass:: testcontainers.kafka.RedpandaContainer diff --git a/modules/kafka/testcontainers/kafka/__init__.py b/modules/kafka/testcontainers/kafka/__init__.py new file mode 100644 index 000000000..ccd7f5b77 --- /dev/null +++ b/modules/kafka/testcontainers/kafka/__init__.py @@ -0,0 +1,192 @@ +import tarfile +import time +from dataclasses import dataclass, field +from io import BytesIO +from os import environ +from textwrap import dedent + +from typing_extensions import Self + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.version import ComparableVersion +from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.kafka._redpanda import RedpandaContainer + +__all__ = [ + "KafkaContainer", + "RedpandaContainer", + "kafka_config", +] +LIMIT_BROKER_ENV_VAR = "TC_KAFKA_LIMIT_BROKER_TO_FIRST_HOST" + + +@dataclass +class _KafkaConfig: + limit_broker_to_first_host: bool = field(default_factory=lambda: environ.get(LIMIT_BROKER_ENV_VAR) == "true") + """ + This option is useful for a setup with a network, + see testcontainers/testcontainers-python#637 for more details + """ + + +kafka_config = _KafkaConfig() + + +class KafkaContainer(DockerContainer): + """ + Kafka container. + + Example: + + .. doctest:: + + >>> from testcontainers.kafka import KafkaContainer + + >>> with KafkaContainer() as kafka: + ... connection = kafka.get_bootstrap_server() + + # Using KRaft protocol + >>> with KafkaContainer().with_kraft() as kafka: + ... connection = kafka.get_bootstrap_server() + """ + + TC_START_SCRIPT = "/tc-start.sh" + MIN_KRAFT_TAG = "7.0.0" + + def __init__(self, image: str = "confluentinc/cp-kafka:7.6.0", port: int = 9093, **kwargs) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.kraft_enabled = False + self.wait_for = r".*\[KafkaServer id=\d+\] started.*" + self.boot_command = "" + self.cluster_id = "MkU3OEVBNTcwNTJENDM2Qk" + self.listeners = f"PLAINTEXT://0.0.0.0:{self.port},BROKER://0.0.0.0:9092" + self.security_protocol_map = "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT" + + self.with_exposed_ports(self.port) + self.with_env("KAFKA_LISTENERS", self.listeners) + self.with_env("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", self.security_protocol_map) + self.with_env("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER") + + self.with_env("KAFKA_BROKER_ID", "1") + self.with_env("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", "1") + self.with_env("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", "1") + self.with_env("KAFKA_LOG_FLUSH_INTERVAL_MESSAGES", "10000000") + self.with_env("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0") + + def with_kraft(self) -> Self: + self._verify_min_kraft_version() + self.kraft_enabled = True + return self + + def _verify_min_kraft_version(self): + actual_version = self.image.split(":")[-1] + + if ComparableVersion(actual_version) < self.MIN_KRAFT_TAG: + raise ValueError( + f"Provided Confluent Platform's version {actual_version} " + f"is not supported in Kraft mode" + f" (must be {self.MIN_KRAFT_TAG} or above)" + ) + + def with_cluster_id(self, cluster_id: str) -> Self: + self.cluster_id = cluster_id + return self + + def configure(self): + if self.kraft_enabled: + self._configure_kraft() + else: + self._configure_zookeeper() + + def _configure_kraft(self) -> None: + self.wait_for = r".*Kafka Server started.*" + + self.with_env("CLUSTER_ID", self.cluster_id) + self.with_env("KAFKA_NODE_ID", 1) + self.with_env( + "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", + f"{self.security_protocol_map},CONTROLLER:PLAINTEXT", + ) + self.with_env( + "KAFKA_LISTENERS", + f"{self.listeners},CONTROLLER://0.0.0.0:9094", + ) + self.with_env("KAFKA_PROCESS_ROLES", "broker,controller") + + network_alias = self._get_network_alias() + controller_quorum_voters = f"1@{network_alias}:9094" + self.with_env("KAFKA_CONTROLLER_QUORUM_VOTERS", controller_quorum_voters) + self.with_env("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER") + + self.boot_command = f""" + sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure + echo 'kafka-storage format --ignore-formatted -t {self.cluster_id} -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure + """ + + def _get_network_alias(self): + if self._network: + return next( + iter(self._network_aliases or [self._network.name or self._kwargs.get("network", [])]), + None, + ) + + return "localhost" + + def _configure_zookeeper(self) -> None: + self.boot_command = """ + echo 'clientPort=2181' > zookeeper.properties + echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties + echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties + zookeeper-server-start zookeeper.properties & + export KAFKA_ZOOKEEPER_CONNECT='localhost:2181' + """ + + def get_bootstrap_server(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + return f"{host}:{port}" + + def tc_start(self) -> None: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + if kafka_config.limit_broker_to_first_host: + listeners = f"PLAINTEXT://{host}:{port},BROKER://$(hostname -i | cut -d' ' -f1):9092" + else: + listeners = f"PLAINTEXT://{host}:{port},BROKER://$(hostname -i):9092" + data = ( + dedent( + f""" + #!/bin/bash + {self.boot_command} + export KAFKA_ADVERTISED_LISTENERS={listeners} + . /etc/confluent/docker/bash-config + /etc/confluent/docker/configure + /etc/confluent/docker/launch + """ + ) + .strip() + .encode("utf-8") + ) + self.create_file(data, KafkaContainer.TC_START_SCRIPT) + + def start(self, timeout=30) -> "KafkaContainer": + script = KafkaContainer.TC_START_SCRIPT + command = f'sh -c "while [ ! -f {script} ]; do sleep 0.1; done; sh {script}"' + self.configure() + self.with_command(command) + super().start() + self.tc_start() + wait_for_logs(self, self.wait_for, timeout=timeout) + return self + + def create_file(self, content: bytes, path: str) -> None: + with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar: + tarinfo = tarfile.TarInfo(name=path) + tarinfo.size = len(content) + tarinfo.mtime = time.time() + tar.addfile(tarinfo, BytesIO(content)) + archive.seek(0) + self.get_wrapped_container().put_archive("/", archive) diff --git a/modules/kafka/testcontainers/kafka/_redpanda.py b/modules/kafka/testcontainers/kafka/_redpanda.py new file mode 100644 index 000000000..90d02c4cb --- /dev/null +++ b/modules/kafka/testcontainers/kafka/_redpanda.py @@ -0,0 +1,82 @@ +import tarfile +import time +from io import BytesIO +from textwrap import dedent + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class RedpandaContainer(DockerContainer): + """ + Redpanda container. + + Example: + + .. doctest:: + + >>> from testcontainers.kafka import RedpandaContainer + + >>> with RedpandaContainer() as redpanda: + ... connection = redpanda.get_bootstrap_server() + """ + + TC_START_SCRIPT = "/tc-start.sh" + + def __init__( + self, + image: str = "docker.redpanda.com/redpandadata/redpanda:v23.1.13", + **kwargs, + ) -> None: + kwargs["entrypoint"] = "sh" + super().__init__(image, **kwargs) + self.redpanda_port = 9092 + self.schema_registry_port = 8081 + self.with_exposed_ports(self.redpanda_port, self.schema_registry_port) + + def get_bootstrap_server(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.redpanda_port) + return f"{host}:{port}" + + def get_schema_registry_address(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.schema_registry_port) + return f"http://{host}:{port}" + + def tc_start(self) -> None: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.redpanda_port) + + data = ( + dedent( + f""" + #!/bin/bash + /usr/bin/rpk redpanda start --mode dev-container --smp 1 --memory 1G \ + --kafka-addr PLAINTEXT://0.0.0.0:29092,OUTSIDE://0.0.0.0:9092 \ + --advertise-kafka-addr PLAINTEXT://127.0.0.1:29092,OUTSIDE://{host}:{port} + """ + ) + .strip() + .encode("utf-8") + ) + + self.create_file(data, RedpandaContainer.TC_START_SCRIPT) + + def start(self, timeout=10) -> "RedpandaContainer": + script = RedpandaContainer.TC_START_SCRIPT + command = f'-c "while [ ! -f {script} ]; do sleep 0.1; done; sh {script}"' + self.with_command(command) + super().start() + self.tc_start() + wait_for_logs(self, r".*Started Kafka API server.*", timeout=timeout) + return self + + def create_file(self, content: bytes, path: str) -> None: + with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar: + tarinfo = tarfile.TarInfo(name=path) + tarinfo.size = len(content) + tarinfo.mtime = time.time() + tar.addfile(tarinfo, BytesIO(content)) + archive.seek(0) + self.get_wrapped_container().put_archive("/", archive) diff --git a/modules/kafka/tests/test_kafka.py b/modules/kafka/tests/test_kafka.py new file mode 100644 index 000000000..901f3f0c3 --- /dev/null +++ b/modules/kafka/tests/test_kafka.py @@ -0,0 +1,55 @@ +import pytest +from kafka import KafkaAdminClient, KafkaConsumer, KafkaProducer, TopicPartition + +from testcontainers.core.network import Network +from testcontainers.kafka import KafkaContainer, kafka_config + + +def test_kafka_producer_consumer(): + with KafkaContainer() as container: + produce_and_consume_kafka_message(container) + + +def test_kafka_with_kraft_producer_consumer(): + with KafkaContainer().with_kraft() as container: + assert container.kraft_enabled + produce_and_consume_kafka_message(container) + + +def test_kafka_producer_consumer_custom_port(): + with KafkaContainer(port=9888) as container: + assert container.port == 9888 + produce_and_consume_kafka_message(container) + + +def test_kafka_on_networks(monkeypatch: pytest.MonkeyPatch): + """ + this test case comes from testcontainers/testcontainers-python#637 + """ + monkeypatch.setattr(kafka_config, "limit_broker_to_first_host", True) + + with Network() as network: + kafka_ctr = KafkaContainer() + kafka_ctr.with_network(network) + kafka_ctr.with_network_aliases("kafka") + + with kafka_ctr: + print("started") # Will not reach here and timeout + admin_client = KafkaAdminClient(bootstrap_servers=[kafka_ctr.get_bootstrap_server()]) + print(admin_client.describe_cluster()) + + +def produce_and_consume_kafka_message(container): + topic = "test-topic" + bootstrap_server = container.get_bootstrap_server() + + producer = KafkaProducer(bootstrap_servers=[bootstrap_server]) + producer.send(topic, b"verification message") + producer.flush() + producer.close() + + consumer = KafkaConsumer(bootstrap_servers=[bootstrap_server]) + tp = TopicPartition(topic, 0) + consumer.assign([tp]) + consumer.seek_to_beginning() + assert consumer.end_offsets([tp])[tp] == 1, "Expected exactly one test message to be present on test topic !" diff --git a/modules/kafka/tests/test_redpanda.py b/modules/kafka/tests/test_redpanda.py new file mode 100644 index 000000000..7cee9fa8a --- /dev/null +++ b/modules/kafka/tests/test_redpanda.py @@ -0,0 +1,54 @@ +import pytest +from requests import post, get +from json import dumps + +from kafka import KafkaConsumer, KafkaProducer, TopicPartition, KafkaAdminClient +from kafka.admin import NewTopic + +from testcontainers.kafka import RedpandaContainer + + +def test_redpanda_producer_consumer(): + with RedpandaContainer() as container: + produce_and_consume_message(container) + + +@pytest.mark.parametrize("version", ["v23.1.13", "v23.3.10"]) +def test_redpanda_confluent_version(version): + with RedpandaContainer(image=f"docker.redpanda.com/redpandadata/redpanda:{version}") as container: + produce_and_consume_message(container) + + +def test_schema_registry(): + with RedpandaContainer() as container: + address = container.get_schema_registry_address() + subject_name = "test-subject-value" + url = f"{address}/subjects" + + payload = {"schema": dumps({"type": "string"})} + headers = {"Content-Type": "application/vnd.schemaregistry.v1+json"} + create_result = post(f"{url}/{subject_name}/versions", data=dumps(payload), headers=headers) + assert create_result.status_code == 200 + + result = get(url) + assert result.status_code == 200 + assert subject_name in result.json() + + +def produce_and_consume_message(container): + topic = "test-topic" + bootstrap_server = container.get_bootstrap_server() + + admin = KafkaAdminClient(bootstrap_servers=[bootstrap_server]) + admin.create_topics([NewTopic(topic, 1, 1)]) + + producer = KafkaProducer(bootstrap_servers=[bootstrap_server]) + future = producer.send(topic, b"verification message") + future.get(timeout=10) + producer.close() + + consumer = KafkaConsumer(bootstrap_servers=[bootstrap_server]) + tp = TopicPartition(topic, 0) + consumer.assign([tp]) + consumer.seek_to_beginning() + assert consumer.end_offsets([tp])[tp] == 1, "Expected exactly one test message to be present on test topic !" diff --git a/modules/keycloak/README.rst b/modules/keycloak/README.rst new file mode 100644 index 000000000..6eb045f4a --- /dev/null +++ b/modules/keycloak/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.keycloak.KeycloakContainer +.. title:: testcontainers.keycloak.KeycloakContainer diff --git a/modules/keycloak/testcontainers/keycloak/__init__.py b/modules/keycloak/testcontainers/keycloak/__init__.py new file mode 100644 index 000000000..6addf09ab --- /dev/null +++ b/modules/keycloak/testcontainers/keycloak/__init__.py @@ -0,0 +1,112 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from typing import Optional + +import requests + +from keycloak import KeycloakAdmin +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +_DEFAULT_DEV_COMMAND = "start-dev" + + +class KeycloakContainer(DockerContainer): + has_realm_imports = False + + """ + Keycloak container. + + Example: + + .. doctest:: + + >>> from testcontainers.keycloak import KeycloakContainer + + >>> with KeycloakContainer(f"quay.io/keycloak/keycloak:24.0.1") as keycloak: + ... keycloak.get_client().users_count() + 1 + """ + + def __init__( + self, + image="quay.io/keycloak/keycloak:latest", + username: Optional[str] = None, + password: Optional[str] = None, + port: int = 8080, + cmd: Optional[str] = _DEFAULT_DEV_COMMAND, + ) -> None: + super().__init__(image=image) + self.username = username or os.environ.get("KEYCLOAK_ADMIN", "test") + self.password = password or os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "test") + self.port = port + self.with_exposed_ports(self.port) + self.cmd = cmd + + def _configure(self) -> None: + self.with_env("KEYCLOAK_ADMIN", self.username) + self.with_env("KEYCLOAK_ADMIN_PASSWORD", self.password) + # Enable health checks + # see: https://www.keycloak.org/server/health#_relevant_options + self.with_env("KC_HEALTH_ENABLED", "true") + # Start Keycloak in development mode + # see: https://www.keycloak.org/server/configuration#_starting_keycloak_in_development_mode + if self.has_realm_imports: + self.cmd += " --import-realm" + self.with_command(self.cmd) + + def get_url(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + return f"http://{host}:{port}" + + @wait_container_is_ready(requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) + def _readiness_probe(self) -> None: + # Keycloak provides REST API endpoints for health checks: https://www.keycloak.org/server/health + response = requests.get(f"{self.get_url()}/health/ready", timeout=1) + response.raise_for_status() + if _DEFAULT_DEV_COMMAND in self._command: + wait_for_logs(self, "Added user .* to realm .*") + + def start(self) -> "KeycloakContainer": + super().start() + self._readiness_probe() + return self + + def with_realm_import_file(self, realm_import_file: str) -> "KeycloakContainer": + file = os.path.abspath(realm_import_file) + if not os.path.exists(file): + raise FileNotFoundError(f"Realm file {file} does not exist") + self.with_volume_mapping(file, "/opt/keycloak/data/import/realm.json") + self.has_realm_imports = True + return self + + def with_realm_import_folder(self, realm_import_folder: str) -> "KeycloakContainer": + folder = os.path.abspath(realm_import_folder) + if not os.path.exists(folder): + raise FileNotFoundError(f"Realm folder {folder} does not exist") + self.with_volume_mapping(folder, "/opt/keycloak/data/import/") + self.has_realm_imports = True + return self + + def get_client(self, **kwargs) -> KeycloakAdmin: + default_kwargs = { + "server_url": self.get_url(), + "username": self.username, + "password": self.password, + "realm_name": "master", + "verify": True, + } + kwargs = {**default_kwargs, **kwargs} + return KeycloakAdmin(**kwargs) diff --git a/modules/keycloak/tests/test_keycloak.py b/modules/keycloak/tests/test_keycloak.py new file mode 100644 index 000000000..ce54e4674 --- /dev/null +++ b/modules/keycloak/tests/test_keycloak.py @@ -0,0 +1,8 @@ +import pytest +from testcontainers.keycloak import KeycloakContainer + + +@pytest.mark.parametrize("image_version", ["24.0.1", "18.0"]) +def test_docker_run_keycloak(image_version: str): + with KeycloakContainer(f"quay.io/keycloak/keycloak:{image_version}") as keycloak_admin: + assert keycloak_admin.get_client().users_count() == 1 diff --git a/modules/localstack/README.rst b/modules/localstack/README.rst new file mode 100644 index 000000000..66cbf4d3c --- /dev/null +++ b/modules/localstack/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.localstack.LocalStackContainer +.. title:: testcontainers.localstack.LocalStackContainer diff --git a/modules/localstack/testcontainers/localstack/__init__.py b/modules/localstack/testcontainers/localstack/__init__.py new file mode 100644 index 000000000..15cabeab6 --- /dev/null +++ b/modules/localstack/testcontainers/localstack/__init__.py @@ -0,0 +1,91 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import functools as ft +import os +from typing import Any, Optional + +import boto3 + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class LocalStackContainer(DockerContainer): + """ + Localstack container. + + Example: + + .. doctest:: + + >>> from testcontainers.localstack import LocalStackContainer + + >>> with LocalStackContainer(image="localstack/localstack:2.0.1") as localstack: + ... dynamo_client = localstack.get_client("dynamodb") + ... tables = dynamo_client.list_tables() + >>> tables + {'TableNames': [], ...} + """ + + def __init__( + self, + image: str = "localstack/localstack:2.0.1", + edge_port: int = 4566, + region_name: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self.edge_port = edge_port + self.region_name = region_name or os.environ.get("AWS_DEFAULT_REGION", "us-west-1") + self.with_exposed_ports(self.edge_port) + self.with_env("AWS_DEFAULT_REGION", self.region_name) + self.with_env("AWS_ACCESS_KEY_ID", "testcontainers-localstack") + self.with_env("AWS_SECRET_ACCESS_KEY", "testcontainers-localstack") + + def with_services(self, *services) -> "LocalStackContainer": + """ + Restrict what services to run. By default all localstack services are launched. + + Args: + services: Sequency of services to launch. + + Returns: + self: Container to allow chaining of 'with_*' calls. + """ + return self.with_env("SERVICES", ",".join(services)) + + def get_url(self) -> str: + """ + Use this to call localstack instead of real AWS services. + ex: boto3.client('lambda', endpoint_url=localstack.get_url()) + :return: the endpoint where localstack is reachable. + """ + host = self.get_container_host_ip() + port = self.get_exposed_port(self.edge_port) + return f"http://{host}:{port}" + + @ft.wraps(boto3.client) + def get_client(self, name, **kwargs) -> Any: + kwargs_ = { + "endpoint_url": self.get_url(), + "region_name": self.region_name, + "aws_access_key_id": "testcontainers-localstack", + "aws_secret_access_key": "testcontainers-localstack", + } + kwargs_.update(kwargs) + return boto3.client(name, **kwargs_) + + def start(self, timeout: float = 60) -> "LocalStackContainer": + super().start() + wait_for_logs(self, r"Ready\.\n", timeout=timeout) + return self diff --git a/modules/localstack/tests/test_localstack.py b/modules/localstack/tests/test_localstack.py new file mode 100644 index 000000000..6801aefdb --- /dev/null +++ b/modules/localstack/tests/test_localstack.py @@ -0,0 +1,24 @@ +import json +import urllib + +from testcontainers.localstack import LocalStackContainer + + +def test_docker_run_localstack(): + with LocalStackContainer() as localstack: + resp = urllib.request.urlopen(f"{localstack.get_url()}/health") + services = json.loads(resp.read().decode())["services"] + + # Check that all services are running + assert all(value == "available" for value in services.values()) + # Check that some of the services keys + assert all(test_service in services for test_service in ["dynamodb", "sns", "sqs"]) + + +def test_localstack_boto3(): + from testcontainers.localstack import LocalStackContainer + + with LocalStackContainer(image="localstack/localstack:2.0.1") as localstack: + dynamo_client = localstack.get_client("dynamodb") + tables = dynamo_client.list_tables() + assert tables["TableNames"] == [] diff --git a/modules/mailpit/README.rst b/modules/mailpit/README.rst new file mode 100644 index 000000000..f2c238a37 --- /dev/null +++ b/modules/mailpit/README.rst @@ -0,0 +1,3 @@ +.. autoclass:: testcontainers.mailpit.MailpitUser +.. autoclass:: testcontainers.mailpit.MailpitContainer +.. title:: testcontainers.mailpit.MailpitContainer diff --git a/modules/mailpit/testcontainers/mailpit/__init__.py b/modules/mailpit/testcontainers/mailpit/__init__.py new file mode 100644 index 000000000..466755949 --- /dev/null +++ b/modules/mailpit/testcontainers/mailpit/__init__.py @@ -0,0 +1,249 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import os +import tempfile +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, NamedTuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import ( + NoEncryption, +) +from cryptography.x509.oid import NameOID + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +if TYPE_CHECKING: + from typing_extensions import Self + + +class MailpitUser(NamedTuple): + """Mailpit user for authentication + + Helper class to define a user for Mailpit authentication. + + This is just a named tuple for username and password. + + + Example: + + .. doctest:: + + >>> from testcontainers.mailpit import MailpitUser + + >>> users = [ + ... MailpitUser("jane", "secret"), + ... MailpitUser("ron", "pass2"), + ... ] + + >>> for user in users: + ... print(user.username, user.password) + ... + jane secret + ron pass2 + + >>> username, password = users[0] + + >>> print(username, password) + jane secret + """ + + username: str + password: str + + +class MailpitContainer(DockerContainer): + """ + Test container for Mailpit. The example below spins up a Mailpit server + + Default configuration supports SMTP with STARTTLS and allows login with any + user/password. + + Options: + + * ``require_tls = True`` forces the use of SSL + * ``users = [MailpitUser("jane", "secret"), MailpitUser("ron", "pass2")]`` \ + only allows login with ``jane:secret`` or ``ron:pass2`` + + Simple example: + + .. doctest:: + + >>> import smtplib + + >>> from testcontainers.mailpit import MailpitContainer + + >>> with MailpitContainer() as mailpit_container: + ... host_ip = mailpit_container.get_container_host_ip() + ... host_port = mailpit_container.get_exposed_smtp_port() + ... server = smtplib.SMTP( + ... mailpit_container.get_container_host_ip(), + ... mailpit_container.get_exposed_smtp_port(), + ... ) + ... code, _ = server.login("any", "auth") + ... assert code == 235 # authentication successful + ... # use server.sendmail(...) to send emails + + Example with auth and forced TLS: + + .. doctest:: + + >>> import smtplib + + >>> from testcontainers.mailpit import MailpitContainer, MailpitUser + + >>> users = [MailpitUser("jane", "secret"), MailpitUser("ron", "pass2")] + + >>> with MailpitContainer(users=users, require_tls=True) as mailpit_container: + ... host_ip = mailpit_container.get_container_host_ip() + ... host_port = mailpit_container.get_exposed_smtp_port() + ... server = smtplib.SMTP_SSL( + ... mailpit_container.get_container_host_ip(), + ... mailpit_container.get_exposed_smtp_port(), + ... ) + ... code, _ = server.login("jane", "secret") + ... assert code == 235 # authentication successful + ... # use server.sendmail(...) to send emails + """ + + def __init__( + self, + image: str = "axllent/mailpit", + *, + smtp_port: int = 1025, + ui_port: int = 8025, + users: list[MailpitUser] | None = None, + require_tls: bool = False, + **kwargs: Any, + ) -> None: + super().__init__(image=image, **kwargs) + self.smtp_port = smtp_port + self.ui_port = ui_port + + self.users = users if users is not None else [] + self.auth_accept_any = int(len(self.users) == 0) + + self.require_tls = int(require_tls) + self.tls_key, self.tls_cert = _generate_tls_certificates() + with tempfile.NamedTemporaryFile(delete=False) as tls_key_file: + tls_key_file.write(self.tls_key) + self.tls_key_file = tls_key_file.name + + with tempfile.NamedTemporaryFile(delete=False) as tls_cert_file: + tls_cert_file.write(self.tls_cert) + self.tls_cert_file = tls_cert_file.name + + @property + def _users_conf(self) -> str: + """Mailpit user configuration string + + "user:password user2:pass2 ...] + """ + return " ".join(f"{user.username}:{user.password}" for user in self.users) + + def _configure(self) -> None: + if self.users: + self.with_env("MP_SMTP_AUTH", self._users_conf) + self.with_env("MP_SMTP_AUTH_ACCEPT_ANY", str(self.auth_accept_any)) + + self.with_env("MP_SMTP_REQUIRE_TLS", str(self.require_tls)) + + self.with_volume_mapping(self.tls_cert_file, "/cert.pem") + self.with_volume_mapping(self.tls_key_file, "/key.pem") + self.with_env("MP_SMTP_TLS_CERT", "/cert.pem") + self.with_env("MP_SMTP_TLS_KEY", "/key.pem") + + self.with_exposed_ports(self.smtp_port, self.ui_port) + + def start(self) -> Self: + super().start() + wait_for_logs(self, ".*accessible via.*") + return self + + def stop(self, *args: Any, **kwargs: Any) -> None: + super().stop(*args, **kwargs) + os.remove(self.tls_key_file) + os.remove(self.tls_cert_file) + + def get_exposed_smtp_port(self) -> int: + return int(self.get_exposed_port(self.smtp_port)) + + def get_exposed_ui_port(self) -> int: + return int(self.get_exposed_port(self.ui_port)) + + def get_base_api_url(self) -> str: + return f"http://{self.get_container_host_ip()}:{self.get_exposed_ui_port()}" + + +class _TLSCertificates(NamedTuple): + private_key: bytes + certificate: bytes + + +def _generate_tls_certificates() -> _TLSCertificates: + """Generate self-signed TLS certificates as bytes""" + private_key = _generate_private_key() + certificate = _generate_self_signed_certificate(private_key) + + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + certificate_bytes = certificate.public_bytes(serialization.Encoding.PEM) + + return _TLSCertificates(private_key_bytes, certificate_bytes) + + +def _generate_private_key() -> rsa.RSAPrivateKey: + """Generate RSA private key""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + + +def _generate_self_signed_certificate( + private_key: rsa.RSAPrivateKey, +) -> x509.Certificate: + """Generate self-signed certificate with RSA private key""" + domain = "mydomain.com" + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "The Post Office"), + x509.NameAttribute(NameOID.COMMON_NAME, domain), + ] + ) + + return ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=3650)) # 10 years + .add_extension( + x509.SubjectAlternativeName([x509.DNSName(domain)]), + critical=False, + ) + .sign(private_key, hashes.SHA256()) + ) diff --git a/tests/test_core/__init__.py b/modules/mailpit/testcontainers/mailpit/py.typed similarity index 100% rename from tests/test_core/__init__.py rename to modules/mailpit/testcontainers/mailpit/py.typed diff --git a/modules/mailpit/tests/test_mailpit.py b/modules/mailpit/tests/test_mailpit.py new file mode 100644 index 000000000..53247f49d --- /dev/null +++ b/modules/mailpit/tests/test_mailpit.py @@ -0,0 +1,124 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +import pytest + +from testcontainers.mailpit import MailpitContainer, MailpitUser + +_sender = "from@example.com" +_receivers = ["to@example.com"] +_msg = MIMEMultipart("mixed") +_msg["From"] = _sender +_msg["To"] = ", ".join(_receivers) +_msg["Subject"] = "test" +_msg.attach(MIMEText("test", "plain")) +_sendmail_args = (_sender, _receivers, _msg.as_string()) + + +def test_mailpit_basic(): + config = MailpitContainer() + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login("any", "auth") + server.sendmail(*_sendmail_args) + + +def test_mailpit_starttls(): + config = MailpitContainer() + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.starttls() + server.login("any", "auth") + server.sendmail(*_sendmail_args) + + +def test_mailpit_force_tls(): + config = MailpitContainer(require_tls=True) + with config as mailpit: + server = smtplib.SMTP_SSL( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login("any", "auth") + server.sendmail(*_sendmail_args) + + +def test_mailpit_basic_with_users_pass_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users) + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login(mailpit.users[0].username, mailpit.users[0].password) + server.sendmail(*_sendmail_args) + + +def test_mailpit_basic_with_users_fail_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users) + with pytest.raises(smtplib.SMTPAuthenticationError): + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login("not", "good") + + +def test_mailpit_starttls_with_users_pass_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users) + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.starttls() + server.login(mailpit.users[0].username, mailpit.users[0].password) + server.sendmail(*_sendmail_args) + + +def test_mailpit_starttls_with_users_fail_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users) + with pytest.raises(smtplib.SMTPAuthenticationError): + with config as mailpit: + server = smtplib.SMTP( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.starttls() + server.login("not", "good") + + +def test_mailpit_force_tls_with_users_pass_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users, require_tls=True) + with config as mailpit: + server = smtplib.SMTP_SSL( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login(mailpit.users[0].username, mailpit.users[0].password) + server.sendmail(*_sendmail_args) + + +def test_mailpit_force_tls_with_users_fail_auth(): + users = [MailpitUser("user", "password")] + config = MailpitContainer(users=users, require_tls=True) + with pytest.raises(smtplib.SMTPAuthenticationError): + with config as mailpit: + server = smtplib.SMTP_SSL( + mailpit.get_container_host_ip(), + mailpit.get_exposed_smtp_port(), + ) + server.login("not", "good") diff --git a/modules/memcached/README.rst b/modules/memcached/README.rst new file mode 100644 index 000000000..83cd9c82e --- /dev/null +++ b/modules/memcached/README.rst @@ -0,0 +1 @@ +.. autoclass:: testcontainers.memcached.MemcachedContainer diff --git a/modules/memcached/setup.py b/modules/memcached/setup.py new file mode 100644 index 000000000..0f4dd3234 --- /dev/null +++ b/modules/memcached/setup.py @@ -0,0 +1,17 @@ +from setuptools import find_namespace_packages, setup + +description = "Memcached component of testcontainers-python." + +setup( + name="testcontainers-memcached", + version="0.0.1rc1", + packages=find_namespace_packages(), + description=description, + long_description=description, + long_description_content_type="text/x-rst", + url="https://github.com/testcontainers/testcontainers-python", + install_requires=[ + "testcontainers-core", + ], + python_requires=">=3.7", +) diff --git a/modules/memcached/testcontainers/memcached/__init__.py b/modules/memcached/testcontainers/memcached/__init__.py new file mode 100644 index 000000000..6da409e06 --- /dev/null +++ b/modules/memcached/testcontainers/memcached/__init__.py @@ -0,0 +1,59 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import socket + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class MemcachedNotReady(Exception): + pass + + +class MemcachedContainer(DockerContainer): + """ + Test container for Memcached. The example below spins up a Memcached server + + Example: + + .. doctest:: + + >>> from testcontainers.memcached import MemcachedContainer + + >>> with MemcachedContainer() as memcached_container: + ... host, port = memcached_container.get_host_and_port() + """ + + def __init__(self, image="memcached:1", port_to_expose=11211, **kwargs): + super().__init__(image, **kwargs) + self.port_to_expose = port_to_expose + self.with_exposed_ports(port_to_expose) + + @wait_container_is_ready(MemcachedNotReady) + def _connect(self): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + host = self.get_container_host_ip() + port = int(self.get_exposed_port(self.port_to_expose)) + s.connect((host, port)) + s.sendall(b"stats\n\r") + data = s.recv(1024) + if len(data) == 0: + raise MemcachedNotReady("Memcached not ready yet") + + def start(self): + super().start() + self._connect() + return self + + def get_host_and_port(self): + return self.get_container_host_ip(), int(self.get_exposed_port(self.port_to_expose)) diff --git a/modules/memcached/tests/test_memcached.py b/modules/memcached/tests/test_memcached.py new file mode 100644 index 000000000..853ede40a --- /dev/null +++ b/modules/memcached/tests/test_memcached.py @@ -0,0 +1,28 @@ +import socket + +from testcontainers.memcached import MemcachedContainer + +import pytest + + +def test_memcached_host_and_exposed_port(): + with MemcachedContainer("memcached:1.6-alpine") as memcached: + host, port = memcached.get_host_and_port() + assert host == "localhost" + assert port != 11211 + + +@pytest.mark.parametrize("image", ["memcached:1.6-bookworm", "memcached:1.6-alpine"]) +def test_memcached_can_connect_and_retrieve_data(image): + with MemcachedContainer(image) as memcached: + host, port = memcached.get_host_and_port() + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + s.sendall(b"stats\n\r") + data = s.recv(1024) + assert len(data) > 0, "We should have received some data from memcached" + + pid_stat, uptime_stat, *_ = data.decode().split("\r\n") + + assert pid_stat.startswith("STAT pid") + assert uptime_stat.startswith("STAT uptime") diff --git a/modules/milvus/README.rst b/modules/milvus/README.rst new file mode 100644 index 000000000..f823d7fe9 --- /dev/null +++ b/modules/milvus/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.milvus.MilvusContainer +.. title:: testcontainers.milvus.MilvusContainer diff --git a/modules/milvus/testcontainers/milvus/__init__.py b/modules/milvus/testcontainers/milvus/__init__.py new file mode 100644 index 000000000..39a1403e9 --- /dev/null +++ b/modules/milvus/testcontainers/milvus/__init__.py @@ -0,0 +1,85 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import requests + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class MilvusContainer(DockerContainer): + """ + Milvus database container. + + Read mode about Milvus: https://milvus.io/docs + + Example: + + The example spins up a Milvus database and connects to it client using MilvisClient. + + .. doctest:: + + >>> from testcontainers.milvus import MilvusContainer + >>> with MilvusContainer("milvusdb/milvus:v2.4.4") as milvus_container: + ... milvus_container.get_exposed_port(milvus_container.port) in milvus_container.get_connection_url() + True + """ + + def __init__( + self, + image: str = "milvusdb/milvus:latest", + port: int = 19530, + **kwargs, + ) -> None: + super().__init__(image=image, **kwargs) + self.port = port + self.healthcheck_port = 9091 + self.with_exposed_ports(self.port, self.healthcheck_port) + self.cmd = "milvus run standalone" + + envs = {"ETCD_USE_EMBED": "true", "ETCD_DATA_DIR": "/var/lib/milvus/etcd", "COMMON_STORAGETYPE": "local"} + + for env, value in envs.items(): + self.with_env(env, value) + + def get_connection_url(self) -> str: + ip = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + return f"http://{ip}:{port}" + + @wait_container_is_ready() + def _connect(self) -> None: + msg = "Welcome to use Milvus!" + wait_for_logs(self, f".*{msg}.*", c.max_tries, c.sleep_time) + self._healthcheck() + + def _get_healthcheck_url(self) -> str: + ip = self.get_container_host_ip() + port = self.get_exposed_port(self.healthcheck_port) + return f"http://{ip}:{port}" + + @wait_container_is_ready(requests.exceptions.HTTPError) + def _healthcheck(self) -> None: + healthcheck_url = self._get_healthcheck_url() + response = requests.get(f"{healthcheck_url}/healthz", timeout=1) + response.raise_for_status() + + def start(self) -> "MilvusContainer": + """This method starts the Milvus container and runs the healthcheck + to verify that the container is ready to use.""" + self.with_command(self.cmd) + super().start() + self._connect() + self._healthcheck() + return self diff --git a/modules/milvus/tests/test_milvus.py b/modules/milvus/tests/test_milvus.py new file mode 100644 index 000000000..12887a49b --- /dev/null +++ b/modules/milvus/tests/test_milvus.py @@ -0,0 +1,39 @@ +import pytest +from pymilvus import MilvusClient + +from testcontainers.milvus import MilvusContainer + +VERSIONS = ["v2.4.0", "v2.4.4"] + + +class ClientMilvusContainer(MilvusContainer): + def get_client(self, *, dbname: str = "default", token: str = "root:Milvus") -> MilvusClient: + connection_url = self.get_connection_url() + client = MilvusClient(uri=connection_url, dbname=dbname, token=token) + return client + + +@pytest.mark.parametrize("version", VERSIONS) +def test_run_milvus_success(version: str): + image = f"milvusdb/milvus:{version}" + + with MilvusContainer(image=image) as milvus_container: + exposed_port = milvus_container.get_exposed_port(milvus_container.port) + url = milvus_container.get_connection_url() + + assert url and exposed_port in url + + +@pytest.mark.parametrize("version", VERSIONS) +def test_milvus_client_success(version: str): + image = f"milvusdb/milvus:{version}" + test_collection = "test_collection" + + with ClientMilvusContainer(image=image) as milvus_container: + client = milvus_container.get_client() + client.create_collection(test_collection, dimension=2) + collections = client.list_collections() + assert test_collection in collections + + client.drop_collection(test_collection) + assert not client.has_collection(test_collection) diff --git a/modules/minio/README.rst b/modules/minio/README.rst new file mode 100644 index 000000000..409151787 --- /dev/null +++ b/modules/minio/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.minio.MinioContainer +.. title:: testcontainers.minio.MinioContainer diff --git a/modules/minio/testcontainers/minio/__init__.py b/modules/minio/testcontainers/minio/__init__.py new file mode 100644 index 000000000..51a7094e3 --- /dev/null +++ b/modules/minio/testcontainers/minio/__init__.py @@ -0,0 +1,114 @@ +from typing import TYPE_CHECKING + +from requests import ConnectionError, get + +from minio import Minio +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + +if TYPE_CHECKING: + from requests import Response + + +class MinioContainer(DockerContainer): + """ + The example below spins up an Minio container and creates a new bucket in it. + Furthermore, it demonstrates how an object is written to this bucket and + then subsequently retrieved. + The method :code:`get_client` can be used to create a client for the Minio Python API. + The method :code:`get_config` can be used to retrieve the endpoint, access key + and secret key of the container. + + Example: + + .. doctest:: + + >>> import io + >>> from testcontainers.minio import MinioContainer + + >>> with MinioContainer() as minio: + ... client = minio.get_client() + ... client.make_bucket("test") + ... test_content = b"Hello World" + ... write_result = client.put_object( + ... "test", + ... "testfile.txt", + ... io.BytesIO(test_content), + ... length=len(test_content), + ... ) + ... retrieved_content = client.get_object("test", "testfile.txt").data + """ + + def __init__( + self, + image: str = "minio/minio:RELEASE.2022-12-02T19-19-22Z", + port: int = 9000, + access_key: str = "minioadmin", + secret_key: str = "minioadmin", + **kwargs, + ) -> None: + """ + Args: + image: Docker image to use for the MinIO container. + port: Port to expose on the container. + access_key: Access key for client connections. + secret_key: Secret key for client connections. + """ + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.access_key = access_key + self.secret_key = secret_key + + self.with_exposed_ports(self.port) + self.with_env("MINIO_ACCESS_KEY", self.access_key) + self.with_env("MINIO_SECRET_KEY", self.secret_key) + self.with_command(f"server /data --address :{self.port}") + + def get_client(self, **kwargs) -> Minio: + """Returns a Minio client to connect to the container. + + Returns: + Minio: Python Minio Client according to + https://min.io/docs/minio/linux/developers/python/API.html + """ + host_ip = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.port) + return Minio( + f"{host_ip}:{exposed_port}", + access_key=self.access_key, + secret_key=self.secret_key, + secure=False, + **kwargs, + ) + + def get_config(self) -> dict: + """This method returns the configuration of the Minio container, + including the endpoint, access key, and secret key. + + Returns: + dict: {`endpoint`: str, `access_key`: str, `secret_key`: str} + """ + host_ip = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.port) + return { + "endpoint": f"{host_ip}:{exposed_port}", + "access_key": self.access_key, + "secret_key": self.secret_key, + } + + @wait_container_is_ready(ConnectionError) + def _healthcheck(self) -> None: + """This is an internal method used to check if the Minio container + is healthy and ready to receive requests.""" + url = f"http://{self.get_config()['endpoint']}/minio/health/live" + response: Response = get(url) + response.raise_for_status() + + def start(self) -> "MinioContainer": + """This method starts the Minio container and runs the healthcheck + to verify that the container is ready to use.""" + super().start() + self._healthcheck() + return self diff --git a/modules/minio/tests/test_minio.py b/modules/minio/tests/test_minio.py new file mode 100644 index 000000000..99eed4380 --- /dev/null +++ b/modules/minio/tests/test_minio.py @@ -0,0 +1,21 @@ +import io + +from testcontainers.minio import MinioContainer + + +def test_docker_run_minio(): + config = MinioContainer(access_key="test-access", secret_key="test-secret") + with config as minio: + client = minio.get_client() + client.make_bucket("test") + test_content = b"Hello World" + client.put_object( + "test", + "testfile.txt", + io.BytesIO(test_content), + length=len(test_content), + ) + + assert client.get_object("test", "testfile.txt").data == test_content + assert minio.get_config()["access_key"] == config.access_key + assert minio.get_config()["secret_key"] == config.secret_key diff --git a/modules/mongodb/README.rst b/modules/mongodb/README.rst new file mode 100644 index 000000000..37e836406 --- /dev/null +++ b/modules/mongodb/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.mongodb.MongoDbContainer +.. title:: testcontainers.mongodb.MongoDbContainer diff --git a/modules/mongodb/testcontainers/mongodb/__init__.py b/modules/mongodb/testcontainers/mongodb/__init__.py new file mode 100644 index 000000000..4a436b195 --- /dev/null +++ b/modules/mongodb/testcontainers/mongodb/__init__.py @@ -0,0 +1,83 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from typing import Optional + +from pymongo import MongoClient + +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_for_logs + + +class MongoDbContainer(DbContainer): + """ + Mongo document-based database container. + + Example: + + .. doctest:: + + >>> from testcontainers.mongodb import MongoDbContainer + + >>> with MongoDbContainer("mongo:7.0.7") as mongo: + ... db = mongo.get_connection_client().test + ... # Insert a database entry + ... result = db.restaurants.insert_one( + ... { + ... "name": "Vella", + ... "cuisine": "Italian", + ... "restaurant_id": "123456" + ... } + ... ) + ... # Find the restaurant document + ... result = db.restaurants.find_one({"name": "Vella"}) + ... result["restaurant_id"] + '123456' + """ + + def __init__( + self, + image: str = "mongo:latest", + port: int = 27017, + username: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image=image, **kwargs) + self.username = username if username else os.environ.get("MONGO_INITDB_ROOT_USERNAME", "test") + self.password = password if password else os.environ.get("MONGO_INITDB_ROOT_PASSWORD", "test") + self.dbname = dbname if dbname else os.environ.get("MONGO_DB", "test") + self.port = port + self.with_exposed_ports(self.port) + + def _configure(self) -> None: + self.with_env("MONGO_INITDB_ROOT_USERNAME", self.username) + self.with_env("MONGO_INITDB_ROOT_PASSWORD", self.password) + self.with_env("MONGO_DB", self.dbname) + + def get_connection_url(self) -> str: + return self._create_connection_url( + dialect="mongodb", + username=self.username, + password=self.password, + port=self.port, + ) + + def _connect(self) -> None: + wait_for_logs(self, "Waiting for connections") + + def get_connection_client(self) -> MongoClient: + return MongoClient(self.get_connection_url()) diff --git a/modules/mongodb/tests/test_mongodb.py b/modules/mongodb/tests/test_mongodb.py new file mode 100644 index 000000000..9bf3600f2 --- /dev/null +++ b/modules/mongodb/tests/test_mongodb.py @@ -0,0 +1,53 @@ +import pytest +from pymongo import MongoClient +from pymongo.errors import OperationFailure + +from testcontainers.mongodb import MongoDbContainer + + +@pytest.mark.parametrize("version", ["7.0.7", "6.0.14", "5.0.26"]) +def test_docker_run_mongodb(version: str): + with MongoDbContainer(f"mongo:{version}") as mongo: + db = mongo.get_connection_client().test + doc = { + "address": { + "street": "2 Avenue", + "zipcode": "10075", + "building": "1480", + "coord": [-73.9557413, 40.7720266], + }, + "borough": "Manhattan", + "cuisine": "Italian", + "name": "Vella", + "restaurant_id": "41704620", + } + result = db.restaurants.insert_one(doc) + assert result.inserted_id + + cursor = db.restaurants.find({"borough": "Manhattan"}) + assert cursor.next()["restaurant_id"] == doc["restaurant_id"] + + +# This is a feature in the generic DbContainer class +# but it can't be tested on its own +# so is tested in various database modules: +# - mysql / mariadb +# - postgresql +# - sqlserver +# - mongodb +# - db2 +def test_quoted_password(): + user = "root" + password = "p@$%25+0&%rd :/!=?" + quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F" + # driver = "pymongo" + kwargs = { + "username": user, + "password": password, + } + with MongoDbContainer("mongo:7.0.7", **kwargs) as container: + host = container.get_container_host_ip() + port = container.get_exposed_port(27017) + expected_url = f"mongodb://{user}:{quoted_password}@{host}:{port}" + url = container.get_connection_url() + assert url == expected_url diff --git a/modules/mqtt/README.rst b/modules/mqtt/README.rst new file mode 100644 index 000000000..2e088cbbb --- /dev/null +++ b/modules/mqtt/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.mqtt.MosquittoContainer +.. title:: testcontainers.mqtt.MosquittoContainer diff --git a/modules/mqtt/testcontainers/mqtt/__init__.py b/modules/mqtt/testcontainers/mqtt/__init__.py new file mode 100644 index 000000000..1382762ae --- /dev/null +++ b/modules/mqtt/testcontainers/mqtt/__init__.py @@ -0,0 +1,155 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from typing_extensions import Self + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +if TYPE_CHECKING: + from paho.mqtt.client import Client + from paho.mqtt.enums import MQTTErrorCode + + +class MosquittoContainer(DockerContainer): + """ + Specialization of DockerContainer for MQTT broker Mosquitto. + Example: + + .. doctest:: + + >>> from testcontainers.mqtt import MosquittoContainer + + >>> with MosquittoContainer() as mosquitto_broker: + ... mqtt_client = mosquitto_broker.get_client() + """ + + TESTCONTAINERS_CLIENT_ID = "TESTCONTAINERS-CLIENT" + MQTT_PORT = 1883 + CONFIG_FILE = "testcontainers-mosquitto-default-configuration.conf" + + def __init__( + self, + image: str = "eclipse-mosquitto:latest", + # password: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + # self.password = password + # reusable client context: + self.client: Optional["Client"] = None + + @wait_container_is_ready() + def get_client(self) -> "Client": + """ + Creates and connects a client, caching the result in `self.client` + returning that if it exists. + + Connection attempts are retried using `@wait_container_is_ready`. + + Returns: + a client from the paho library + """ + if self.client: + return self.client + client, err = self.new_client() + # 0 is a conventional "success" value in C, which is falsy in python + if err: + # retry, maybe it is not available yet + raise ConnectionError(f"Failed to establish a connection: {err}") + if not client.is_connected(): + raise TimeoutError("The Paho MQTT secondary thread has not connected yet!") + self.client = client + return client + + def new_client(self, **kwargs) -> tuple["Client", "MQTTErrorCode"]: + """ + Get a paho.mqtt client connected to this container. + Check the returned object is_connected() method before use + + Usage of this method is required for versions <2; + versions >=2 will wait for log messages to determine container readiness. + There is no way to pass arguments to new_client in versions <2, + please use an up-to-date version. + + Args: + **kwargs: Keyword arguments passed to `paho.mqtt.client`. + + Returns: + client: MQTT client to connect to the container. + error: an error code or MQTT_ERR_SUCCESS. + """ + try: + from paho.mqtt.client import CallbackAPIVersion, Client + from paho.mqtt.enums import MQTTErrorCode + except ImportError as i: + raise ImportError("'pip install paho-mqtt' required for MosquittoContainer.new_client") from i + + err = MQTTErrorCode.MQTT_ERR_SUCCESS + if self.client is None: + self.client = Client( + client_id=MosquittoContainer.TESTCONTAINERS_CLIENT_ID, + callback_api_version=CallbackAPIVersion.VERSION2, + userdata=self, + **kwargs, + ) + self.client._connect_timeout = 1.0 + + # connect() is a blocking call: + err = self.client.connect(self.get_container_host_ip(), int(self.get_exposed_port(self.MQTT_PORT))) + self.client.loop_start() # launch a thread to call loop() and dequeue the message + + return self.client, err + + def start(self, configfile: Optional[str] = None) -> Self: + # setup container: + self.with_exposed_ports(self.MQTT_PORT) + if configfile is None: + # default config file + configfile = Path(__file__).parent / MosquittoContainer.CONFIG_FILE + self.with_volume_mapping(configfile, "/mosquitto/config/mosquitto.conf") + # if self.password: + # # TODO: add authentication + # pass + + # do container start + super().start() + + self._wait() + return self + + def _wait(self): + if self.image.split(":")[-1].startswith("1"): + import logging + + logging.warning( + "You are using version 1 of eclipse-mosquitto which is not supported for use by this module without paho-mqtt also installed" + ) + self.get_client() + else: + wait_for_logs(self, r"mosquitto version \d+.\d+.\d+ running", timeout=30) + + def stop(self, force=True, delete_volume=True) -> None: + if self.client is not None: + self.client.disconnect() + self.client = None # force recreation of the client object at next start() + super().stop(force, delete_volume) + + def publish_message(self, topic: str, payload: str, timeout: int = 2) -> None: + ret = self.get_client().publish(topic, payload) + ret.wait_for_publish(timeout=timeout) + if not ret.is_published(): + raise RuntimeError(f"Could not publish a message on topic {topic} to Mosquitto broker: {ret}") diff --git a/modules/mqtt/testcontainers/mqtt/testcontainers-mosquitto-default-configuration.conf b/modules/mqtt/testcontainers/mqtt/testcontainers-mosquitto-default-configuration.conf new file mode 100644 index 000000000..13728cec0 --- /dev/null +++ b/modules/mqtt/testcontainers/mqtt/testcontainers-mosquitto-default-configuration.conf @@ -0,0 +1,20 @@ +# see https://mosquitto.org/man/mosquitto-conf-5.html + +protocol mqtt +user root +log_dest stdout +allow_anonymous true + +log_type error +log_type warning +log_type notice +log_type information + +log_timestamp_format %Y-%m-%d %H:%M:%S +persistence true +persistence_location /data/ + +listener 1883 +protocol mqtt + +sys_interval 1 diff --git a/modules/mqtt/tests/test_mosquitto.py b/modules/mqtt/tests/test_mosquitto.py new file mode 100644 index 000000000..63ce7fcd9 --- /dev/null +++ b/modules/mqtt/tests/test_mosquitto.py @@ -0,0 +1,18 @@ +import pytest + +from testcontainers.mqtt import MosquittoContainer + +VERSIONS = ["1.6.15", "2.0.18"] + + +@pytest.mark.parametrize("version", VERSIONS) +def test_mosquitto(version): + with MosquittoContainer(image=f"eclipse-mosquitto:{version}") as container: + external_port = int(container.get_exposed_port(container.MQTT_PORT)) + print(f"listening on port: {external_port}") + + +@pytest.mark.parametrize("version", VERSIONS) +def test_mosquitto_client(version): + with MosquittoContainer(image=f"eclipse-mosquitto:{version}") as container: + container.get_client() diff --git a/modules/mssql/README.rst b/modules/mssql/README.rst new file mode 100644 index 000000000..56368ca71 --- /dev/null +++ b/modules/mssql/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.mssql.SqlServerContainer +.. title:: testcontainers.mssql.SqlServerContainer diff --git a/modules/mssql/testcontainers/mssql/__init__.py b/modules/mssql/testcontainers/mssql/__init__.py new file mode 100644 index 000000000..6cee36813 --- /dev/null +++ b/modules/mssql/testcontainers/mssql/__init__.py @@ -0,0 +1,61 @@ +from os import environ +from typing import Optional + +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class SqlServerContainer(DbContainer): + """ + Microsoft SQL Server database container. + + Example: + + .. doctest:: + + >>> import sqlalchemy + >>> from testcontainers.mssql import SqlServerContainer + + >>> with SqlServerContainer("mcr.microsoft.com/mssql/server:2022-CU12-ubuntu-22.04") as mssql: + ... engine = sqlalchemy.create_engine(mssql.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select @@VERSION")) + """ + + def __init__( + self, + image: str = "mcr.microsoft.com/mssql/server:2019-latest", + username: str = "SA", + password: Optional[str] = None, + port: int = 1433, + dbname: str = "tempdb", + dialect: str = "mssql+pymssql", + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "user", "username") + super().__init__(image, **kwargs) + + self.port = port + self.with_exposed_ports(self.port) + + self.password = password or environ.get("SQLSERVER_PASSWORD", "1Secure*Password1") + self.username = username + self.dbname = dbname + self.dialect = dialect + + def _configure(self) -> None: + self.with_env("SA_PASSWORD", self.password) + self.with_env("SQLSERVER_USER", self.username) + self.with_env("SQLSERVER_DBNAME", self.dbname) + self.with_env("ACCEPT_EULA", "Y") + + @wait_container_is_ready(AssertionError) + def _connect(self) -> None: + status, _ = self.exec(f"/opt/mssql-tools/bin/sqlcmd -U {self.username} -P {self.password} -Q 'SELECT 1'") + assert status == 0, "Cannot run 'SELECT 1': container is not ready" + + def get_connection_url(self) -> str: + return super()._create_connection_url( + dialect=self.dialect, username=self.username, password=self.password, dbname=self.dbname, port=self.port + ) diff --git a/modules/mssql/tests/test_mssql.py b/modules/mssql/tests/test_mssql.py new file mode 100644 index 000000000..f7aabd3af --- /dev/null +++ b/modules/mssql/tests/test_mssql.py @@ -0,0 +1,57 @@ +import pytest +import sqlalchemy + +from testcontainers.core.utils import is_arm +from testcontainers.mssql import SqlServerContainer + + +@pytest.mark.skipif(is_arm(), reason="mssql container not available for ARM") +@pytest.mark.parametrize("version", ["2022-CU12-ubuntu-22.04", "2019-CU25-ubuntu-20.04"]) +def test_docker_run_mssql(version: str): + with SqlServerContainer(f"mcr.microsoft.com/mssql/server:{version}", password="1Secure*Password2") as mssql: + engine = sqlalchemy.create_engine(mssql.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select @@servicename")) + for row in result: + assert row[0] == "MSSQLSERVER" + + +def test_docker_run_azure_sql_edge(): + with SqlServerContainer("mcr.microsoft.com/azure-sql-edge:1.0.7") as mssql: + engine = sqlalchemy.create_engine(mssql.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select @@servicename")) + for row in result: + assert row[0] == "MSSQLSERVER" + + +# This is a feature in the generic DbContainer class +# but it can't be tested on its own +# so is tested in various database modules: +# - mysql / mariadb +# - postgresql +# - sqlserver +# - mongodb +def test_quoted_password(): + user = "SA" + # spaces seem to cause issues? + password = "p@$%25+0&%rd:/!=?" + quoted_password = "p%40%24%2525+0%26%25rd%3A%2F%21%3D%3F" + driver = "pymssql" + port = 1433 + expected_url = f"mssql+{driver}://{user}:{quoted_password}@localhost:{port}/tempdb" + kwargs = { + "username": user, + "password": password, + } + with ( + SqlServerContainer("mcr.microsoft.com/azure-sql-edge:1.0.7", **kwargs) + .with_env("ACCEPT_EULA", "Y") + .with_env( + "MSSQL_SA_PASSWORD", "{" + password + "}" + ) # special characters have to be quoted in braces in env vars + ) as container: + exposed_port = container.get_exposed_port(container.port) + expected_url = expected_url.replace(f":{port}", f":{exposed_port}") + url = container.get_connection_url() + assert url == expected_url diff --git a/modules/mysql/README.rst b/modules/mysql/README.rst new file mode 100644 index 000000000..d69785cd7 --- /dev/null +++ b/modules/mysql/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.mysql.MySqlContainer +.. title:: testcontainers.mysql.MySqlContainer diff --git a/modules/mysql/testcontainers/mysql/__init__.py b/modules/mysql/testcontainers/mysql/__init__.py new file mode 100644 index 000000000..46efbcfbc --- /dev/null +++ b/modules/mysql/testcontainers/mysql/__init__.py @@ -0,0 +1,120 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import re +import tarfile +from io import BytesIO +from os import environ +from pathlib import Path +from typing import Optional + +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_for_logs + + +class MySqlContainer(DbContainer): + """ + MySql database container. + + Example: + + The example will spin up a MySql database to which you can connect with the credentials + passed in the constructor. Alternatively, you may use the :code:`get_connection_url()` + method which returns a sqlalchemy-compatible url in format + :code:`dialect+driver://username:password@host:port/database`. + + .. doctest:: + + >>> import sqlalchemy + >>> from testcontainers.mysql import MySqlContainer + + >>> with MySqlContainer('mysql:5.7.17') as mysql: + ... engine = sqlalchemy.create_engine(mysql.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select version()")) + ... version, = result.fetchone() + + The optional :code:`seed` parameter enables arbitrary SQL files to be loaded. + This is perfect for schema and sample data. This works by mounting the seed to + `/docker-entrypoint-initdb./d`, which containerized MySQL are set up to load + automatically. + + .. doctest:: + >>> import sqlalchemy + >>> from testcontainers.mysql import MySqlContainer + >>> with MySqlContainer(seed="../../tests/seeds/") as mysql: + ... engine = sqlalchemy.create_engine(mysql.get_connection_url()) + ... with engine.begin() as connection: + ... query = "select * from stuff" # Can now rely on schema/data + ... result = connection.execute(sqlalchemy.text(query)) + ... first_stuff, = result.fetchone() + + """ + + def __init__( + self, + image: str = "mysql:latest", + username: Optional[str] = None, + root_password: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + port: int = 3306, + seed: Optional[str] = None, + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "MYSQL_USER", "username") + raise_for_deprecated_parameter(kwargs, "MYSQL_ROOT_PASSWORD", "root_password") + raise_for_deprecated_parameter(kwargs, "MYSQL_PASSWORD", "password") + raise_for_deprecated_parameter(kwargs, "MYSQL_DATABASE", "dbname") + super().__init__(image, **kwargs) + + self.port = port + self.with_exposed_ports(self.port) + self.username = username or environ.get("MYSQL_USER", "test") + self.root_password = root_password or environ.get("MYSQL_ROOT_PASSWORD", "test") + self.password = password or environ.get("MYSQL_PASSWORD", "test") + self.dbname = dbname or environ.get("MYSQL_DATABASE", "test") + + if self.username == "root": + self.root_password = self.password + self.seed = seed + + def _configure(self) -> None: + self.with_env("MYSQL_ROOT_PASSWORD", self.root_password) + self.with_env("MYSQL_DATABASE", self.dbname) + + if self.username != "root": + self.with_env("MYSQL_USER", self.username) + self.with_env("MYSQL_PASSWORD", self.password) + + def _connect(self) -> None: + wait_for_logs( + self, + re.compile(".*: ready for connections.*: ready for connections.*", flags=re.DOTALL | re.MULTILINE).search, + ) + + def get_connection_url(self) -> str: + return super()._create_connection_url( + dialect="mysql+pymysql", username=self.username, password=self.password, dbname=self.dbname, port=self.port + ) + + def _transfer_seed(self) -> None: + if self.seed is None: + return + src_path = Path(self.seed) + dest_path = "/docker-entrypoint-initdb.d/" + with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar: + for filename in src_path.iterdir(): + tar.add(filename.absolute(), arcname=filename.relative_to(src_path)) + archive.seek(0) + self.get_wrapped_container().put_archive(dest_path, archive) diff --git a/modules/mysql/tests/seeds/01-schema.sql b/modules/mysql/tests/seeds/01-schema.sql new file mode 100644 index 000000000..ea3982445 --- /dev/null +++ b/modules/mysql/tests/seeds/01-schema.sql @@ -0,0 +1,6 @@ +-- Sample SQL schema, no data +CREATE TABLE `stuff` ( + `id` mediumint NOT NULL AUTO_INCREMENT, + `name` VARCHAR(63) NOT NULL, + PRIMARY KEY (`id`) +); diff --git a/modules/mysql/tests/seeds/02-seeds.sql b/modules/mysql/tests/seeds/02-seeds.sql new file mode 100644 index 000000000..7ce78903b --- /dev/null +++ b/modules/mysql/tests/seeds/02-seeds.sql @@ -0,0 +1,3 @@ +-- Sample data, to be loaded after the schema +INSERT INTO stuff (name) +VALUES ("foo"), ("bar"), ("qux"), ("frob"); diff --git a/modules/mysql/tests/test_mysql.py b/modules/mysql/tests/test_mysql.py new file mode 100644 index 000000000..323c35328 --- /dev/null +++ b/modules/mysql/tests/test_mysql.py @@ -0,0 +1,91 @@ +from pathlib import Path +import re +from unittest import mock + +import pytest +import sqlalchemy + +from testcontainers.core.utils import is_arm +from testcontainers.mysql import MySqlContainer + + +def test_docker_run_mysql(): + config = MySqlContainer("mysql:8.3.0") + with config as mysql: + engine = sqlalchemy.create_engine(mysql.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select version()")) + for row in result: + assert row[0].startswith("8.3.0") + + +@pytest.mark.skipif(is_arm(), reason="mysql container not available for ARM") +def test_docker_run_legacy_mysql(): + config = MySqlContainer("mysql:5.7.44") + with config as mysql: + engine = sqlalchemy.create_engine(mysql.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select version()")) + for row in result: + assert row[0].startswith("5.7.44") + + +@pytest.mark.skipif(is_arm(), reason="mysql container not available for ARM") +def test_docker_run_mysql_8_seed(): + # Avoid pytest CWD path issues + SEEDS_PATH = (Path(__file__).parent / "seeds").absolute() + config = MySqlContainer("mysql:8", seed=SEEDS_PATH) + with config as mysql: + engine = sqlalchemy.create_engine(mysql.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select * from stuff")) + assert len(list(result)) == 4, "Should have gotten all the stuff" + + +@pytest.mark.parametrize("version", ["11.3.2", "10.11.7"]) +def test_docker_run_mariadb(version: str): + with MySqlContainer(f"mariadb:{version}") as mariadb: + engine = sqlalchemy.create_engine(mariadb.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select version()")) + for row in result: + assert row[0].startswith(version) + + +def test_docker_env_variables(): + with ( + mock.patch.dict("os.environ", MYSQL_USER="demo", MYSQL_DATABASE="custom_db"), + MySqlContainer("mariadb:10.6.5").with_bind_ports(3306, 32785) as container, + ): + url = container.get_connection_url() + pattern = r"mysql\+pymysql:\/\/demo:test@[\w,.]+:(3306|32785)\/custom_db" + assert re.match(pattern, url) + + +# This is a feature in the generic DbContainer class +# but it can't be tested on its own +# so is tested in various database modules: +# - mysql / mariadb +# - postgresql +# - sqlserver +# - mongodb +# - db2 +def test_quoted_password(): + user = "root" + password = "p@$%25+0&%rd :/!=?" + quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F" + driver = "pymysql" + with MySqlContainer("mariadb:10.6.5", username=user, password=password) as container: + host = container.get_container_host_ip() + port = container.get_exposed_port(3306) + expected_url = f"mysql+{driver}://{user}:{quoted_password}@{host}:{port}/test" + url = container.get_connection_url() + assert url == expected_url + + with sqlalchemy.create_engine(expected_url).begin() as connection: + connection.execute(sqlalchemy.text("select version()")) + + raw_pass_url = f"mysql+{driver}://{user}:{password}@{host}:{port}/test" + with pytest.raises(Exception): + with sqlalchemy.create_engine(raw_pass_url).begin() as connection: + connection.execute(sqlalchemy.text("select version()")) diff --git a/modules/nats/README.rst b/modules/nats/README.rst new file mode 100644 index 000000000..785892939 --- /dev/null +++ b/modules/nats/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.nats.NatsContainer +.. title:: testcontainers.nats.NatsContainer diff --git a/modules/nats/testcontainers/nats/__init__.py b/modules/nats/testcontainers/nats/__init__.py new file mode 100644 index 000000000..8ffeca4da --- /dev/null +++ b/modules/nats/testcontainers/nats/__init__.py @@ -0,0 +1,75 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class NatsContainer(DockerContainer): + """ + Nats container. + + Example: + + .. doctest:: + + >>> import asyncio + >>> from nats import connect as nats_connect + >>> from testcontainers.nats import NatsContainer + + >>> async def test_doctest_usage(): + ... with NatsContainer() as nats_container: + ... client = await nats_connect(nats_container.nats_uri()) + ... sub_tc = await client.subscribe("tc") + ... await client.publish("tc", b"Test-Containers") + ... next_message = await sub_tc.next_msg(timeout=5.0) + ... await client.close() + ... return next_message.data + >>> asyncio.run(test_doctest_usage()) + b'Test-Containers' + """ + + def __init__( + self, + image: str = "nats:latest", + client_port: int = 4222, + management_port: int = 8222, + expected_ready_log: str = "Server is ready", + ready_timeout_secs: int = 120, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self.client_port = client_port + self.management_port = management_port + self._expected_ready_log = expected_ready_log + self._ready_timeout_secs = max(ready_timeout_secs, 0) + self.with_exposed_ports(self.client_port, self.management_port) + + @wait_container_is_ready() + def _healthcheck(self) -> None: + wait_for_logs(self, self._expected_ready_log, timeout=self._ready_timeout_secs) + + def nats_uri(self) -> str: + return f"nats://{self.get_container_host_ip()}:{self.get_exposed_port(self.client_port)}" + + def nats_host_and_port(self) -> tuple[str, int]: + return self.get_container_host_ip(), self.get_exposed_port(self.client_port) + + def nats_management_uri(self) -> str: + return f"nats://{self.get_container_host_ip()}:{self.get_exposed_port(self.management_port)}" + + def start(self) -> "NatsContainer": + super().start() + self._healthcheck() + return self diff --git a/modules/nats/tests/test_nats.py b/modules/nats/tests/test_nats.py new file mode 100644 index 000000000..7b72ea81b --- /dev/null +++ b/modules/nats/tests/test_nats.py @@ -0,0 +1,87 @@ +from testcontainers.nats import NatsContainer +from uuid import uuid4 +import pytest + +from nats import connect as nats_connect +from nats.aio.client import Client as NATSClient + + +async def get_client(container: NatsContainer) -> "NATSClient": + """ + Get a nats client. + + Returns: + client: Nats client to connect to the container. + """ + conn_string = container.nats_uri() + client = await nats_connect(conn_string) + return client + + +def test_basic_container_ops(): + with NatsContainer() as container: + # Not sure how to get type information without doing this + container: NatsContainer = container + h, p = container.nats_host_and_port() + assert h == "localhost" + uri = container.nats_uri() + management_uri = container.nats_management_uri() + + assert uri != management_uri + + +@pytest.mark.asyncio +async def test_pubsub(anyio_backend): + with NatsContainer() as container: + nc: NATSClient = await get_client(container) + + topic = str(uuid4()) + + sub = await nc.subscribe(topic) + sent_message = b"Test-Containers" + await nc.publish(topic, b"Test-Containers") + received_msg = await sub.next_msg() + print("Received:", received_msg) + assert sent_message == received_msg.data + await nc.flush() + await nc.close() + + +@pytest.mark.asyncio +async def test_more_complex_example(anyio_backend): + with NatsContainer() as container: + nc: NATSClient = await get_client(container) + + sub = await nc.subscribe("greet.*") + await nc.publish("greet.joe", b"hello") + + try: + await sub.next_msg(timeout=0.1) + except TimeoutError: + pass + + await nc.publish("greet.joe", b"hello.joe") + await nc.publish("greet.pam", b"hello.pam") + + first = await sub.next_msg(timeout=0.1) + assert b"hello.joe" == first.data + + second = await sub.next_msg(timeout=0.1) + assert b"hello.pam" == second.data + + await nc.publish("greet.bob", b"hello") + + await sub.unsubscribe() + await nc.drain() + + +@pytest.mark.asyncio +async def test_doctest_usage(): + """simpler to run test to mirror what is in the doctest""" + with NatsContainer() as nats_container: + client = await nats_connect(nats_container.nats_uri()) + sub_tc = await client.subscribe("tc") + await client.publish("tc", b"Test-Containers") + next_message = await sub_tc.next_msg(timeout=5.0) + await client.close() + assert next_message.data == b"Test-Containers" diff --git a/modules/neo4j/README.rst b/modules/neo4j/README.rst new file mode 100644 index 000000000..885d4a598 --- /dev/null +++ b/modules/neo4j/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.neo4j.Neo4jContainer +.. title:: testcontainers.neo4j.Neo4jContainer diff --git a/modules/neo4j/testcontainers/neo4j/__init__.py b/modules/neo4j/testcontainers/neo4j/__init__.py new file mode 100644 index 000000000..7939c013f --- /dev/null +++ b/modules/neo4j/testcontainers/neo4j/__init__.py @@ -0,0 +1,74 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +from typing import Optional + +from neo4j import Driver, GraphDatabase +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class Neo4jContainer(DbContainer): + """ + Neo4j Graph Database (Standalone) database container. + + Example: + + .. doctest:: + + >>> from testcontainers.neo4j import Neo4jContainer + + >>> with Neo4jContainer() as neo4j, \\ + ... neo4j.get_driver() as driver, \\ + ... driver.session() as session: + ... result = session.run("MATCH (n) RETURN n LIMIT 1") + ... record = result.single() + """ + + def __init__( + self, + image: str = "neo4j:latest", + port: int = 7687, + password: Optional[str] = None, + username: Optional[str] = None, + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "bolt_port", "port") + super().__init__(image, **kwargs) + self.username = username or os.environ.get("NEO4J_USER", "neo4j") + self.password = password or os.environ.get("NEO4J_PASSWORD", "password") + self.port = port + self.with_exposed_ports(self.port) + self._driver = None + + def _configure(self) -> None: + self.with_env("NEO4J_AUTH", f"neo4j/{self.password}") + + def get_connection_url(self) -> str: + return f"bolt://{self.get_container_host_ip()}:{self.get_exposed_port(self.port)}" + + @wait_container_is_ready() + def _connect(self) -> None: + wait_for_logs(self, "Remote interface available at", c.timeout) + + # Then we actually check that the container really is listening + with self.get_driver() as driver: + # Drivers may or may not be lazy + # force them to do a round trip to confirm neo4j is working + driver.verify_connectivity() + + def get_driver(self, **kwargs) -> Driver: + return GraphDatabase.driver(self.get_connection_url(), auth=(self.username, self.password), **kwargs) diff --git a/modules/neo4j/tests/test_neo4j.py b/modules/neo4j/tests/test_neo4j.py new file mode 100644 index 000000000..6058d34c2 --- /dev/null +++ b/modules/neo4j/tests/test_neo4j.py @@ -0,0 +1,15 @@ +from testcontainers.neo4j import Neo4jContainer + + +def test_docker_run_neo4j_latest(): + with Neo4jContainer() as neo4j, neo4j.get_driver() as driver, driver.session() as session: + result = session.run( + """ + CALL dbms.components() + YIELD name, versions, edition + UNWIND versions as version + RETURN name, version, edition + """ + ) + record = result.single() + assert record["name"].startswith("Neo4j") diff --git a/modules/nginx/README.rst b/modules/nginx/README.rst new file mode 100644 index 000000000..a949a93d1 --- /dev/null +++ b/modules/nginx/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.nginx.NginxContainer +.. title:: testcontainers.nginx.NginxContainer diff --git a/modules/nginx/testcontainers/nginx/__init__.py b/modules/nginx/testcontainers/nginx/__init__.py new file mode 100644 index 000000000..ecf4c072e --- /dev/null +++ b/modules/nginx/testcontainers/nginx/__init__.py @@ -0,0 +1,41 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import urllib.error +import urllib.parse +import urllib.request + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class NginxContainer(DockerContainer): + def __init__(self, image: str = "nginx:latest", port: int = 80, **kwargs) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.with_exposed_ports(self.port) + + def start(self) -> "NginxContainer": + super().start() + + host = self.get_container_host_ip() + port = str(self.get_exposed_port(self.port)) + self._connect(host, port) + + return self + + @wait_container_is_ready(urllib.error.URLError) + def _connect(self, host: str, port: str) -> None: + url = urllib.parse.urlunsplit(("http", f"{host}:{port}", "", "", "")) + urllib.request.urlopen(url, timeout=1) diff --git a/modules/nginx/tests/test_nginx.py b/modules/nginx/tests/test_nginx.py new file mode 100644 index 000000000..39fba5e97 --- /dev/null +++ b/modules/nginx/tests/test_nginx.py @@ -0,0 +1,12 @@ +import requests + +from testcontainers.nginx import NginxContainer + + +def test_docker_run_nginx(): + nginx_container = NginxContainer("nginx:1.13.8") + with nginx_container as nginx: + url = f"http://{nginx.get_container_host_ip()}:{nginx.get_exposed_port(nginx.port)}/" + r = requests.get(url) + assert r.status_code == 200 + assert "Welcome to nginx!" in r.text diff --git a/modules/ollama/README.rst b/modules/ollama/README.rst new file mode 100644 index 000000000..dc18fe265 --- /dev/null +++ b/modules/ollama/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.ollama.OllamaContainer +.. title:: testcontainers.ollama.OllamaContainer diff --git a/modules/ollama/testcontainers/ollama/__init__.py b/modules/ollama/testcontainers/ollama/__init__.py new file mode 100644 index 000000000..ea089f149 --- /dev/null +++ b/modules/ollama/testcontainers/ollama/__init__.py @@ -0,0 +1,160 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from os import PathLike +from typing import Any, Optional, TypedDict, Union + +from docker.types.containers import DeviceRequest +from requests import get + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class OllamaModel(TypedDict): + name: str + model: str + modified_at: str + size: int + digest: str + details: dict[str, Any] + + +class OllamaContainer(DockerContainer): + """ + Ollama Container + + :param: image - the ollama image to use (default: :code:`ollama/ollama:0.1.44`) + :param: ollama_home - the directory to mount for model data (default: None) + + you may pass :code:`pathlib.Path.home() / ".ollama"` to re-use models + that have already been pulled with ollama running on this host outside the container. + + Examples: + + .. doctest:: + + >>> from testcontainers.ollama import OllamaContainer + >>> with OllamaContainer() as ollama: + ... ollama.list_models() + [] + + .. code-block:: python + + >>> from json import loads + >>> from pathlib import Path + >>> from requests import post + >>> from testcontainers.ollama import OllamaContainer + >>> def split_by_line(generator): + ... data = b'' + ... for each_item in generator: + ... for line in each_item.splitlines(True): + ... data += line + ... if data.endswith((b'\\r\\r', b'\\n\\n', b'\\r\\n\\r\\n', b'\\n')): + ... yield from data.splitlines() + ... data = b'' + ... if data: + ... yield from data.splitlines() + + >>> with OllamaContainer(ollama_home=Path.home() / ".ollama") as ollama: + ... if "llama3:latest" not in [e["name"] for e in ollama.list_models()]: + ... print("did not find 'llama3:latest', pulling") + ... ollama.pull_model("llama3:latest") + ... endpoint = ollama.get_endpoint() + ... for chunk in split_by_line( + ... post(url=f"{endpoint}/api/chat", stream=True, json={ + ... "model": "llama3:latest", + ... "messages": [{ + ... "role": "user", + ... "content": "what color is the sky? MAX ONE WORD" + ... }] + ... }) + ... ): + ... print(loads(chunk)["message"]["content"], end="") + Blue. + """ + + OLLAMA_PORT = 11434 + + def __init__( + self, + image: str = "ollama/ollama:0.1.44", + ollama_home: Optional[Union[str, PathLike]] = None, + **kwargs, + # + ): + super().__init__(image=image, **kwargs) + self.ollama_home = ollama_home + self.with_exposed_ports(OllamaContainer.OLLAMA_PORT) + self._check_and_add_gpu_capabilities() + + def _check_and_add_gpu_capabilities(self): + info = self.get_docker_client().client.info() + if "nvidia" in info["Runtimes"]: + self._kwargs = {**self._kwargs, "device_requests": DeviceRequest(count=-1, capabilities=[["gpu"]])} + + def start(self) -> "OllamaContainer": + """ + Start the Ollama server + """ + if self.ollama_home: + self.with_volume_mapping(self.ollama_home, "/root/.ollama", "rw") + super().start() + wait_for_logs(self, "Listening on ", timeout=30) + + return self + + def get_endpoint(self): + """ + Return the endpoint of the Ollama server + """ + host = self.get_container_host_ip() + exposed_port = self.get_exposed_port(OllamaContainer.OLLAMA_PORT) + url = f"http://{host}:{exposed_port}" + return url + + @property + def id(self) -> str: + """ + Return the container object + """ + return self._container.id + + def pull_model(self, model_name: str) -> None: + """ + Pull a model from the Ollama server + + Args: + model_name (str): Name of the model + """ + self.exec(f"ollama pull {model_name}") + + def list_models(self) -> list[OllamaModel]: + endpoint = self.get_endpoint() + response = get(url=f"{endpoint}/api/tags") + response.raise_for_status() + return response.json().get("models", []) + + def commit_to_image(self, image_name: str) -> None: + """ + Commit the current container to a new image + + Args: + image_name (str): Name of the new image + """ + docker_client = self.get_docker_client() + existing_images = docker_client.client.images.list(name=image_name) + if not existing_images and self.id: + docker_client.client.containers.get(self.id).commit( + repository=image_name, conf={"Labels": {"org.testcontainers.session-id": ""}} + ) diff --git a/modules/ollama/tests/test_ollama.py b/modules/ollama/tests/test_ollama.py new file mode 100644 index 000000000..980dac00b --- /dev/null +++ b/modules/ollama/tests/test_ollama.py @@ -0,0 +1,60 @@ +import random +import string +from pathlib import Path + +import requests +from testcontainers.ollama import OllamaContainer + + +def random_string(length=6): + return "".join(random.choices(string.ascii_lowercase, k=length)) + + +def test_ollama_container(): + with OllamaContainer() as ollama: + url = ollama.get_endpoint() + response = requests.get(url) + assert response.status_code == 200 + assert response.text == "Ollama is running" + + +def test_with_default_config(): + with OllamaContainer("ollama/ollama:0.1.26") as ollama: + ollama.start() + response = requests.get(f"{ollama.get_endpoint()}/api/version") + version = response.json().get("version") + assert version == "0.1.26" + + +def test_download_model_and_commit_to_image(): + new_image_name = f"tc-ollama-allminilm-{random_string(length=4).lower()}" + with OllamaContainer("ollama/ollama:0.1.26") as ollama: + ollama.start() + # Pull the model + ollama.pull_model("all-minilm") + + response = requests.get(f"{ollama.get_endpoint()}/api/tags") + model_name = ollama.list_models()[0].get("name") + assert "all-minilm" in model_name + + # Commit the container state to a new image + ollama.commit_to_image(new_image_name) + + # Verify the new image + with OllamaContainer(new_image_name) as ollama: + ollama.start() + response = requests.get(f"{ollama.get_endpoint()}/api/tags") + model_name = response.json().get("models", [])[0].get("name") + assert "all-minilm" in model_name + + +def test_models_saved_in_folder(tmp_path: Path): + with OllamaContainer("ollama/ollama:0.1.26", ollama_home=tmp_path) as ollama: + assert len(ollama.list_models()) == 0 + ollama.pull_model("all-minilm") + assert len(ollama.list_models()) == 1 + assert "all-minilm" in ollama.list_models()[0].get("name") + + with OllamaContainer("ollama/ollama:0.1.26", ollama_home=tmp_path) as ollama: + assert len(ollama.list_models()) == 1 + assert "all-minilm" in ollama.list_models()[0].get("name") diff --git a/modules/opensearch/README.rst b/modules/opensearch/README.rst new file mode 100644 index 000000000..fdc450fd6 --- /dev/null +++ b/modules/opensearch/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.opensearch.OpenSearchContainer +.. title:: testcontainers.opensearch.OpenSearchContainer diff --git a/modules/opensearch/testcontainers/opensearch/__init__.py b/modules/opensearch/testcontainers/opensearch/__init__.py new file mode 100644 index 000000000..06d3a7671 --- /dev/null +++ b/modules/opensearch/testcontainers/opensearch/__init__.py @@ -0,0 +1,105 @@ +from opensearchpy import OpenSearch +from opensearchpy.exceptions import ConnectionError, TransportError +from urllib3.exceptions import ProtocolError + +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class OpenSearchContainer(DockerContainer): + """ + The following example demonstrates how to create a new index in an OpenSearch container and add + a document to it. It also shows how to search within the created index. The refresh step in + between makes sure that the newly created document is available for search. + + The method :code:`get_client` can be used to create a OpenSearch Python Client. The method + :code:`get_config` can be used to retrieve the host, port, username, and password of the + container. + + Example: + + .. doctest:: + + >>> from testcontainers.opensearch import OpenSearchContainer + + >>> with OpenSearchContainer() as opensearch: + ... client = opensearch.get_client() + ... creation_result = client.index(index="test", body={"test": "test"}) + ... refresh_result = client.indices.refresh(index="test") + ... search_result = client.search(index="test", body={"query": {"match_all": {}}}) + """ + + def __init__( + self, + image: str = "opensearchproject/opensearch:2.4.0", + port: int = 9200, + security_enabled: bool = False, + **kwargs, + ) -> None: + """ + Args: + image: Docker image to use for the container. + port: Port to expose on the container. + security_enabled: :code:`False` disables the security plugin in OpenSearch. + """ + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.security_enabled = security_enabled + + self.with_exposed_ports(self.port) + self.with_env("discovery.type", "single-node") + self.with_env("plugins.security.disabled", "false" if security_enabled else "true") + if security_enabled: + self.with_env("plugins.security.allow_default_init_securityindex", "true") + + def get_config(self) -> dict: + """This method returns the configuration of the OpenSearch container, + including the host, port, username, and password. + + Returns: + dict: {`host`: str, `port`: str, `username`: str, `password`: str} + """ + + return { + "host": self.get_container_host_ip(), + "port": self.get_exposed_port(self.port), + "username": "admin", + "password": "admin", + } + + def get_client(self, verify_certs: bool = False, **kwargs) -> OpenSearch: + """Returns a OpenSearch client to connect to the container. + + Returns: + OpenSearch: Python OpenSearch Client according to + https://opensearch.org/docs/latest/clients/python/ + """ + config = self.get_config() + return OpenSearch( + hosts=[ + { + "host": config["host"], + "port": config["port"], + } + ], + http_auth=(config["username"], config["password"]), + use_ssl=self.security_enabled, + verify_certs=verify_certs, + **kwargs, + ) + + @wait_container_is_ready(ConnectionError, TransportError, ProtocolError, ConnectionResetError) + def _healthcheck(self) -> None: + """This is an internal method used to check if the OpenSearch container + is healthy and ready to receive requests.""" + client: OpenSearchContainer = self.get_client() + client.cluster.health(wait_for_status="green") + + def start(self) -> "OpenSearchContainer": + """This method starts the OpenSearch container and runs the healthcheck + to verify that the container is ready to use.""" + super().start() + self._healthcheck() + return self diff --git a/modules/opensearch/tests/test_opensearch.py b/modules/opensearch/tests/test_opensearch.py new file mode 100644 index 000000000..a287563ed --- /dev/null +++ b/modules/opensearch/tests/test_opensearch.py @@ -0,0 +1,34 @@ +from testcontainers.opensearch import OpenSearchContainer + + +def test_docker_run_opensearch(): + with OpenSearchContainer() as opensearch: + client = opensearch.get_client() + assert client.cluster.health()["status"] == "green" + + +def test_docker_run_opensearch_with_security(): + with OpenSearchContainer(security_enabled=True) as opensearch: + client = opensearch.get_client() + assert client.cluster.health()["status"] == "green" + + +def test_docker_run_opensearch_v1(): + with OpenSearchContainer(image="opensearchproject/opensearch:1.3.6") as opensearch: + client = opensearch.get_client() + assert client.cluster.health()["status"] == "green" + + +def test_docker_run_opensearch_v1_with_security(): + with OpenSearchContainer(image="opensearchproject/opensearch:1.3.6", security_enabled=True) as opensearch: + client = opensearch.get_client() + assert client.cluster.health()["status"] == "green" + + +def test_search(): + with OpenSearchContainer() as opensearch: + client = opensearch.get_client() + client.index(index="test", body={"test": "test"}) + client.indices.refresh(index="test") + result = client.search(index="test", body={"query": {"match_all": {}}}) + assert result["hits"]["total"]["value"] == 1 diff --git a/modules/oracle-free/README.rst b/modules/oracle-free/README.rst new file mode 100644 index 000000000..bbd5d3b5d --- /dev/null +++ b/modules/oracle-free/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.oracle.OracleDbContainer +.. title:: testcontainers.oracle.OracleDbContainer diff --git a/modules/oracle-free/testcontainers/oracle/__init__.py b/modules/oracle-free/testcontainers/oracle/__init__.py new file mode 100644 index 000000000..781be4280 --- /dev/null +++ b/modules/oracle-free/testcontainers/oracle/__init__.py @@ -0,0 +1,80 @@ +from os import environ +from secrets import randbits +from typing import Optional + +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class OracleDbContainer(DbContainer): + """ + Oracle database container. + + Example: + + .. doctest:: + + >>> import sys, pytest + >>> if sys.platform.startswith('win') or sys.platform == 'darwin': + ... pytest.skip("linux only test") + + >>> import sqlalchemy + >>> from testcontainers.oracle import OracleDbContainer + + >>> with OracleDbContainer() as oracle: + ... engine = sqlalchemy.create_engine(oracle.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("SELECT 1 FROM dual")) + ... result.fetchall() + [(1,)] + """ + + def __init__( + self, + image: str = "gvenzl/oracle-free:slim", + oracle_password: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + port: int = 1521, + dbname: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__(image=image, **kwargs) + + self.port = port + self.with_exposed_ports(self.port) + + self.oracle_password = oracle_password or environ.get("ORACLE_PASSWORD") or hex(randbits(24)) + self.username = username or environ.get("APP_USER") + self.password = password or environ.get("APP_USER_PASSWORD") + self.dbname = dbname or environ.get("ORACLE_DATABASE") + + def get_connection_url(self) -> str: + return super()._create_connection_url( + dialect="oracle+oracledb", + username=self.username or "system", + password=self.password or self.oracle_password, + port=self.port, + ) + "/?service_name={}".format(self.dbname or "FREEPDB1") + # Default DB is "FREEPDB1" + + def _connect(self) -> None: + wait_for_logs(self, "DATABASE IS READY TO USE!") + + def _configure(self) -> None: + # if self.oracle_password is not None: + # self.with_env("ORACLE_PASSWORD", self.oracle_password) + # # Either ORACLE_PASSWORD or ORACLE_RANDOM_PASSWORD need to be passed on + # else: + # self.with_env("ORACLE_RANDOM_PASSWORD", "y") + # this module is unusable with a random password + self.with_env("ORACLE_PASSWORD", self.oracle_password) + + if self.username is not None: + self.with_env("APP_USER", self.username) + if self.password is not None: + self.with_env("APP_USER_PASSWORD", self.password) + + # FREE and FREEPDB1 are predefined databases, do not pass them on as ORACLE_DATABASE + if self.dbname is not None and self.dbname.upper() not in ("FREE", "FREEPDB1"): + self.with_env("ORACLE_DATABASE", self.dbname) diff --git a/modules/oracle-free/tests/test_oracle.py b/modules/oracle-free/tests/test_oracle.py new file mode 100644 index 000000000..0c6d8998e --- /dev/null +++ b/modules/oracle-free/tests/test_oracle.py @@ -0,0 +1,81 @@ +import pytest +import sqlalchemy + +from testcontainers.core.utils import is_arm +from testcontainers.oracle import OracleDbContainer + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_system_password(): + with OracleDbContainer(oracle_password="test") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_username_password(): + with OracleDbContainer(username="test", password="test") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_custom_db_and_system_username_password(): + with OracleDbContainer(oracle_password="coolpassword", dbname="myTestPDB") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_custom_db_and_app_username_password(): + with OracleDbContainer(username="mycooluser", password="123connect", dbname="anotherPDB") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_default_db_and_app_username_password(): + with OracleDbContainer(username="mycooluser", password="123connect") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_docker_run_oracle_with_cdb_and_system_username(): + with OracleDbContainer(oracle_password="MyOraclePWD1", dbname="free") as oracledb: + engine = sqlalchemy.create_engine(oracledb.get_connection_url()) + with engine.begin() as connection: + test_val = 1 + result = connection.execute(sqlalchemy.text("SELECT {} FROM dual".format(test_val))) + for row in result: + assert row[0] == test_val + + +@pytest.mark.skipif(is_arm(), reason="oracle-free container not available for ARM") +def test_doctest(): + with OracleDbContainer() as oracle: + print(oracle.get_connection_url()) + engine = sqlalchemy.create_engine(oracle.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("SELECT 1 FROM dual")) + assert result.fetchall() == [(1,)] diff --git a/modules/postgres/README.rst b/modules/postgres/README.rst new file mode 100644 index 000000000..bce939244 --- /dev/null +++ b/modules/postgres/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.postgres.PostgresContainer +.. title:: testcontainers.postgres.PostgresContainer diff --git a/modules/postgres/testcontainers/postgres/__init__.py b/modules/postgres/testcontainers/postgres/__init__.py new file mode 100644 index 000000000..c9ba2a22a --- /dev/null +++ b/modules/postgres/testcontainers/postgres/__init__.py @@ -0,0 +1,135 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from time import sleep +from typing import Optional + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DbContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +_UNSET = object() + + +class PostgresContainer(DbContainer): + """ + Postgres database container. + + To get a URL without a driver, pass in :code:`driver=None`. + + Example: + + The example spins up a Postgres database and connects to it using the :code:`psycopg` + driver. + + .. doctest:: + + >>> from testcontainers.postgres import PostgresContainer + >>> import sqlalchemy + + >>> with PostgresContainer("postgres:16") as postgres: + ... engine = sqlalchemy.create_engine(postgres.get_connection_url()) + ... with engine.begin() as connection: + ... result = connection.execute(sqlalchemy.text("select version()")) + ... version, = result.fetchone() + >>> version + 'PostgreSQL 16...' + """ + + def __init__( + self, + image: str = "postgres:latest", + port: int = 5432, + username: Optional[str] = None, + password: Optional[str] = None, + dbname: Optional[str] = None, + driver: Optional[str] = "psycopg2", + **kwargs, + ) -> None: + raise_for_deprecated_parameter(kwargs, "user", "username") + super().__init__(image=image, **kwargs) + self.username: str = username or os.environ.get("POSTGRES_USER", "test") + self.password: str = password or os.environ.get("POSTGRES_PASSWORD", "test") + self.dbname: str = dbname or os.environ.get("POSTGRES_DB", "test") + self.port = port + self.driver = f"+{driver}" if driver else "" + + self.with_exposed_ports(self.port) + + def _configure(self) -> None: + self.with_env("POSTGRES_USER", self.username) + self.with_env("POSTGRES_PASSWORD", self.password) + self.with_env("POSTGRES_DB", self.dbname) + + def get_connection_url(self, host: Optional[str] = None, driver: Optional[str] = _UNSET) -> str: + """Get a DB connection URL to connect to the PG DB. + + If a driver is set in the constructor (defaults to psycopg2!), the URL will contain the + driver. The optional driver argument to :code:`get_connection_url` overwrites the constructor + set value. Pass :code:`driver=None` to get URLs without a driver. + """ + driver_str = "" if driver is None else self.driver if driver is _UNSET else f"+{driver}" + return super()._create_connection_url( + dialect=f"postgresql{driver_str}", + username=self.username, + password=self.password, + dbname=self.dbname, + host=host, + port=self.port, + ) + + @wait_container_is_ready() + def _connect(self) -> None: + # postgres itself logs these messages to the standard error stream: + # + # $ /opt/homebrew/opt/postgresql@14/bin/postgres -D /opt/homebrew/var/postgresql@14 \ + # > | grep -o -a -m 1 -h 'database system is ready to accept connections' + # 2024-08-03 00:13:02.799 EDT [70226] LOG: starting PostgreSQL 14.11 (Homebrew) .... + # 2024-08-03 00:13:02.804 EDT [70226] LOG: listening on IPv4 address "127.0.0.1", port 5432 + # ... + # ^C2024-08-03 00:13:04.226 EDT [70226] LOG: received fast shutdown request + # ... + # + # $ /opt/homebrew/opt/postgresql@14/bin/postgres -D /opt/homebrew/var/postgresql@14 2>&1 \ + # > | grep -o -a -m 1 -h 'database system is ready to accept connections' + # database system is ready to accept connections + # + # and the setup script inside docker library postgres + # uses pg_ctl: + # https://github.com/docker-library/postgres/blob/66da3846b40396249936938ee17e9684e6968a57/16/alpine3.20/docker-entrypoint.sh#L261-L282 + # which prints logs to stdout: + # https://www.postgresql.org/docs/current/app-pg-ctl.html#:~:text=the%20server%27s%20standard%20output%20and%20standard%20error%20are%20sent%20to%20pg_ctl%27s%20standard%20output + # + # so we must wait for both the setup and real startup: + predicate_streams_and = True + + wait_for_logs( + self, + ".*database system is ready to accept connections.*", + c.max_tries, + c.sleep_time, + predicate_streams_and=predicate_streams_and, + # + ) + + count = 0 + while count < c.max_tries: + status, _ = self.exec(f"pg_isready -hlocalhost -p{self.port} -U{self.username}") + if status == 0: + return + + sleep(c.sleep_time) + count += 1 + + raise RuntimeError("Postgres could not get into a ready state") diff --git a/modules/postgres/tests/fixtures/postgres_create_example_table.sql b/modules/postgres/tests/fixtures/postgres_create_example_table.sql new file mode 100644 index 000000000..4d7994ead --- /dev/null +++ b/modules/postgres/tests/fixtures/postgres_create_example_table.sql @@ -0,0 +1,6 @@ +create table example +( + id serial not null primary key, + name varchar(255) not null unique, + description text null +); diff --git a/modules/postgres/tests/test_postgres.py b/modules/postgres/tests/test_postgres.py new file mode 100644 index 000000000..42bcfe858 --- /dev/null +++ b/modules/postgres/tests/test_postgres.py @@ -0,0 +1,124 @@ +from pathlib import Path + +import pytest + +from testcontainers.postgres import PostgresContainer +import sqlalchemy + + +# https://www.postgresql.org/support/versioning/ +@pytest.mark.parametrize("version", ["12", "13", "14", "15", "16", "latest"]) +def test_docker_run_postgres(version: str, monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("SQLA was called during PG container setup") + + monkeypatch.setattr(sqlalchemy, "create_engine", fail) + postgres_container = PostgresContainer(f"postgres:{version}") + with postgres_container as postgres: + status, msg = postgres.exec(f"pg_isready -hlocalhost -p{postgres.port} -U{postgres.username}") + + assert msg.decode("utf-8").endswith("accepting connections\n") + assert status == 0 + + status, msg = postgres.exec( + f"psql -hlocalhost -p{postgres.port} -U{postgres.username} -c 'select 2*3*5*7*11*13*17 as a;' " + ) + assert "510510" in msg.decode("utf-8") + assert "(1 row)" in msg.decode("utf-8") + assert status == 0 + + +def test_docker_run_postgres_with_sqlalchemy(): + postgres_container = PostgresContainer("postgres:9.5") + with postgres_container as postgres: + engine = sqlalchemy.create_engine(postgres.get_connection_url()) + with engine.begin() as connection: + result = connection.execute(sqlalchemy.text("select version()")) + for row in result: + assert row[0].lower().startswith("postgresql 9.5") + + +def test_docker_run_postgres_with_driver_pg8000(): + postgres_container = PostgresContainer("postgres:9.5", driver="pg8000") + with postgres_container as postgres: + engine = sqlalchemy.create_engine(postgres.get_connection_url()) + with engine.begin() as connection: + connection.execute(sqlalchemy.text("select 1=1")) + + +# This is a feature in the generic DbContainer class +# but it can't be tested on its own +# so is tested in various database modules: +# - mysql / mariadb +# - postgresql +# - sqlserver +# - mongodb +# - db2 +def test_quoted_password(): + user = "root" + password = "p@$%25+0&%rd :/!=?" + quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F" + driver = "psycopg2" + kwargs = { + "driver": driver, + "username": user, + "password": password, + } + with PostgresContainer("postgres:16-alpine", **kwargs) as container: + port = container.get_exposed_port(5432) + host = container.get_container_host_ip() + expected_url = f"postgresql+{driver}://{user}:{quoted_password}@{host}:{port}/test" + + url = container.get_connection_url() + assert url == expected_url + + with sqlalchemy.create_engine(expected_url).begin() as connection: + connection.execute(sqlalchemy.text("select 1=1")) + + raw_pass_url = f"postgresql+{driver}://{user}:{password}@{host}:{port}/test" + with pytest.raises(Exception): + # it raises ValueError, but auth (OperationalError) = more interesting + with sqlalchemy.create_engine(raw_pass_url).begin() as connection: + connection.execute(sqlalchemy.text("select 1=1")) + + +def test_show_how_to_initialize_db_via_initdb_dir(): + postgres_container = PostgresContainer("postgres:16-alpine") + script = Path(__file__).parent / "fixtures" / "postgres_create_example_table.sql" + postgres_container.with_volume_mapping(host=str(script), container=f"/docker-entrypoint-initdb.d/{script.name}") + + insert_query = "insert into example(name, description) VALUES ('sally', 'sells seashells');" + select_query = "select id, name, description from example;" + + with postgres_container as postgres: + engine = sqlalchemy.create_engine(postgres.get_connection_url()) + with engine.begin() as connection: + connection.execute(sqlalchemy.text(insert_query)) + result = connection.execute(sqlalchemy.text(select_query)) + result = result.fetchall() + assert len(result) == 1 + assert result[0] == (1, "sally", "sells seashells") + + +def test_none_driver_urls(): + user = "root" + password = "pass" + kwargs = { + "username": user, + "password": password, + } + with PostgresContainer("postgres:16-alpine", driver=None, **kwargs) as container: + port = container.get_exposed_port(5432) + host = container.get_container_host_ip() + expected_url = f"postgresql://{user}:{password}@{host}:{port}/test" + + url = container.get_connection_url() + assert url == expected_url + + with PostgresContainer("postgres:16-alpine", **kwargs) as container: + port = container.get_exposed_port(5432) + host = container.get_container_host_ip() + expected_url = f"postgresql://{user}:{password}@{host}:{port}/test" + + url = container.get_connection_url(driver=None) + assert url == expected_url diff --git a/modules/qdrant/README.rst b/modules/qdrant/README.rst new file mode 100644 index 000000000..643096a37 --- /dev/null +++ b/modules/qdrant/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.qdrant.QdrantContainer +.. title:: testcontainers.qdrant.QdrantContainer diff --git a/modules/qdrant/testcontainers/qdrant/__init__.py b/modules/qdrant/testcontainers/qdrant/__init__.py new file mode 100644 index 000000000..d36fe62ee --- /dev/null +++ b/modules/qdrant/testcontainers/qdrant/__init__.py @@ -0,0 +1,156 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from functools import cached_property +from pathlib import Path +from typing import Optional + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class QdrantContainer(DbContainer): + """ + Qdrant vector database container. + + Example: + .. doctest:: + + >>> from testcontainers.qdrant import QdrantContainer + + >>> with QdrantContainer() as qdrant: + ... client = qdrant.get_client() + ... client.get_collections() + CollectionsResponse(collections=[]) + """ + + QDRANT_CONFIG_FILE_PATH = "/qdrant/config/config.yaml" + + def __init__( + self, + image: str = "qdrant/qdrant:v1.8.3", + rest_port: int = 6333, + grpc_port: int = 6334, + api_key: Optional[str] = None, + config_file_path: Optional[Path] = None, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self._rest_port = rest_port + self._grpc_port = grpc_port + self._api_key = api_key or os.getenv("QDRANT_CONTAINER_API_KEY") + + if config_file_path: + self.with_volume_mapping(host=str(config_file_path), container=QdrantContainer.QDRANT_CONFIG_FILE_PATH) + + self.with_exposed_ports(self._rest_port, self._grpc_port) + + def _configure(self) -> None: + self.with_env("QDRANT__SERVICE__API_KEY", self._api_key) + + @wait_container_is_ready() + def _connect(self) -> None: + wait_for_logs(self, ".*Actix runtime found; starting in Actix runtime.*", c.timeout) + + def get_client(self, **kwargs): + """ + Get a `qdrant_client.QdrantClient` instance associated with the container. + + Args: + **kwargs: Additional keyword arguments to be passed to the `qdrant_client.QdrantClient` constructor. + + Returns: + QdrantClient: An instance of the `qdrant_client.QdrantClient` class. + + """ + + try: + from qdrant_client import QdrantClient + except ImportError as e: + raise ImportError("To use the `get_client` method, you must install the `qdrant_client` package.") from e + return QdrantClient( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self._rest_port), + grpc_port=self.get_exposed_port(self._grpc_port), + api_key=self._api_key, + https=False, + **kwargs, + ) + + def get_async_client(self, **kwargs): + """ + Get a `qdrant_client.AsyncQdrantClient` instance associated with the container. + + Args: + **kwargs: Additional keyword arguments to be passed to the `qdrant_client.AsyncQdrantClient` constructor. + + Returns: + QdrantClient: An instance of the `qdrant_client.AsyncQdrantClient` class. + + """ + + try: + from qdrant_client import AsyncQdrantClient + except ImportError as e: + raise ImportError( + "To use the `get_async_client` method, you must install the `qdrant_client` package." + ) from e + return AsyncQdrantClient( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self._rest_port), + grpc_port=self.get_exposed_port(self._grpc_port), + api_key=self._api_key, + https=False, + **kwargs, + ) + + @cached_property + def rest_host_address(self) -> str: + """ + Get the REST host address of the Qdrant container. + + Returns: + str: The REST host address of the Qdrant container. + """ + return f"{self.get_container_host_ip()}:{self.exposed_rest_port}" + + @cached_property + def grpc_host_address(self) -> str: + """ + Get the GRPC host address of the Qdrant container. + + Returns: + str: The GRPC host address of the Qdrant container. + """ + return f"{self.get_container_host_ip()}:{self.exposed_grpc_port}" + + @cached_property + def exposed_rest_port(self) -> int: + """ + Get the exposed REST port of the Qdrant container. + + Returns: + int: The REST port of the Qdrant container. + """ + return self.get_exposed_port(self._rest_port) + + @cached_property + def exposed_grpc_port(self) -> int: + """ + Get the exposed GRPC port of the Qdrant container. + + Returns: + int: The GRPC port of the Qdrant container. + """ + return self.get_exposed_port(self._grpc_port) diff --git a/modules/qdrant/tests/test_config.yaml b/modules/qdrant/tests/test_config.yaml new file mode 100644 index 000000000..7b13dabd1 --- /dev/null +++ b/modules/qdrant/tests/test_config.yaml @@ -0,0 +1,6 @@ +# Qdrant image configuration file for testing +# Reference: https://qdrant.tech/documentation/guides/configuration/#configuration-file-example +log_level: INFO + +service: + api_key: "SOME_TEST_KEY" diff --git a/modules/qdrant/tests/test_qdrant.py b/modules/qdrant/tests/test_qdrant.py new file mode 100644 index 000000000..d3b59e57c --- /dev/null +++ b/modules/qdrant/tests/test_qdrant.py @@ -0,0 +1,80 @@ +import pytest +from testcontainers.qdrant import QdrantContainer +import uuid +from grpc import RpcError +from pathlib import Path + +import qdrant_client + + +def test_docker_run_qdrant(): + with QdrantContainer() as qdrant: + client = qdrant.get_client() + collections = client.get_collections().collections + assert len(collections) == 0 + + client = qdrant.get_client(prefer_grpc=True) + collections = client.get_collections().collections + assert len(collections) == 0 + + +def test_qdrant_with_api_key_http(): + api_key = uuid.uuid4().hex + + with QdrantContainer(api_key=api_key) as qdrant: + with pytest.raises(qdrant_client.http.exceptions.UnexpectedResponse) as e: + # Construct a client without an API key + qdrant_client.QdrantClient(location=f"http://{qdrant.rest_host_address}").get_collections() + + assert "Must provide an API key" in str(e.value) + + # Construct a client with an API key + collections = ( + qdrant_client.QdrantClient(location=f"http://{qdrant.rest_host_address}", api_key=api_key) + .get_collections() + .collections + ) + + assert len(collections) == 0 + + # Get an automatically configured client instance + collections = qdrant.get_client().get_collections().collections + + assert len(collections) == 0 + + +def test_qdrant_with_api_key_grpc(): + api_key = uuid.uuid4().hex + + with QdrantContainer(api_key=api_key) as qdrant: + with pytest.raises(RpcError) as e: + qdrant_client.QdrantClient( + url=f"http://{qdrant.grpc_host_address}", + grpc_port=qdrant.exposed_grpc_port, + prefer_grpc=True, + ).get_collections() + + assert "Must provide an API key" in str(e.value) + + collections = ( + qdrant_client.QdrantClient( + url=f"http://{qdrant.grpc_host_address}", + grpc_port=qdrant.exposed_grpc_port, + prefer_grpc=True, + api_key=api_key, + ) + .get_collections() + .collections + ) + + assert len(collections) == 0 + + +def test_qdrant_with_config_file(): + config_file_path = Path(__file__).with_name("test_config.yaml") + + with QdrantContainer(config_file_path=config_file_path) as qdrant: + with pytest.raises(qdrant_client.http.exceptions.UnexpectedResponse) as e: + qdrant_client.QdrantClient(location=f"http://{qdrant.rest_host_address}").get_collections() + + assert "Must provide an API key" in str(e.value) diff --git a/modules/rabbitmq/README.rst b/modules/rabbitmq/README.rst new file mode 100644 index 000000000..1b31362d3 --- /dev/null +++ b/modules/rabbitmq/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.rabbitmq.RabbitMqContainer +.. title:: testcontainers.rabbitmq.RabbitMqContainer diff --git a/modules/rabbitmq/testcontainers/rabbitmq/__init__.py b/modules/rabbitmq/testcontainers/rabbitmq/__init__.py new file mode 100644 index 000000000..cd7b079a4 --- /dev/null +++ b/modules/rabbitmq/testcontainers/rabbitmq/__init__.py @@ -0,0 +1,85 @@ +import os +from typing import Optional + +import pika + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class RabbitMqContainer(DockerContainer): + """ + Test container for RabbitMQ. The example below spins up a RabbitMQ broker and uses the + `pika client library <(https://pypi.org/project/pika/)>`__ to establish a connection to the + broker. + + Example: + + .. doctest:: + + >>> import pika + >>> from testcontainers.rabbitmq import RabbitMqContainer + + >>> with RabbitMqContainer("rabbitmq:3.9.10") as rabbitmq: + ... connection = pika.BlockingConnection(rabbitmq.get_connection_params()) + ... channel = connection.channel() + """ + + def __init__( + self, + image: str = "rabbitmq:latest", + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + vhost: Optional[str] = None, + **kwargs, + ) -> None: + """Initialize the RabbitMQ test container. + + Args: + image: Docker image from docker hub. Defaults to "rabbitmq:latest". + port: Port to reach the AMQP API. Defaults to 5672. + username: RabbitMQ username. + password: RabbitMQ password. + """ + super().__init__(image=image, **kwargs) + self.port = port or int(os.environ.get("RABBITMQ_NODE_PORT", 5672)) + self.username = username or os.environ.get("RABBITMQ_DEFAULT_USER", "guest") + self.password = password or os.environ.get("RABBITMQ_DEFAULT_PASS", "guest") + self.vhost = vhost or os.environ.get("RABBITMQ_DEFAULT_VHOST", "/") + + self.with_exposed_ports(self.port) + self.with_env("RABBITMQ_NODE_PORT", self.port) + self.with_env("RABBITMQ_DEFAULT_USER", self.username) + self.with_env("RABBITMQ_DEFAULT_PASS", self.password) + self.with_env("RABBITMQ_DEFAULT_VHOST", self.vhost) + + @wait_container_is_ready(pika.exceptions.IncompatibleProtocolError, pika.exceptions.AMQPConnectionError) + def readiness_probe(self) -> bool: + """Test if the RabbitMQ broker is ready.""" + connection = pika.BlockingConnection(self.get_connection_params()) + if connection.is_open: + connection.close() + return self + raise RuntimeError("Could not open connection to RabbitMQ broker.") + + def get_connection_params(self) -> pika.ConnectionParameters: + """ + Get connection params as a pika.ConnectionParameters object. + For more details see: + https://pika.readthedocs.io/en/latest/modules/parameters.html + """ + credentials = pika.PlainCredentials(username=self.username, password=self.password) + + return pika.ConnectionParameters( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self.port), + virtual_host=self.vhost, + credentials=credentials, + ) + + def start(self) -> "RabbitMqContainer": + """Start the test container.""" + super().start() + self.readiness_probe() + return self diff --git a/tests/test_rabbitmq.py b/modules/rabbitmq/tests/test_rabbitmq.py similarity index 72% rename from tests/test_rabbitmq.py rename to modules/rabbitmq/tests/test_rabbitmq.py index 08427a861..c0c1894d8 100644 --- a/tests/test_rabbitmq.py +++ b/modules/rabbitmq/tests/test_rabbitmq.py @@ -1,8 +1,9 @@ -from typing import Optional import json +from typing import Optional import pika import pytest + from testcontainers.rabbitmq import RabbitMqContainer QUEUE = "test-q" @@ -12,17 +13,16 @@ @pytest.mark.parametrize( - "port,username,password", - [ - (None, None, None), # use the defaults - (5673, None, None), # test with custom port - (None, "my_test_user", "my_secret_password"), # test with custom credentials - ] + argnames=["port", "username", "password", "vhost"], + argvalues=[ + [None, None, None, None], # use the defaults + [5673, None, None, None], # test with custom port + [None, "my_test_user", "my_secret_password", None], # test with custom credentials + [None, None, None, "vhost"], # test with custom vhost + ], ) def test_docker_run_rabbitmq( - port: Optional[int], - username: Optional[str], - password: Optional[str] + port: Optional[int], username: Optional[str], password: Optional[str], vhost: Optional[str] ): """Run rabbitmq test container and use it to deliver a simple message.""" kwargs = {} @@ -32,6 +32,8 @@ def test_docker_run_rabbitmq( kwargs["username"] = username if password is not None: kwargs["password"] = password + if vhost is not None: + kwargs["vhost"] = vhost rabbitmq_container = RabbitMqContainer("rabbitmq:latest", **kwargs) with rabbitmq_container as rabbitmq: @@ -45,7 +47,7 @@ def test_docker_run_rabbitmq( channel.queue_declare(QUEUE, arguments={}) channel.queue_bind(QUEUE, EXCHANGE, ROUTING_KEY) - # pulish message: + # publish message: encoded_message = json.dumps(MESSAGE) channel.basic_publish(EXCHANGE, ROUTING_KEY, body=encoded_message) diff --git a/modules/redis/README.rst b/modules/redis/README.rst new file mode 100644 index 000000000..1dfcf9e35 --- /dev/null +++ b/modules/redis/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.redis.RedisContainer +.. title:: testcontainers.redis.RedisContainer diff --git a/modules/redis/testcontainers/redis/__init__.py b/modules/redis/testcontainers/redis/__init__.py new file mode 100644 index 000000000..7a4d46613 --- /dev/null +++ b/modules/redis/testcontainers/redis/__init__.py @@ -0,0 +1,98 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Optional + +import redis +from redis.asyncio import Redis as asyncRedis +from testcontainers.core.container import DockerContainer +from testcontainers.core.utils import raise_for_deprecated_parameter +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class RedisContainer(DockerContainer): + """ + Redis container. + + Example: + + .. doctest:: + + >>> from testcontainers.redis import RedisContainer + + >>> with RedisContainer() as redis_container: + ... redis_client = redis_container.get_client() + """ + + def __init__(self, image: str = "redis:latest", port: int = 6379, password: Optional[str] = None, **kwargs) -> None: + raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") + super().__init__(image, **kwargs) + self.port = port + self.password = password + self.with_exposed_ports(self.port) + if self.password: + self.with_command(f"redis-server --requirepass {self.password}") + + @wait_container_is_ready(redis.exceptions.ConnectionError) + def _connect(self) -> None: + client = self.get_client() + if not client.ping(): + raise redis.exceptions.ConnectionError("Could not connect to Redis") + + def get_client(self, **kwargs) -> redis.Redis: + """ + Get a redis client. + + Args: + **kwargs: Keyword arguments passed to `redis.Redis`. + + Returns: + client: Redis client to connect to the container. + """ + return redis.Redis( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self.port), + password=self.password, + **kwargs, + ) + + def start(self) -> "RedisContainer": + super().start() + self._connect() + return self + + +class AsyncRedisContainer(RedisContainer): + """ + Redis container. + + Example + ------- + .. doctest:: + + >>> from testcontainers.redis import AsyncRedisContainer + + >>> with AsyncRedisContainer() as redis_container: + ... redis_client =await redis_container.get_async_client() + """ + + def __init__(self, image="redis:latest", port_to_expose=6379, password=None, **kwargs): + super().__init__(image, port_to_expose, password, **kwargs) + + async def get_async_client(self, **kwargs): + return await asyncRedis( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self.port), + password=self.password, + **kwargs, + ) diff --git a/modules/redis/tests/test_redis.py b/modules/redis/tests/test_redis.py new file mode 100644 index 000000000..bd8e244c5 --- /dev/null +++ b/modules/redis/tests/test_redis.py @@ -0,0 +1,79 @@ +import time + +from testcontainers.redis import RedisContainer, AsyncRedisContainer +import pytest + + +def test_docker_run_redis(): + config = RedisContainer() + with config as redis: + client = redis.get_client() + p = client.pubsub() + p.subscribe("test") + client.publish("test", "new_msg") + msg = wait_for_message(p) + assert "data" in msg + assert b"new_msg", msg["data"] + + +def test_docker_run_redis_with_password(): + config = RedisContainer(password="mypass") + with config as redis: + client = redis.get_client(decode_responses=True) + client.set("hello", "world") + assert client.get("hello") == "world" + + +pytest.mark.usefixtures("anyio_backend") + + +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +async def test_key_set_in_async_redis(anyio_backend): + with AsyncRedisContainer() as container: + async_redis_client: redis.Redis = await container.get_async_client(decode_responses=True) + key = "key" + expected_value = 1 + await async_redis_client.set(key, expected_value) + actual_value = await async_redis_client.get(key) + assert int(actual_value) == expected_value + + +pytest.mark.usefixtures("anyio_backend") + + +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +@pytest.mark.skip(reason="Need to sort out async pub/sub") +async def test_docker_run_async_redis(anyio_backend): + config = AsyncRedisContainer() + with config as container: + client: redis.Redis = await container.get_async_client(decode_responses=True) + p = await client.pubsub() + await p.subscribe("test") + await client.publish("test", "new_msg") + msg = wait_for_message(p) + assert "data" in msg + assert b"new_msg", msg["data"] + + +pytest.mark.usefixtures("anyio_backend") + + +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +async def test_docker_run_async_redis_with_password(anyio_backend): + config = AsyncRedisContainer(password="mypass") + with config as container: + client: redis.Redis = await container.get_async_client(decode_responses=True) + await client.set("hello", "world") + assert await client.get("hello") == "world" + + +def wait_for_message(pubsub, timeout=1, ignore_subscribe_messages=True): + now = time.time() + timeout = now + timeout + while now < timeout: + message = pubsub.get_message(ignore_subscribe_messages=ignore_subscribe_messages) + if message is not None: + return message + time.sleep(0.01) + now = time.time() + return None diff --git a/modules/registry/README.rst b/modules/registry/README.rst new file mode 100644 index 000000000..f503f338e --- /dev/null +++ b/modules/registry/README.rst @@ -0,0 +1,8 @@ +.. autoclass:: testcontainers.registry.DockerRegistryContainer + +When building Docker containers with Docker Buildx there is currently no option to test your containers locally without +a local registry. Otherwise Buildx pushes your image to Docker Hub, which is not what you want in a test case. More +and more you need to use Buildx for efficiently building images and especially multi arch images. + +When you use Docker Python libraries like docker-py or python-on-whales to build and test Docker images, what a lot of +persons and DevOps engineers like me nowadays do, a test container comes in very handy. diff --git a/modules/registry/testcontainers/registry/__init__.py b/modules/registry/testcontainers/registry/__init__.py new file mode 100644 index 000000000..7b846ad5c --- /dev/null +++ b/modules/registry/testcontainers/registry/__init__.py @@ -0,0 +1,77 @@ +import time +from io import BytesIO +from tarfile import TarFile, TarInfo +from typing import TYPE_CHECKING, Optional + +import bcrypt +from requests import get +from requests.auth import HTTPBasicAuth +from requests.exceptions import ConnectionError, ReadTimeout + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + +if TYPE_CHECKING: + from requests import Response + + +class DockerRegistryContainer(DockerContainer): + # https://docs.docker.com/registry/ + credentials_path: str = "/htpasswd/credentials.txt" + + def __init__( + self, + image: str = "registry:2", + port: int = 5000, + username: Optional[str] = None, + password: Optional[str] = None, + **kwargs, + ) -> None: + super().__init__(image=image, **kwargs) + self.port: int = port + self.username: Optional[str] = username + self.password: Optional[str] = password + self.with_exposed_ports(self.port) + + def _copy_credentials(self) -> None: + # Create credentials and write them to the container + hashed_password: str = bcrypt.hashpw( + self.password.encode("utf-8"), + bcrypt.gensalt(rounds=12, prefix=b"2a"), + ).decode("utf-8") + content: bytes = f"{self.username}:{hashed_password}".encode("utf-8") # noqa: UP012 + + with BytesIO() as tar_archive_object, TarFile(fileobj=tar_archive_object, mode="w") as tmp_tarfile: + tarinfo: TarInfo = TarInfo(name=self.credentials_path) + tarinfo.size = len(content) + tarinfo.mtime = time.time() + + tmp_tarfile.addfile(tarinfo, BytesIO(content)) + tar_archive_object.seek(0) + self.get_wrapped_container().put_archive("/", tar_archive_object) + + @wait_container_is_ready(ConnectionError, ReadTimeout) + def _readiness_probe(self) -> None: + url: str = f"http://{self.get_registry()}/v2" + if self.username and self.password: + response: Response = get(url, auth=HTTPBasicAuth(self.username, self.password), timeout=1) + else: + response: Response = get(url, timeout=1) + response.raise_for_status() + + def start(self): + if self.username and self.password: + self.with_env("REGISTRY_AUTH_HTPASSWD_REALM", "local-registry") + self.with_env("REGISTRY_AUTH_HTPASSWD_PATH", self.credentials_path) + super().start() + self._copy_credentials() + else: + super().start() + + self._readiness_probe() + return self + + def get_registry(self) -> str: + host: str = self.get_container_host_ip() + port: str = self.get_exposed_port(self.port) + return f"{host}:{port}" diff --git a/modules/registry/tests/test_registry.py b/modules/registry/tests/test_registry.py new file mode 100644 index 000000000..0aa568ee5 --- /dev/null +++ b/modules/registry/tests/test_registry.py @@ -0,0 +1,27 @@ +from requests import Response, get +from requests.auth import HTTPBasicAuth +from testcontainers.registry import DockerRegistryContainer + + +REGISTRY_USERNAME: str = "foo" +REGISTRY_PASSWORD: str = "bar" + + +def test_registry(): + with DockerRegistryContainer().with_bind_ports(5000, 5000) as registry_container: + url: str = f"http://{registry_container.get_registry()}/v2/_catalog" + + response: Response = get(url) + + assert response.status_code == 200 + + +def test_registry_with_authentication(): + with DockerRegistryContainer(username=REGISTRY_USERNAME, password=REGISTRY_PASSWORD).with_bind_ports( + 5000, 5000 + ) as registry_container: + url: str = f"http://{registry_container.get_registry()}/v2/_catalog" + + response: Response = get(url, auth=HTTPBasicAuth(REGISTRY_USERNAME, REGISTRY_PASSWORD)) + + assert response.status_code == 200 diff --git a/modules/scylla/README.rst b/modules/scylla/README.rst new file mode 100644 index 000000000..fd1ea03fb --- /dev/null +++ b/modules/scylla/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.scylla.ScyllaContainer +.. title:: testcontainers.scylla.ScyllaContainer diff --git a/modules/scylla/testcontainers/scylla/__init__.py b/modules/scylla/testcontainers/scylla/__init__.py new file mode 100644 index 000000000..ca0f44afb --- /dev/null +++ b/modules/scylla/testcontainers/scylla/__init__.py @@ -0,0 +1,47 @@ +from testcontainers.core.config import MAX_TRIES +from testcontainers.core.generic import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + + +class ScyllaContainer(DockerContainer): + """ + Scylla database container. + + Example + ------- + .. doctest:: + + >>> from testcontainers.scylla import ScyllaContainer + + >>> with ScyllaContainer() as scylla: + ... cluster = scylla.get_cluster() + ... with cluster.connect() as session: + ... result = session.execute( + ... "CREATE KEYSPACE keyspace1 WITH replication " + ... "= {'class': 'SimpleStrategy', 'replication_factor': '1'};") + """ + + def __init__(self, image="scylladb/scylla:latest", ports_to_expose=(9042,)): + super().__init__(image) + self.ports_to_expose = ports_to_expose + self.with_exposed_ports(*self.ports_to_expose) + self.with_command("--skip-wait-for-gossip-to-settle=0") + + @wait_container_is_ready() + def _connect(self): + wait_for_logs(self, predicate="Starting listening for CQL clients", timeout=MAX_TRIES) + cluster = self.get_cluster() + cluster.connect() + + def start(self): + super().start() + self._connect() + return self + + def get_cluster(self, **kwargs): + from cassandra.cluster import Cluster + + container = self.get_wrapped_container() + container.reload() + hostname = container.attrs["NetworkSettings"]["IPAddress"] + return Cluster(contact_points=[hostname], **kwargs) diff --git a/modules/scylla/tests/test_scylla.py b/modules/scylla/tests/test_scylla.py new file mode 100644 index 000000000..3d1ecf44d --- /dev/null +++ b/modules/scylla/tests/test_scylla.py @@ -0,0 +1,18 @@ +from testcontainers.scylla import ScyllaContainer + + +def test_docker_run_scylla(): + with ScyllaContainer() as scylla: + cluster = scylla.get_cluster() + with cluster.connect() as session: + session.execute( + "CREATE KEYSPACE keyspace1 WITH replication = " + "{'class': 'SimpleStrategy', 'replication_factor': '1'};" + ) + session.execute("CREATE TABLE keyspace1.table1 (key1 int, key2 int, PRIMARY KEY (key1));") + session.execute("INSERT INTO keyspace1.table1 (key1,key2) values (1,2);") + + response = session.execute("SELECT * FROM keyspace1.table1") + + assert response.one().key1 == 1 + assert response.one().key2 == 2 diff --git a/modules/selenium/README.rst b/modules/selenium/README.rst new file mode 100644 index 000000000..5beef79cd --- /dev/null +++ b/modules/selenium/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.selenium.BrowserWebDriverContainer +.. title:: testcontainers.selenium.BrowserWebDriverContainer diff --git a/modules/selenium/testcontainers/selenium/__init__.py b/modules/selenium/testcontainers/selenium/__init__.py new file mode 100644 index 000000000..53305ef93 --- /dev/null +++ b/modules/selenium/testcontainers/selenium/__init__.py @@ -0,0 +1,131 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from pathlib import Path +from typing import Any, Optional + +import urllib3 +from typing_extensions import Self + +from selenium import webdriver +from selenium.webdriver.common.options import ArgOptions +from testcontainers.core.container import DockerContainer +from testcontainers.core.network import Network +from testcontainers.core.waiting_utils import wait_container_is_ready +from testcontainers.selenium.video import SeleniumVideoContainer + +IMAGES = {"firefox": "selenium/standalone-firefox:latest", "chrome": "selenium/standalone-chrome:latest"} + + +def get_image_name(capabilities: dict[str, Any]) -> str: + return IMAGES[capabilities["browserName"]] + + +class BrowserWebDriverContainer(DockerContainer): + """ + Selenium browser container for Chrome or Firefox. + + Example: + + .. doctest:: + + >>> from testcontainers.selenium import BrowserWebDriverContainer + >>> from selenium.webdriver import DesiredCapabilities + + >>> with BrowserWebDriverContainer(DesiredCapabilities.CHROME) as chrome: + ... webdriver = chrome.get_driver() + + You can easily change browser by passing :code:`DesiredCapabilities.FIREFOX` instead. + """ + + def __init__( + self, + capabilities: dict[str, Any], + options: Optional[ArgOptions] = None, + image: Optional[str] = None, + port: int = 4444, + vnc_port: int = 5900, + **kwargs, + ) -> None: + self.capabilities = capabilities + self.options = options + self.image = image or get_image_name(capabilities) + self.port = port + self.vnc_port = vnc_port + self.video = None + self.__video_network = None + super().__init__(image=self.image, **kwargs) + self.with_exposed_ports(self.port, self.vnc_port) + + def _configure(self) -> None: + self.with_env("no_proxy", "localhost") + self.with_env("HUB_ENV_no_proxy", "localhost") + + @wait_container_is_ready(urllib3.exceptions.HTTPError) + def _connect(self) -> webdriver.Remote: + options = ArgOptions() if self.options is None else self.options + for key, value in self.capabilities.items(): + options.set_capability(key, value) + return webdriver.Remote(command_executor=(self.get_connection_url()), options=options) + + def get_driver(self) -> webdriver.Remote: + return self._connect() + + def get_connection_url(self) -> str: + ip = self.get_container_host_ip() + port = self.get_exposed_port(self.port) + return f"http://{ip}:{port}/wd/hub" + + def with_options(self, options: Optional[ArgOptions]): + self.options = options + return self + + def with_video(self, image: Optional[str] = None, video_path: Optional[Path] = None) -> Self: + video_path = video_path or Path.cwd() + + self.video = SeleniumVideoContainer(image) + + video_folder_path = video_path.parent if video_path.suffix else video_path + self.video.set_videos_host_path(str(video_folder_path.resolve())) + + if video_path.name: + self.video.set_video_name(video_path.name) + + return self + + def start(self) -> "DockerContainer": + if not self.video: + super().start() + return self + + self.__video_network = Network().__enter__() + + self.with_kwargs(network=self.__video_network.name) + super().start() + + self.video.with_kwargs(network=self.__video_network.name).set_selenium_container_host( + self.get_wrapped_container().short_id + ).start() + + return self + + def stop(self, force=True, delete_volume=True) -> None: + if self.video: + # get_wrapped_container().stop -> stop the container + # video.stop -> remove the container + self.video.get_wrapped_container().stop() + self.video.stop(force, delete_volume) + + super().stop(force, delete_volume) + + if self.__video_network: + self.__video_network.remove() diff --git a/modules/selenium/testcontainers/selenium/video.py b/modules/selenium/testcontainers/selenium/video.py new file mode 100644 index 000000000..debf098db --- /dev/null +++ b/modules/selenium/testcontainers/selenium/video.py @@ -0,0 +1,39 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from typing import Optional + +from testcontainers.core.container import DockerContainer + +VIDEO_DEFAULT_IMAGE = "selenium/video:ffmpeg-6.1-20240402" + + +class SeleniumVideoContainer(DockerContainer): + """ + Selenium video container. + """ + + def __init__(self, image: Optional[str] = None, **kwargs) -> None: + self.image = image or VIDEO_DEFAULT_IMAGE + super().__init__(image=self.image, **kwargs) + + def set_video_name(self, video_name: str) -> "DockerContainer": + self.with_env("FILE_NAME", video_name) + return self + + def set_videos_host_path(self, host_path: str) -> "DockerContainer": + self.with_volume_mapping(host_path, "/videos", "rw") + return self + + def set_selenium_container_host(self, host: str) -> "DockerContainer": + self.with_env("DISPLAY_CONTAINER_NAME", host) + return self diff --git a/modules/selenium/tests/test_selenium.py b/modules/selenium/tests/test_selenium.py new file mode 100644 index 000000000..ac243c27b --- /dev/null +++ b/modules/selenium/tests/test_selenium.py @@ -0,0 +1,47 @@ +import os +import tempfile +from pathlib import Path + +import pytest +from selenium.webdriver import DesiredCapabilities +from selenium.webdriver.common.by import By + +from testcontainers.core.utils import is_arm +from testcontainers.selenium import BrowserWebDriverContainer + + +@pytest.mark.parametrize("caps", [DesiredCapabilities.CHROME, DesiredCapabilities.FIREFOX]) +def test_webdriver_container_container(caps): + if is_arm(): + pytest.skip("https://github.com/SeleniumHQ/docker-selenium/issues/1076") + + with BrowserWebDriverContainer(caps).maybe_emulate_amd64() as chrome: + webdriver = chrome.get_driver() + webdriver.get("http://example.com") + header = webdriver.find_element(By.TAG_NAME, "h1").text + assert header == "Example Domain" + + +def test_selenium_custom_image(): + image = "selenium/standalone-chrome:latest" + chrome = BrowserWebDriverContainer(DesiredCapabilities.CHROME, image=image) + assert "image" in dir(chrome), "`image` attribute was not instantialized." + assert chrome.image == image, "`image` attribute was not set to the user provided value" + + +@pytest.mark.parametrize("caps", [DesiredCapabilities.CHROME, DesiredCapabilities.FIREFOX]) +def test_selenium_video(caps, workdir): + video_path = workdir / Path("video.mp4") + with BrowserWebDriverContainer(caps).with_video(video_path=video_path) as chrome: + chrome.get_driver().get("https://google.com") + + assert video_path.exists(), "Selenium video file does not exist" + + +@pytest.fixture +def workdir() -> Path: + tmpdir = tempfile.TemporaryDirectory() + # Enable write permissions for the Docker user container. + os.chmod(tmpdir.name, 0o777) + yield Path(tmpdir.name) + tmpdir.cleanup() diff --git a/modules/sftp/README.rst b/modules/sftp/README.rst new file mode 100644 index 000000000..2287d59c9 --- /dev/null +++ b/modules/sftp/README.rst @@ -0,0 +1,3 @@ +.. autoclass:: testcontainers.sftp.SFTPContainer +.. autoclass:: testcontainers.sftp.SFTPUser +.. title:: testcontainers.sftp.SFTPContainer diff --git a/modules/sftp/testcontainers/sftp/__init__.py b/modules/sftp/testcontainers/sftp/__init__.py new file mode 100644 index 000000000..0e073ea1a --- /dev/null +++ b/modules/sftp/testcontainers/sftp/__init__.py @@ -0,0 +1,301 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import os +import tempfile +from typing import TYPE_CHECKING, Any, NamedTuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +if TYPE_CHECKING: + from typing_extensions import Self + + +class SFTPUser: + """ + Helper class to define a user for SFTPContainer authentication. + + Constructor args/kwargs: + + * ``name``: (req.) username + * ``public_key``: (opt.) bytes of publickey + * ``private_key``: (opt.) bytes of privatekey (useful if you want to access \ + them later in test code) + * ``password``: (opt.) password + * ``uid``: (opt.) user ID + * ``gid``: (opt.) group ID + * ``folders``: (opt.) folders to create inside the user's directory (e.g. upload/) + * ``mount_dir``: (opt.) a local folder to mount to the user's root directory + + Properties: + + * ``public_key_file``: str path of public key tempfile (gets mounted to \ + SFTPContainer as a volume) + * ``private_key_file``: str path of private key tempfile (useful to pass to \ + paramiko when connecting to the sftp server using ssh + + Methods: + + * ``with_keypair``: classmethod to create a new user with an auto-generated RSA keypair + * ``conf``: str configuration string to register user on server + + + Example: + + .. doctest:: + + >>> from testcontainers.sftp import SFTPUser + + >>> users = [ + ... SFTPUser("jane", password="secret"), + ... SFTPUser.with_keypair("ron", folders=["stuff"]), + ... ] + + >>> for user in users: + ... print(user.name, user.folders[0]) + ... + jane upload + ron stuff + + >>> assert users[0].password == "secret" + + >>> assert users[1].public_key is not None + + >>> assert users[1].public_key.decode().startswith("ssh-rsa ") + + >>> assert users[1].private_key is not None + + >>> assert users[1].private_key.decode().startswith("-----BEGIN RSA PRIVATE KEY-----") + """ + + def __init__( + self, + name: str, + *, + public_key: bytes | None = None, + private_key: bytes | None = None, + password: str | None = None, + uid: str | None = None, + gid: str | None = None, + folders: list[str] | None = None, + mount_dir: str | None = None, + ) -> None: + if folders is None: + folders = ["upload"] + self.name = name + self.public_key = public_key + self.private_key = private_key + self.password = password + self.uid = uid + self.gid = gid + self.folders = folders + self.mount_dir = mount_dir + + self.public_key_file: str | None = None + if self.public_key is not None: + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(self.public_key) + self.public_key_file = f.name + + self.private_key_file: str | None = None + if self.private_key is not None: + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(self.private_key) + self.private_key_file = f.name + + def __del__(self) -> None: + """Clean up keypair temp files""" + if self.public_key_file is not None: + os.unlink(self.public_key_file) + if self.private_key_file is not None: + os.unlink(self.private_key_file) + + @property + def conf(self) -> str: + """Configuration string to register user on server""" + return ":".join( + [ + self.name, + self.password or "", + self.uid or "", + self.gid or "", + ",".join(self.folders), + ] + ) + + @classmethod + def with_keypair( + cls, + name: str, + password: str | None = None, + uid: str | None = None, + gid: str | None = None, + folders: list[str] | None = None, + mount_dir: str | None = None, + ) -> SFTPUser: + """Construct a new SFTPUser with an auto-generated RSA keypair""" + keypair = _generate_keypair() + return SFTPUser( + name=name, + public_key=keypair.public_key, + private_key=keypair.private_key, + password=password, + uid=uid, + gid=gid, + folders=folders, + mount_dir=mount_dir, + ) + + def __repr__(self) -> str: + return ( + f"SFTPUser({self.name}, password={self.password}, uid={self.uid}," + f" gid={self.gid}, folders={self.folders}," + f" public_key_file={self.public_key_file}," + f" private_key_file={self.private_key_file})" + ) + + +class SFTPContainer(DockerContainer): + """Test container for an SFTP server. + + Default configuration creates two users, ``basic:password`` and ``keypair`` + which has no password but should use the private key accessible at + ``my_container.users[1].private_key``. + + **Users can only download from their root user folder, but can upload & + download from any subfolder** (``upload/`` by default). + + Options: + + * ``users = [SFTPUser("jane", password="secret"), SFTPUser.with_keypair("ron")]`` \ + creates ``jane:secret`` or ``ron`` who uses the private key accessible at \ + ``users[1].private_key``. + + Simple example with basic auth: + + .. doctest:: + + >>> import paramiko + + >>> from testcontainers.sftp import SFTPContainer + + >>> with SFTPContainer() as sftp_container: + ... host_ip = sftp_container.get_container_host_ip() + ... host_port = sftp_container.get_exposed_sftp_port() + ... ssh = paramiko.SSHClient() + ... ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ... ssh.connect(host_ip, host_port, "basic", "password") + ... # ssh.get(...) + ... # ssh.listdir() + ... # ssh.chdir("upload") + ... # ssh.put(...) + + Example with keypair auth: + + .. doctest:: + + >>> import paramiko + + >>> from testcontainers.sftp import SFTPContainer + + >>> with SFTPContainer() as sftp_container: + ... host_ip = sftp_container.get_container_host_ip() + ... host_port = sftp_container.get_exposed_sftp_port() + ... ssh = paramiko.SSHClient() + ... ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ... private_key_file = sftp_container.users[1].private_key_file + ... ssh.connect(host_ip, host_port, "keypair", key_filename=private_key_file) + ... # ssh.listdir() + ... # ssh.get(...) + ... # ssh.chdir("upload") + ... # ssh.put(...) + """ + + def __init__( + self, + image: str = "atmoz/sftp:alpine", + port: int = 22, + *, + users: list[SFTPUser] | None = None, + **kwargs: Any, + ) -> None: + if users is None: + users = [ + SFTPUser(name="basic", password="password"), + SFTPUser.with_keypair(name="keypair"), + ] + + super().__init__(image=image, **kwargs) + self.port = port + self.users = users + + @property + def _users_conf(self) -> str: + return " ".join(user.conf for user in self.users) + + def _configure(self) -> None: + for user in self.users: + if user.public_key_file is not None: + self.with_volume_mapping( + user.public_key_file, + f"/home/{user.name}/.ssh/keys/{user.name}.pub", + ) + if user.mount_dir is not None: + self.with_volume_mapping( + user.mount_dir, + f"/home/{user.name}/", + "rw", + ) + self.with_env("SFTP_USERS", self._users_conf) + self.with_exposed_ports(self.port) + + def start(self) -> Self: + super().start() + wait_for_logs(self, f".*Server listening on 0.0.0.0 port {self.port}.*") + return self + + def get_exposed_sftp_port(self) -> int: + return int(self.get_exposed_port(self.port)) + + +class _Keypair(NamedTuple): + """RSA keypair as bytes""" + + private_key: bytes + public_key: bytes + + +def _generate_keypair() -> _Keypair: + """Generate RSA keypair as bytes in OpenSSH format.""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + public_key_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, # paramiko flakiness fix + format=serialization.PublicFormat.OpenSSH, + ) + return _Keypair( + private_key=private_key_bytes, + public_key=public_key_bytes, + ) diff --git a/modules/sftp/testcontainers/sftp/py.typed b/modules/sftp/testcontainers/sftp/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/modules/sftp/tests/test_sftp.py b/modules/sftp/tests/test_sftp.py new file mode 100644 index 000000000..e3dab2e3b --- /dev/null +++ b/modules/sftp/tests/test_sftp.py @@ -0,0 +1,159 @@ +import tempfile +from pathlib import Path + +import paramiko +import pytest + +from testcontainers.sftp import SFTPContainer, SFTPUser + + +def test_sftp_login_with_default_basic_auth(): + with SFTPContainer() as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=sftp_container.users[0].name, + password=sftp_container.users[0].password, + ) + + +def test_sftp_login_with_default_keypair_auth(): + with SFTPContainer() as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=sftp_container.users[1].name, + key_filename=sftp_container.users[1].private_key_file, + ) + + +def test_sftp_login_with_custom_user_basic_auth(): + user = SFTPUser(name="custom", password="custom_password") + with SFTPContainer(users=[user]) as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=user.name, + password=user.password, + ) + + +def test_sftp_login_with_custom_user_keypair_auth(): + user = SFTPUser.with_keypair(name="custom") + with SFTPContainer(users=[user]) as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=user.name, + key_filename=user.private_key_file, + ) + + +def test_sftp_login_with_custom_user_password_and_keypair_auth(): + user = SFTPUser.with_keypair(name="custom", password="custom_password") + with SFTPContainer(users=[user]) as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=user.name, + password=user.password, + key_filename=user.private_key_file, + ) + + +def test_sftp_user_can_upload(): + with SFTPContainer() as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=sftp_container.users[0].name, + password=sftp_container.users[0].password, + ) + sftp = ssh.open_sftp() + sftp.chdir("upload") + with tempfile.NamedTemporaryFile() as f: + f.write(b"test") + f.seek(0) + sftp.put(f.name, "test.txt") + + with tempfile.NamedTemporaryFile() as f: + sftp.get("test.txt", f.name) + f.seek(0) + assert f.read() == b"test" + + +def test_sftp_user_can_download_from_mounted(tmp_path: Path): + temp_dir = tmp_path / "sub" + temp_dir.mkdir() + temp_file = temp_dir / "test.txt" + temp_file.write_text("test") + user = SFTPUser.with_keypair(name="custom", mount_dir=temp_dir.as_posix()) + with SFTPContainer(users=[user]) as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=user.name, + key_filename=user.private_key_file, + ) + sftp = ssh.open_sftp() + with tempfile.NamedTemporaryFile() as f: + sftp.get(temp_file.name, f.name) + f.seek(0) + assert f.read() == b"test" + + +def test_sftp_user_cant_upload_to_root(tmp_path: Path): + temp_dir = tmp_path / "sub" + temp_dir.mkdir() + temp_file = temp_dir / "test.txt" + temp_file.write_text("test") + with SFTPContainer() as sftp_container: + sftp_container.start() + host_ip = sftp_container.get_container_host_ip() + host_port = sftp_container.get_exposed_sftp_port() + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=host_ip, + port=host_port, + username=sftp_container.users[0].name, + password=sftp_container.users[0].password, + ) + sftp = ssh.open_sftp() + with pytest.raises(PermissionError): + sftp.put(temp_file.as_posix(), temp_file.name) diff --git a/modules/test b/modules/test new file mode 100644 index 000000000..e69de29bb diff --git a/modules/test_module_import/README.rst b/modules/test_module_import/README.rst new file mode 100644 index 000000000..3d2e7543a --- /dev/null +++ b/modules/test_module_import/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.test_module_import.NewSubModuleContainer +.. title:: testcontainers.test_module_import.NewSubModuleContainer diff --git a/modules/test_module_import/testcontainers/test_module_import/__init__.py b/modules/test_module_import/testcontainers/test_module_import/__init__.py new file mode 100644 index 000000000..74074699e --- /dev/null +++ b/modules/test_module_import/testcontainers/test_module_import/__init__.py @@ -0,0 +1 @@ +from .new_sub_module import NewSubModuleContainer # noqa: F401 diff --git a/modules/test_module_import/testcontainers/test_module_import/new_sub_module.py b/modules/test_module_import/testcontainers/test_module_import/new_sub_module.py new file mode 100644 index 000000000..6f25d2777 --- /dev/null +++ b/modules/test_module_import/testcontainers/test_module_import/new_sub_module.py @@ -0,0 +1,27 @@ +from testcontainers.generic.server import ServerContainer + + +class NewSubModuleContainer(ServerContainer): + """ + This class is a mock container for testing purposes. It is used to test importing from other modules. + + .. doctest:: + + >>> import httpx + >>> from testcontainers.core.image import DockerImage + >>> from testcontainers.test_module_import import NewSubModuleContainer + + >>> with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-new-mod:latest") as image: + ... with NewSubModuleContainer(port=9000, image=image) as new_mod: + ... url = new_mod._create_connection_url() + ... response = httpx.get(f"{url}", timeout=5) + ... assert response.status_code == 200, "Response status code is not 200" + ... assert new_mod.additional_capability() == "NewSubModuleContainer" + + """ + + def __init__(self, port: int, image: str) -> None: + super().__init__(port, image) + + def additional_capability(self) -> str: + return "NewSubModuleContainer" diff --git a/modules/test_module_import/tests/test_mock_one.py b/modules/test_module_import/tests/test_mock_one.py new file mode 100644 index 000000000..915c95b11 --- /dev/null +++ b/modules/test_module_import/tests/test_mock_one.py @@ -0,0 +1,14 @@ +import httpx + +from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.core.image import DockerImage +from testcontainers.test_module_import import NewSubModuleContainer + + +def test_like_doctest(): + with DockerImage(path="./modules/generic/tests/samples/python_server", tag="test-new-mod:latest") as image: + with NewSubModuleContainer(port=9000, image=image) as new_mod: + url = new_mod._create_connection_url() + response = httpx.get(f"{url}", timeout=5) + assert response.status_code == 200, "Response status code is not 200" + assert new_mod.additional_capability() == "NewSubModuleContainer" diff --git a/modules/trino/README.rst b/modules/trino/README.rst new file mode 100644 index 000000000..95b4be930 --- /dev/null +++ b/modules/trino/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.trino.TrinoContainer +.. title:: testcontainers.trino.TrinoContainer diff --git a/modules/trino/testcontainers/trino/__init__.py b/modules/trino/testcontainers/trino/__init__.py new file mode 100644 index 000000000..97e3f9de4 --- /dev/null +++ b/modules/trino/testcontainers/trino/__init__.py @@ -0,0 +1,56 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import re + +from testcontainers.core.config import testcontainers_config as c +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs +from trino.dbapi import connect + + +class TrinoContainer(DbContainer): + def __init__( + self, + image="trinodb/trino:latest", + user: str = "test", + port: int = 8080, + **kwargs, + ): + super().__init__(image=image, **kwargs) + self.user = user + self.port = port + self.with_exposed_ports(self.port) + + @wait_container_is_ready() + def _connect(self) -> None: + wait_for_logs( + self, + re.compile(".*======== SERVER STARTED ========.*", re.MULTILINE).search, + c.max_tries, + c.sleep_time, + ) + conn = connect( + host=self.get_container_host_ip(), + port=self.get_exposed_port(self.port), + user=self.user, + ) + cur = conn.cursor() + cur.execute("SELECT 1") + cur.fetchall() + conn.close() + + def get_connection_url(self): + return f"trino://{self.user}@{self.get_container_host_ip()}:{self.port}" + + def _configure(self): + pass diff --git a/modules/trino/tests/test_trino.py b/modules/trino/tests/test_trino.py new file mode 100644 index 000000000..c1a70230b --- /dev/null +++ b/modules/trino/tests/test_trino.py @@ -0,0 +1,17 @@ +from testcontainers.trino import TrinoContainer +from trino.dbapi import connect + + +def test_docker_run_trino(): + container = TrinoContainer("trinodb/trino:451") + with container as trino: + conn = connect( + host=trino.get_container_host_ip(), + port=trino.get_exposed_port(trino.port), + user="test", + ) + cur = conn.cursor() + cur.execute("SELECT version()") + rows = cur.fetchall() + assert rows[0][0] == "451" + conn.close() diff --git a/modules/vault/README.rst b/modules/vault/README.rst new file mode 100644 index 000000000..71c079dfa --- /dev/null +++ b/modules/vault/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.vault.VaultContainer +.. title:: testcontainers.vault.VaultContainer diff --git a/modules/vault/testcontainers/vault/__init__.py b/modules/vault/testcontainers/vault/__init__.py new file mode 100644 index 000000000..5f50cdd4e --- /dev/null +++ b/modules/vault/testcontainers/vault/__init__.py @@ -0,0 +1,74 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from http.client import HTTPException +from urllib.request import urlopen + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class VaultContainer(DockerContainer): + """ + Vault container. + + Example: + + .. doctest:: + + >>> from testcontainers.vault import VaultContainer + >>> import hvac + + >>> with VaultContainer("hashicorp/vault:1.16.1") as vault_container: + ... connection_url = vault_container.get_connection_url() + ... client = hvac.Client(url=connection_url, token=vault_container.root_token) + ... assert client.is_authenticated() + ... # use root client to perform desired actions, e.g. + ... policies = client.sys.list_acl_policies() + """ + + def __init__( + self, + image: str = "hashicorp/vault:latest", + port: int = 8200, + root_token: str = "toor", + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self.port = port + self.root_token = root_token + self.with_exposed_ports(self.port) + self.with_env("VAULT_DEV_ROOT_TOKEN_ID", self.root_token) + + def get_connection_url(self) -> str: + """ + Get the connection URL used to connect to the Vault container. + + Returns: + str: The address to connect to. + """ + host_ip = self.get_container_host_ip() + exposed_port = self.get_exposed_port(self.port) + return f"http://{host_ip}:{exposed_port}" + + @wait_container_is_ready(HTTPException) + def _healthcheck(self) -> None: + url = f"{self.get_connection_url()}/v1/sys/health" + with urlopen(url) as res: + if res.status > 299: + raise HTTPException() + + def start(self) -> "VaultContainer": + super().start() + self._healthcheck() + return self diff --git a/modules/vault/tests/test_vault.py b/modules/vault/tests/test_vault.py new file mode 100644 index 000000000..54017d2f9 --- /dev/null +++ b/modules/vault/tests/test_vault.py @@ -0,0 +1,41 @@ +import hvac +from testcontainers.vault import VaultContainer + + +def test_docker_run_vault(): + config = VaultContainer("hashicorp/vault:1.16.1") + with config as vault: + url = vault.get_connection_url() + client = hvac.Client(url=url) + status = client.sys.read_health_status() + assert status.status_code == 200 + + +def test_docker_run_vault_act_as_root(): + config = VaultContainer("hashicorp/vault:1.16.1") + with config as vault: + url = vault.get_connection_url() + client = hvac.Client(url=url, token=vault.root_token) + assert client.is_authenticated() + assert client.sys.is_initialized() + assert not client.sys.is_sealed() + + client.sys.enable_secrets_engine( + backend_type="kv", + path="secrets", + config={ + "version": "2", + }, + ) + client.secrets.kv.v2.create_or_update_secret( + path="my-secret", + mount_point="secrets", + secret={ + "pssst": "this is secret", + }, + ) + resp = client.secrets.kv.v2.read_secret( + path="my-secret", + mount_point="secrets", + ) + assert resp["data"]["data"]["pssst"] == "this is secret" diff --git a/modules/weaviate/README.rst b/modules/weaviate/README.rst new file mode 100644 index 000000000..560934164 --- /dev/null +++ b/modules/weaviate/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.weaviate.WeaviateContainer +.. title:: testcontainers.weaviate.WeaviateContainer diff --git a/modules/weaviate/testcontainers/weaviate/__init__.py b/modules/weaviate/testcontainers/weaviate/__init__.py new file mode 100644 index 000000000..c33983121 --- /dev/null +++ b/modules/weaviate/testcontainers/weaviate/__init__.py @@ -0,0 +1,178 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from typing import TYPE_CHECKING, Optional + +from requests import ConnectionError, get + +from testcontainers.core.generic import DbContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + +if TYPE_CHECKING: + from requests import Response + + +class WeaviateContainer(DbContainer): + """ + Weaviate vector database container. + + Arguments: + `image` + Docker image to use with Weaviate container. + `env_vars` + Additional environment variables to include with the container, e.g. ENABLE_MODULES list, QUERY_DEFAULTS_LIMIT setting. + + Example: + This example shows how to start Weaviate container with default settings. + + .. doctest:: + + >>> from testcontainers.weaviate import WeaviateContainer + + >>> with WeaviateContainer() as container: + ... with container.get_client() as client: + ... client.is_live() + True + + This example shows how to start Weaviate container with additional settings. + + .. doctest:: + + >>> from testcontainers.weaviate import WeaviateContainer + + >>> with WeaviateContainer( + ... env_vars={ + ... "ENABLE_MODULES": "backup-filesystem,text2vec-openai", + ... "BACKUP_FILESYSTEM_PATH": "/tmp/backups", + ... "QUERY_DEFAULTS_LIMIT": 100, + ... } + ... ) as container: + ... with container.get_client() as client: + ... client.is_live() + True + """ + + def __init__( + self, + image: str = "semitechnologies/weaviate:1.24.5", + env_vars: Optional[dict[str, str]] = None, + **kwargs, + ) -> None: + super().__init__(image, **kwargs) + self._http_port = 8080 + self._grpc_port = 50051 + + self.with_command(f"--host 0.0.0.0 --scheme http --port {self._http_port}") + self.with_exposed_ports(self._http_port, self._grpc_port) + + if env_vars is not None: + for key, value in env_vars.items(): + self.with_env(key, value) + + def _configure(self) -> None: + self.with_env("AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED", "true") + self.with_env("PERSISTENCE_DATA_PATH", "/var/lib/weaviate") + + @wait_container_is_ready(ConnectionError) + def _connect(self) -> None: + url = f"http://{self.get_http_host()}:{self.get_http_port()}/v1/.well-known/ready" + response: Response = get(url) + response.raise_for_status() + + def get_client( + self, + headers: Optional[dict[str, str]] = None, + ): + """ + Get a `weaviate.WeaviateClient` instance associated with the container. + + Arguments: + `headers` + Additional headers to include in the requests, e.g. API keys for third-party Cloud vectorization. + + Returns: + WeaviateClient: An instance of the `weaviate.WeaviateClient` class. + """ + + try: + import weaviate + except ImportError as e: + raise ImportError("To use the `get_client` method, you must install the `weaviate-client` package.") from e + return weaviate.connect_to_custom( + http_host=self.get_http_host(), + http_port=self.get_http_port(), + http_secure=self.get_http_secure(), + grpc_host=self.get_http_host(), + grpc_port=self.get_grpc_port(), + grpc_secure=self.get_grpc_secure(), + headers=headers, + ) + + def get_http_host(self) -> str: + """ + Get the HTTP host of Weaviate container. + + Returns: + `str` + The HTTP host of Weaviate container. + """ + return f"{self.get_container_host_ip()}" + + def get_http_port(self) -> int: + """ + Get the HTTP port of Weaviate container. + + Returns: + `int` + The HTTP port of Weaviate container. + """ + return self.get_exposed_port(self._http_port) + + def get_http_secure(self) -> bool: + """ + Get the HTTP secured setting of Weaviate container. + + Returns: + `bool` + True if it's https. + """ + return False + + def get_grpc_host(self) -> str: + """ + Get the gRPC host of Weaviate container. + + Returns: + `str` + The gRPC host of Weaviate container. + """ + return f"{self.get_container_host_ip()}" + + def get_grpc_port(self) -> int: + """ + Get the gRPC port of Weaviate container. + + Returns: + `int` + The gRPC port of Weaviate container. + """ + return self.get_exposed_port(self._grpc_port) + + def get_grpc_secure(self) -> bool: + """ + Get the gRPC secured setting of Weaviate container. + + Returns: + `str` + True if the conntection is secured with SSL. + """ + return False diff --git a/modules/weaviate/tests/test_weaviate.py b/modules/weaviate/tests/test_weaviate.py new file mode 100644 index 000000000..40728d4aa --- /dev/null +++ b/modules/weaviate/tests/test_weaviate.py @@ -0,0 +1,55 @@ +from testcontainers.weaviate import WeaviateContainer +import weaviate + + +def test_docker_run_weaviate(): + with WeaviateContainer() as container: + client = weaviate.connect_to_custom( + http_host=container.get_http_host(), + http_port=container.get_http_port(), + http_secure=container.get_http_secure(), + grpc_host=container.get_grpc_host(), + grpc_port=container.get_grpc_port(), + grpc_secure=container.get_grpc_secure(), + ) + + meta = client.get_meta() + assert len(meta.get("version")) > 0 + + client.close() + + +def test_docker_run_weaviate_with_client(): + with WeaviateContainer() as container: + with container.get_client() as client: + assert client.is_live() + + meta = client.get_meta() + assert len(meta.get("version")) > 0 + + +def test_docker_run_weaviate_with_modules(): + enable_modules = [ + "backup-filesystem", + "text2vec-openai", + "text2vec-cohere", + "text2vec-huggingface", + "generative-openai", + ] + with WeaviateContainer( + env_vars={ + "ENABLE_MODULES": ",".join(enable_modules), + "BACKUP_FILESYSTEM_PATH": "/tmp/backups", + } + ) as container: + with container.get_client() as client: + assert client.is_live() + + meta = client.get_meta() + assert len(meta.get("version")) > 0 + + modules = meta.get("modules") + assert len(modules) == len(enable_modules) + + for name in enable_modules: + assert len(modules[name]) > 0 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..bf8872cb5 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,4680 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "alabaster" +version = "0.7.16" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.9" +files = [ + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, +] + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = true +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "argon2-cffi" +version = "23.1.0" +description = "Argon2 for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[package.extras] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +description = "Low-level CFFI bindings for Argon2" +optional = true +python-versions = ">=3.6" +files = [ + {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, + {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, +] + +[package.dependencies] +cffi = ">=1.0.1" + +[package.extras] +dev = ["cogapp", "pre-commit", "pytest", "wheel"] +tests = ["pytest"] + +[[package]] +name = "asn1crypto" +version = "1.5.1" +description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" +optional = false +python-versions = "*" +files = [ + {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, + {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, +] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = true +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = true +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "authlib" +version = "1.3.0" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = true +python-versions = ">=3.8" +files = [ + {file = "Authlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:9637e4de1fb498310a56900b3e2043a206b03cb11c05422014b0302cbc814be3"}, + {file = "Authlib-1.3.0.tar.gz", hash = "sha256:959ea62a5b7b5123c5059758296122b57cd2585ae2ed1c0622c21b371ffdae06"}, +] + +[package.dependencies] +cryptography = "*" + +[[package]] +name = "azure-core" +version = "1.30.1" +description = "Microsoft Azure Core Library for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "azure-core-1.30.1.tar.gz", hash = "sha256:26273a254131f84269e8ea4464f3560c731f29c0c1f69ac99010845f239c1a8f"}, + {file = "azure_core-1.30.1-py3-none-any.whl", hash = "sha256:7c5ee397e48f281ec4dd773d67a0a47a0962ed6fa833036057f9ea067f688e74"}, +] + +[package.dependencies] +requests = ">=2.21.0" +six = ">=1.11.0" +typing-extensions = ">=4.6.0" + +[package.extras] +aio = ["aiohttp (>=3.0)"] + +[[package]] +name = "azure-cosmos" +version = "4.7.0" +description = "Microsoft Azure Cosmos Client Library for Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "azure-cosmos-4.7.0.tar.gz", hash = "sha256:72d714033134656302a2e8957c4b93590673bd288b0ca60cb123e348ae99a241"}, + {file = "azure_cosmos-4.7.0-py3-none-any.whl", hash = "sha256:03d8c7740ddc2906fb16e07b136acc0fe6a6a02656db46c5dd6f1b127b58cc96"}, +] + +[package.dependencies] +azure-core = ">=1.25.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-storage-blob" +version = "12.19.1" +description = "Microsoft Azure Blob Storage Client Library for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "azure-storage-blob-12.19.1.tar.gz", hash = "sha256:13e16ba42fc54ac2c7e8f976062173a5c82b9ec0594728e134aac372965a11b0"}, + {file = "azure_storage_blob-12.19.1-py3-none-any.whl", hash = "sha256:c5530dc51c21c9564e4eb706cd499befca8819b10dd89716d3fc90d747556243"}, +] + +[package.dependencies] +azure-core = ">=1.28.0,<2.0.0" +cryptography = ">=2.1.4" +isodate = ">=0.6.1" +typing-extensions = ">=4.3.0" + +[package.extras] +aio = ["azure-core[aio] (>=1.28.0,<2.0.0)"] + +[[package]] +name = "babel" +version = "2.14.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + +[[package]] +name = "bcrypt" +version = "4.1.2" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.1.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ac621c093edb28200728a9cca214d7e838529e557027ef0581685909acd28b5e"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea505c97a5c465ab8c3ba75c0805a102ce526695cd6818c6de3b1a38f6f60da1"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57fa9442758da926ed33a91644649d3e340a71e2d0a5a8de064fb621fd5a3326"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb3bd3321517916696233b5e0c67fd7d6281f0ef48e66812db35fc963a422a1c"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6cad43d8c63f34b26aef462b6f5e44fdcf9860b723d2453b5d391258c4c8e966"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:44290ccc827d3a24604f2c8bcd00d0da349e336e6503656cb8192133e27335e2"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:732b3920a08eacf12f93e6b04ea276c489f1c8fb49344f564cca2adb663b3e4c"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c28973decf4e0e69cee78c68e30a523be441972c826703bb93099868a8ff5b5"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8df79979c5bae07f1db22dcc49cc5bccf08a0380ca5c6f391cbb5790355c0b0"}, + {file = "bcrypt-4.1.2-cp37-abi3-win32.whl", hash = "sha256:fbe188b878313d01b7718390f31528be4010fed1faa798c5a1d0469c9c48c369"}, + {file = "bcrypt-4.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:9800ae5bd5077b13725e2e3934aa3c9c37e49d3ea3d06318010aa40f54c63551"}, + {file = "bcrypt-4.1.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:71b8be82bc46cedd61a9f4ccb6c1a493211d031415a34adde3669ee1b0afbb63"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e3c6642077b0c8092580c819c1684161262b2e30c4f45deb000c38947bf483"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387e7e1af9a4dd636b9505a465032f2f5cb8e61ba1120e79a0e1cd0b512f3dfc"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f70d9c61f9c4ca7d57f3bfe88a5ccf62546ffbadf3681bb1e268d9d2e41c91a7"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2a298db2a8ab20056120b45e86c00a0a5eb50ec4075b6142db35f593b97cb3fb"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ba55e40de38a24e2d78d34c2d36d6e864f93e0d79d0b6ce915e4335aa81d01b1"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3566a88234e8de2ccae31968127b0ecccbb4cddb629da744165db72b58d88ca4"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b90e216dc36864ae7132cb151ffe95155a37a14e0de3a8f64b49655dd959ff9c"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:69057b9fc5093ea1ab00dd24ede891f3e5e65bee040395fb1e66ee196f9c9b4a"}, + {file = "bcrypt-4.1.2-cp39-abi3-win32.whl", hash = "sha256:02d9ef8915f72dd6daaef40e0baeef8a017ce624369f09754baf32bb32dba25f"}, + {file = "bcrypt-4.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:be3ab1071662f6065899fe08428e45c16aa36e28bc42921c4901a191fda6ee42"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d75fc8cd0ba23f97bae88a6ec04e9e5351ff3c6ad06f38fe32ba50cbd0d11946"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:a97e07e83e3262599434816f631cc4c7ca2aa8e9c072c1b1a7fec2ae809a1d2d"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e51c42750b7585cee7892c2614be0d14107fad9581d1738d954a262556dd1aab"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba4e4cc26610581a6329b3937e02d319f5ad4b85b074846bf4fef8a8cf51e7bb"}, + {file = "bcrypt-4.1.2.tar.gz", hash = "sha256:33313a1200a3ae90b75587ceac502b048b840fc69e7f7a0905b5f87fac7a1258"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "boto3" +version = "1.34.59" +description = "The AWS SDK for Python" +optional = true +python-versions = ">= 3.8" +files = [ + {file = "boto3-1.34.59-py3-none-any.whl", hash = "sha256:004e67b078be58d34469406f93cc8b95bc43becef4bbe44523a0b8e51f84c668"}, + {file = "boto3-1.34.59.tar.gz", hash = "sha256:162edf182e53c198137a28432a626dba103f787a8f5000ed4758b73ccd203fa0"}, +] + +[package.dependencies] +botocore = ">=1.34.59,<1.35.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.10.0,<0.11.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.34.59" +description = "Low-level, data-driven core of boto 3." +optional = true +python-versions = ">= 3.8" +files = [ + {file = "botocore-1.34.59-py3-none-any.whl", hash = "sha256:4bc112dafb1679ab571117593f7656604726a3da0e5ae5bad00ea772fa40e75c"}, + {file = "botocore-1.34.59.tar.gz", hash = "sha256:24edb4d21d7c97dea0c6c4a80d36b3809b1443a30b0bd5e317d6c319dfac823f"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = [ + {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""}, + {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, +] + +[package.extras] +crt = ["awscrt (==0.19.19)"] + +[[package]] +name = "cachetools" +version = "5.3.3" +description = "Extensible memoizing collections and decorators" +optional = true +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, + {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, +] + +[[package]] +name = "cassandra-driver" +version = "3.29.1" +description = "DataStax Driver for Apache Cassandra" +optional = false +python-versions = "*" +files = [ + {file = "cassandra-driver-3.29.1.tar.gz", hash = "sha256:38e9c2a2f2a9664bb03f1f852d5fccaeff2163942b5db35dffcf8bf32a51cfe5"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8f175c7616a63ca48cb8bd4acc443e2a3d889964d5157cead761f23cc8db7bd"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d66398952b9cd21c40edff56e22b6d3bce765edc94b207ddb5896e7bc9aa088"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bbc6f575ef109ce5d4abfa2033bf36c394032abd83e32ab671159ce68e7e17b"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78f241af75696adb3e470209e2fbb498804c99e2b197d24d74774eee6784f283"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-win32.whl", hash = "sha256:54d9e651a742d6ca3d874ef8d06a40fa032d2dba97142da2d36f60c5675e39f8"}, + {file = "cassandra_driver-3.29.1-cp310-cp310-win_amd64.whl", hash = "sha256:630dc5423cd40eba0ee9db31065e2238098ff1a25a6b1bd36360f85738f26e4b"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0b841d38c96bb878d31df393954863652d6d3a85f47bcc00fd1d70a5ea73023f"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19cc7375f673e215bd4cbbefae2de9f07830be7dabef55284a2d2ff8d8691efe"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b74b355be3dcafe652fffda8f14f385ccc1a8dae9df28e6080cc660da39b45f"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e6dac7eddd3f4581859f180383574068a3f113907811b4dad755a8ace4c3fbd"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-win32.whl", hash = "sha256:293a79dba417112b56320ed0013d71fd7520f5fc4a5fd2ac8000c762c6dd5b07"}, + {file = "cassandra_driver-3.29.1-cp311-cp311-win_amd64.whl", hash = "sha256:7c2374fdf1099047a6c9c8329c79d71ad11e61d9cca7de92a0f49655da4bdd8a"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4431a0c836f33a33c733c84997fbdb6398be005c4d18a8c8525c469fdc29393c"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23b08381b171a9e42ace483a82457edcddada9e8367e31677b97538cde2dc34"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4beb29a0139e63a10a5b9a3c7b72c30a4e6e20c9f0574f9d22c0d4144fe3d348"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b206423cc454a78f16b411e7cb641dddc26168ac2e18f2c13665f5f3c89868c"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-win32.whl", hash = "sha256:ac898cca7303a3a2a3070513eee12ef0f1be1a0796935c5b8aa13dae8c0a7f7e"}, + {file = "cassandra_driver-3.29.1-cp312-cp312-win_amd64.whl", hash = "sha256:4ad0c9fb2229048ad6ff8c6ddbf1fdc78b111f2b061c66237c2257fcc4a31b14"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4282c5deac462e4bb0f6fd0553a33d514dbd5ee99d0812594210080330ddd1a2"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:41ca7eea069754002418d3bdfbd3dfd150ea12cb9db474ab1a01fa4679a05bcb"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6639ccb268c4dc754bc45e03551711780d0e02cb298ab26cde1f42b7bcc74f8"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a9d7d3b1be24a7f113b5404186ccccc977520401303a8fe78ba34134cad2482"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-win32.whl", hash = "sha256:81c8fd556c6e1bb93577e69c1f10a3fadf7ddb93958d226ccbb72389396e9a92"}, + {file = "cassandra_driver-3.29.1-cp38-cp38-win_amd64.whl", hash = "sha256:cfe70ed0f27af949de2767ea9cef4092584e8748759374a55bf23c30746c7b23"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a2c03c1d834ac1a0ae39f9af297a8cd38829003ce910b08b324fb3abe488ce2b"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9a3e1e2b01f3b7a5cf75c97401bce830071d99c42464352087d7475e0161af93"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90c42006665a4e490b0766b70f3d637f36a30accbef2da35d6d4081c0e0bafc3"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c1aca41f45772f9759e8246030907d92bc35fbbdc91525a3cb9b49939b80ad7"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-win32.whl", hash = "sha256:ce4a66245d4a0c8b07fdcb6398698c2c42eb71245fb49cff39435bb702ff7be6"}, + {file = "cassandra_driver-3.29.1-cp39-cp39-win_amd64.whl", hash = "sha256:4cae69ceb1b1d9383e988a1b790115253eacf7867ceb15ed2adb736e3ce981be"}, +] + +[package.dependencies] +geomet = ">=0.1,<0.3" + +[package.extras] +cle = ["cryptography (>=35.0)"] +graph = ["gremlinpython (==3.4.6)"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "chromadb-client" +version = "0.4.25.dev0" +description = "Chroma Client." +optional = true +python-versions = ">=3.8" +files = [ + {file = "chromadb-client-0.4.25.dev0.tar.gz", hash = "sha256:18762d04720db1ca9ac6347ecd04371064e414b22401aadc2e78a1893fd46595"}, + {file = "chromadb_client-0.4.25.dev0-py3-none-any.whl", hash = "sha256:da52dd28e02bb168be6ab82177726c27f770f5c190ef7c3484b12c6014f2cc07"}, +] + +[package.dependencies] +numpy = ">=1.22.5" +opentelemetry-api = ">=1.2.0" +opentelemetry-exporter-otlp-proto-grpc = ">=1.2.0" +opentelemetry-sdk = ">=1.2.0" +orjson = ">=3.9.12" +overrides = ">=7.3.1" +posthog = ">=2.4.0" +pydantic = ">=1.9" +PyYAML = ">=6.0.0" +requests = ">=2.28" +tenacity = ">=8.2.3" +typing-extensions = ">=4.5.0" + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "clickhouse-driver" +version = "0.2.7" +description = "Python driver with native interface for ClickHouse" +optional = true +python-versions = ">=3.7, <4" +files = [ + {file = "clickhouse-driver-0.2.7.tar.gz", hash = "sha256:299cfbe6d561955d88eeab6e09f3de31e2f6daccc6fdd904a59e46357d2d28d9"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c44fefc2fd44f432d5b162bfe34ad76840137c34167d46a18c554a7c7c6e3566"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e018452a7bf8d8c0adf958afbc5b0d29e402fc09a1fb34e9186293eae57f3b4e"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff8b09f8b13df28d2f91ee3d0d2edd9589cbda76b74acf60669112219cea8c9d"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54aa91c9512fd5a73f038cae4f67ca2ff0b2f8a84de846179a31530936ef4e20"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8342a7ba31ccb393ee31dfd61173aa84c995b4ac0b44d404adc8463534233d5"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:199000f8adf38fade0b5a52c273a396168105539de741a18ba3e68d7fc06e0e6"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60a2a40602b207506e505cfb184a81cd4b752bde17153bc0b32c3931ddb792f"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5db3a26b18146b2b0b06d3f32ce588af5afaa38c719daf6f9606981514228a8b"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5579a31da1f3cf49630e43fbbb11cab891b78161abdcb33908b79820b7cd3a23"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cc39f0fb761aed96917b0f55679174a50f9591afc0e696e745cd698ef822661f"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9aa0f7c740e4e61886c6d388792c5d1a2084d4b5462e6dcfc24e30ca7e7f8e68"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2caee88b6eec7b33ddbccd24501ad99ff8ff2b0a6a4471945cbfb28947a9a791"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-win32.whl", hash = "sha256:a4aef432cc7120a971eebb7ca2fddac4472e810b57e403d3a371b0c69cbb2bb0"}, + {file = "clickhouse_driver-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f307de7df6bc23ad5ec8a1ba1db157f4d14de673ddd4798f37790f23255605b0"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbf3ca8919bf856ca6588669a863065fb732a32a6387095f64d19038fd99db9f"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab68b3d9b9d1386adfd3a57edd47b62858a145bf7ccc7f11b31d308195d966e5"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:985a9d60044c5ad39c6e018b852c7105ec4ebfdf4c3abe23183b4867454e570a"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c94330054c8d92d2286898906f843f26e2f96fc2aa11a9a96a7b5593d299bf0"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:92938f55c8f797e50e624a4b96e685178d043cdf0ede306a7fd4e7dda19b8dfd"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05bd53e9bf49c3013d06f9e6d2812872d44b150f7a2d1cf18e1498257d42330e"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f1f8ed5404e283a9ded499c33eade2423fdc15e31f8a711d75e91f890d0f70b"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a398085e4a1766d907ac32c282d4172db38a44243bde303372396208d1cbf4bb"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa1808593123b6056f93808f0afbc7938f06a8149cb4e381aa7b1a234c1d3c18"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:0512d54ae23bd4a69278e04f42b651d7c71b63ba6043e2c6bd97b11329692f99"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5bc2b67e7e68f74ccebf95a8b3a13f13a7c34b89b32c9813103221de14c06c8b"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:04a37cdafc671cb796af3e566cef0aeb39111d82aebeecd9106a049434953b26"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-win32.whl", hash = "sha256:019538c7c23e976538e5081dd2f77a8a40bf663c638a62d857ff05f42b0c9052"}, + {file = "clickhouse_driver-0.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5166643683584bc53fcadda73c65f6a9077feb472f3d167ecef1a1a7024973aa"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:59affab7b5a3c4aab5b6a730f606575efdefea213458de2eb14927ee4e0640f4"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcb93dd07fe65ac4f1a2bc0b8967911d4ad2152dbee000f025ea5cb575da5ecb"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55a48019b79181ae1ca90e980e74c5d413c3f8829f6744e2b056646c2d435a1a"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:507463c9157240fd7c3246781e8c30df8db3c80bf68925b36ff3ad4a80c4b924"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e2d8d2295ee9e0cfab8ad77cb635a05da2160334b4f16ed8c3d00fbf39a2343"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e38c44546dcdb956b5ab0944cb3d51e8c98f816e75bab1a2254c478865bc6e7b"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6690a2bdd9e7531fe50b53193279f8b35cbcd5c5ee36c0fcc112518a7d24f16"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc6b4ba0a6467fd09021aa1d87a44fb4589600d61b010fca41e0dfffd0dee322"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:254bbd400eb87ff547a08755bc714f712e11f7a6d3ebbbb7aaa1dd454fb16d44"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7bbbe3f8b87fc1489bc15fa9c88cc9fac9d4d7d683d076f058c2c83e6ee422fd"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:745e5b18f0957d932151527f1523d0e516c199de8c589638e5f55ab2559886f3"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0fa0357fb5f26149e3df86a117d3678329b85d8827b78a5a09bbf224d8dd4541"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-win32.whl", hash = "sha256:ace652af7ca94ba3cb3a04a5c363e135dc5009f31d8201903e21db9d5daf2358"}, + {file = "clickhouse_driver-0.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:c0ba68489544df89e4138a14b0ec3e1e5eb102d5d3283a91d9b837c420c0ab97"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:66267e4ba21fa66c97ce784a5de2202d3b7d4db3e50bfcdde92830a68f6fae30"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cf55c285b75c178487407721baef4980b3c6515c9c0c1a6c1ea8b001afe658e"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:deeb66bb56490db2157f199c6d9aa2c53f046677be430cc834fc1e74eec6e654"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfe5b4020939abeeb407b4eead598c954b1573d2d2b4f174f793b196d378b9d9"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84d39506b5f8d86a1195ebde1c66aba168f34ebce6ebd828888f0625cac54774"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f93a27db2dcbbd3ecad36e8df4395d047cb7410e2dc69f6d037674e15442f4ee"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ebc29e501e47ecbfd44c89c0e5c87b2a722049d38b9e93fdd4bea510a82e16ac"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f9cc8c186fea09a94d89e5c9c4e8d05ec3a80e2f6d25673c48efec8117a13cfc"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0757dfde5410c42230b24825ea3ab904a78160520e5ceb953482e133e368733b"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c9f88818cf411f928c29ba295c677cd95773bd256b8490f5655fb489e0c6658c"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e19952f158ebe274c65ffeb294ba378d75048a48f31b77573948d606bed019d5"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-win32.whl", hash = "sha256:008b1f32c7c68564de8051482b72a5289b6933bca9d9b1ad1474dd448d6768ba"}, + {file = "clickhouse_driver-0.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:622933cc9834c39f03de5d43a12f13fc7133d31d6d2597e67866d4a549ca9e60"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:92540581e5b5f36d915f14d05c30244870fb123c74b38c645fa47663053c5471"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02dfadc6111b64e01c20b8c11266cab97d4f06685a392a183af437f2f1afb990"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ca17fece86fe85d97705024bec881978271931b3d00db273c9d63244f7d606"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76474f1315ca3ab484ae28ad085b8f756c8b9a755882f93912b2149290482033"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5c0ff12368b34aaf58dd948b0819e5b54d261911de334d3f048328dc9354013"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd441b17294e90e313b08fabf84fcc782c191d2b9b2a924f163928202db6fcc"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62aa158f61d7d84c58e8cd75b3b8340b28607e5a70132395078f578d518aaae3"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcb2a39a1fef8bf1b581f06125c2a84a5b92c939b079d1a95126e3207b05dc77"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1f29cc641a65e89a51a15f6d195f565ad2761d1bd653408c6b4046c987c5fb99"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ac1a43690696bda46c9a23fc6fd79b6fe22d428a18e880bdbdf5e6aeb31008c5"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:1dd5ea4584c42f85d96ddfa7d07da2abb35a797c45e4d3a66ace149ee4977cad"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a736c0af858a3c83af03848b18754ab18dc594cc7f3bf6be0b1fac682def182c"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-win32.whl", hash = "sha256:6cb8ca47f5818c1bc5814b9ff775e383f3c50059b1fd28a02cb9be1b666929f8"}, + {file = "clickhouse_driver-0.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:a90e7dc92985669a5e6569356bb3028d9d475f95006d4487cb0789aa53f9489c"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:04b77cd6c583da9135db4a62c5a7999ae248c2dbfc0cb8e8a3d8a853b1fbfa11"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c7671f8c0e8960d766b2e0eaefcae3088fccdd3920e9cd3dee8e344cfd0a6929"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:502d7cd28522b95a399e993ffd48487e8c12c50ce2d4e89b77b938f945304405"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:969739279f4010e7b5b6b2c9d2ab56a463aed11fdaed5e02424c1b3915f144f8"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed34b60f741eeb02407ea72180d77cbfc368c1be6fc2f2ff8319d1856ce67e10"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a667b48927f4420eb8c03fa33369edfbdf359a788897a01ac945263a2a611461"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f93aa3a90f3847872d7464ec9076482b2e812c4e7d61682daedffdf3471be00"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:190890667215691fdf2155c3b233b39146054ab1cd854c7d91221e6ed633d71e"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ff280aeac5e96c764cd31ba1077c95601337b9a97fb0b9ed4d24c64431f2c322"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01e63e35d2ab55b8eb48facf6e951968c80d27ee6703aa6c91c73d9d0a4d0efe"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a29fb24b910dafc8c11ba882797d13ec0323a97dce80a57673116fa893d1b669"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5f229a7853fc767e63143ea69889d49f6fd5623adc2f7b0f7eb360117d7e91a5"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-win32.whl", hash = "sha256:b7f34ad2ed509f48f8ed1f9b96e89765173a7b35d286c7350aa85934a11c0f49"}, + {file = "clickhouse_driver-0.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:78b166597afbe490cc0cdac44fed8c8b81668f87125601dda17b154f237eef5d"}, + {file = "clickhouse_driver-0.2.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:16ab64beb8d079cb9b3200539539a35168f524eedf890c9acefb719e25bdc96e"}, + {file = "clickhouse_driver-0.2.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03e28fd50fc7c54874bf8e638a2ea87f73ae35bfbbf90123fdb395f38d62f159"}, + {file = "clickhouse_driver-0.2.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0677b8350acd8d186b6acd0026b62dd262d6fee428a5fa3ad9561908d4b02c39"}, + {file = "clickhouse_driver-0.2.7-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a2f3c9e2182809131701bb28a606dec90525c7ab20490714714a4b3eb015454b"}, + {file = "clickhouse_driver-0.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e03a1a1b30cc58c9bd2cbe25bf5e40b1f1d16d52d44ddefb3af50435d1ed613c"}, + {file = "clickhouse_driver-0.2.7-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a1be8081306a4beb12444ed8e3208e1eb6c01ed207c471b33009c13504c88139"}, + {file = "clickhouse_driver-0.2.7-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:933b40722cbca9b1123a5bb2fb4bafafd234deae0f3481125cb6b6fa1d39aa84"}, + {file = "clickhouse_driver-0.2.7-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054b5022f9bf15a5f4663a7cd190f466e70a2d7b8d45429d8742c515b556c10"}, + {file = "clickhouse_driver-0.2.7-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61744760ee046c9a268cb801ca21bfe44c4873db9901a7cd0f3ca8830205feff"}, + {file = "clickhouse_driver-0.2.7-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:5e28427e05a72e7a4c3672e36703a2d80107ee0b3ab537e3380d726c96b07821"}, + {file = "clickhouse_driver-0.2.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c483f5ec836ae87803478f2a7b9daf15343078edd6a8be7364dd9db64905bbd0"}, + {file = "clickhouse_driver-0.2.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28220b794874e68bc2f06dbfff5748f1c5a3236922f59e127abd58d44ae20a3f"}, + {file = "clickhouse_driver-0.2.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c09877b59b34d5b3043ad70ec31543173cac8b64b4a8afaa89416b22fb28da5"}, + {file = "clickhouse_driver-0.2.7-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3580f78db27119f7380627873214ae1342066f1ecb35700c1d7bf418dd70ae73"}, + {file = "clickhouse_driver-0.2.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0842ac1b2f7a9ca46dac2027849b241bccd8eb8ff1c59cb0a5874042b267b733"}, + {file = "clickhouse_driver-0.2.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7a3fb585e2d3514196258a4a3b0267510c03477f3c2380239ade4c056ba689a7"}, + {file = "clickhouse_driver-0.2.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ea25287566d45efbaee0857ad25e8b33ffd7fd73e89424d79fe7f532962915"}, + {file = "clickhouse_driver-0.2.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee4a4935667b59b4816a5ca77300f5dbe5a7416860551d17376426b8fefc1175"}, + {file = "clickhouse_driver-0.2.7-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:358058cfceea9b43c4af9de81842563746f16984b34525a15b41eacf8fc2bed2"}, + {file = "clickhouse_driver-0.2.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae760fb843dec0b5c398536ca8dfaf243f494ba8fc68132ae1bd62004b0c396a"}, +] + +[package.dependencies] +pytz = "*" +tzlocal = "*" + +[package.extras] +lz4 = ["clickhouse-cityhash (>=1.0.2.1)", "lz4", "lz4 (<=3.0.1)"] +numpy = ["numpy (>=1.12.0)", "pandas (>=0.24.0)"] +zstd = ["clickhouse-cityhash (>=1.0.2.1)", "zstd"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.4.3" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, + {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, + {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, + {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, + {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, + {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, + {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, + {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, + {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, + {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, + {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, + {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, + {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cryptography" +version = "42.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = true +python-versions = "*" +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[package.dependencies] +packaging = "*" + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "dnspython" +version = "2.6.1" +description = "DNS toolkit" +optional = true +python-versions = ">=3.8" +files = [ + {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, + {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=41)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=0.9.25)"] +idna = ["idna (>=3.6)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "docker" +version = "7.0.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.0.0-py3-none-any.whl", hash = "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b"}, + {file = "docker-7.0.0.tar.gz", hash = "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3"}, +] + +[package.dependencies] +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + +[[package]] +name = "docutils" +version = "0.20.1" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, +] + +[[package]] +name = "environs" +version = "9.5.0" +description = "simplified environment variable parsing" +optional = false +python-versions = ">=3.6" +files = [ + {file = "environs-9.5.0-py2.py3-none-any.whl", hash = "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124"}, + {file = "environs-9.5.0.tar.gz", hash = "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9"}, +] + +[package.dependencies] +marshmallow = ">=3.0.0" +python-dotenv = "*" + +[package.extras] +dev = ["dj-database-url", "dj-email-url", "django-cache-url", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)", "pytest", "tox"] +django = ["dj-database-url", "dj-email-url", "django-cache-url"] +lint = ["flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)"] +tests = ["dj-database-url", "dj-email-url", "django-cache-url", "pytest"] + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "geomet" +version = "0.2.1.post1" +description = "GeoJSON <-> WKT/WKB conversion utilities" +optional = false +python-versions = ">2.6, !=3.3.*, <4" +files = [ + {file = "geomet-0.2.1.post1-py3-none-any.whl", hash = "sha256:a41a1e336b381416d6cbed7f1745c848e91defaa4d4c1bdc1312732e46ffad2b"}, + {file = "geomet-0.2.1.post1.tar.gz", hash = "sha256:91d754f7c298cbfcabd3befdb69c641c27fe75e808b27aa55028605761d17e95"}, +] + +[package.dependencies] +click = "*" +six = "*" + +[[package]] +name = "google-api-core" +version = "2.17.1" +description = "Google API client core library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-api-core-2.17.1.tar.gz", hash = "sha256:9df18a1f87ee0df0bc4eea2770ebc4228392d8cc4066655b320e2cfccb15db95"}, + {file = "google_api_core-2.17.1-py3-none-any.whl", hash = "sha256:610c5b90092c360736baccf17bd3efbcb30dd380e7a6dc28a71059edb8bd0d8e"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +grpcio = [ + {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, +] +grpcio-status = [ + {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-auth" +version = "2.28.2" +description = "Google Authentication Library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-auth-2.28.2.tar.gz", hash = "sha256:80b8b4969aa9ed5938c7828308f20f035bc79f9d8fb8120bf9dc8db20b41ba30"}, + {file = "google_auth-2.28.2-py2.py3-none-any.whl", hash = "sha256:9fd67bbcd40f16d9d42f950228e9cf02a2ded4ae49198b27432d0cded5a74c38"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-cloud-core" +version = "2.4.1" +description = "Google Cloud API client core library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, + {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] + +[[package]] +name = "google-cloud-datastore" +version = "2.19.0" +description = "Google Cloud Datastore API client library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-cloud-datastore-2.19.0.tar.gz", hash = "sha256:07fc5870a0261f25466c557c134df95a96dfd2537abd088b9d537fbabe99b974"}, + {file = "google_cloud_datastore-2.19.0-py2.py3-none-any.whl", hash = "sha256:c52086670d4c3779ea7bd8f8353b093a9b5e81c6606f36ffcdf46e6ce9fc80c0"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-cloud-core = ">=1.4.0,<3.0.0dev" +proto-plus = [ + {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, + {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" + +[package.extras] +libcst = ["libcst (>=0.2.5)"] + +[[package]] +name = "google-cloud-pubsub" +version = "2.20.1" +description = "Google Cloud Pub/Sub API client library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-cloud-pubsub-2.20.1.tar.gz", hash = "sha256:b6d06f1827968273c42b57a09f642462649c9504dc0f8756f99770f4e3e755ad"}, + {file = "google_cloud_pubsub-2.20.1-py2.py3-none-any.whl", hash = "sha256:06dd62181e2f248f32b9077f4dc07b413191a84fc06d7323b208602d887207bc"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +grpcio = ">=1.51.3,<2.0dev" +grpcio-status = ">=1.33.2" +proto-plus = [ + {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, + {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" + +[package.extras] +libcst = ["libcst (>=0.3.10)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.62.0" +description = "Common protobufs used in Google APIs" +optional = true +python-versions = ">=3.7" +files = [ + {file = "googleapis-common-protos-1.62.0.tar.gz", hash = "sha256:83f0ece9f94e5672cced82f592d2a5edf527a96ed1794f0bab36d5735c996277"}, + {file = "googleapis_common_protos-1.62.0-py2.py3-none-any.whl", hash = "sha256:4750113612205514f9f6aa4cb00d523a94f3e8c06c5ad2fee466387dc4875f07"}, +] + +[package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0.dev0", optional = true, markers = "extra == \"grpc\""} +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "greenlet" +version = "3.0.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, + {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, + {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, + {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, + {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, + {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, + {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, + {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, + {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, + {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, + {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, + {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, + {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, + {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, + {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, + {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.13.0" +description = "IAM API client library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "grpc-google-iam-v1-0.13.0.tar.gz", hash = "sha256:fad318608b9e093258fbf12529180f400d1c44453698a33509cc6ecf005b294e"}, + {file = "grpc_google_iam_v1-0.13.0-py2.py3-none-any.whl", hash = "sha256:53902e2af7de8df8c1bd91373d9be55b0743ec267a7428ea638db3775becae89"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" + +[[package]] +name = "grpcio" +version = "1.62.1" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpcio-1.62.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:179bee6f5ed7b5f618844f760b6acf7e910988de77a4f75b95bbfaa8106f3c1e"}, + {file = "grpcio-1.62.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:48611e4fa010e823ba2de8fd3f77c1322dd60cb0d180dc6630a7e157b205f7ea"}, + {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:b2a0e71b0a2158aa4bce48be9f8f9eb45cbd17c78c7443616d00abbe2a509f6d"}, + {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbe80577c7880911d3ad65e5ecc997416c98f354efeba2f8d0f9112a67ed65a5"}, + {file = "grpcio-1.62.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58f6c693d446964e3292425e1d16e21a97a48ba9172f2d0df9d7b640acb99243"}, + {file = "grpcio-1.62.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:77c339403db5a20ef4fed02e4d1a9a3d9866bf9c0afc77a42234677313ea22f3"}, + {file = "grpcio-1.62.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b5a4ea906db7dec694098435d84bf2854fe158eb3cd51e1107e571246d4d1d70"}, + {file = "grpcio-1.62.1-cp310-cp310-win32.whl", hash = "sha256:4187201a53f8561c015bc745b81a1b2d278967b8de35f3399b84b0695e281d5f"}, + {file = "grpcio-1.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:844d1f3fb11bd1ed362d3fdc495d0770cfab75761836193af166fee113421d66"}, + {file = "grpcio-1.62.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:833379943d1728a005e44103f17ecd73d058d37d95783eb8f0b28ddc1f54d7b2"}, + {file = "grpcio-1.62.1-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:c7fcc6a32e7b7b58f5a7d27530669337a5d587d4066060bcb9dee7a8c833dfb7"}, + {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:fa7d28eb4d50b7cbe75bb8b45ed0da9a1dc5b219a0af59449676a29c2eed9698"}, + {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48f7135c3de2f298b833be8b4ae20cafe37091634e91f61f5a7eb3d61ec6f660"}, + {file = "grpcio-1.62.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f11fd63365ade276c9d4a7b7df5c136f9030e3457107e1791b3737a9b9ed6a"}, + {file = "grpcio-1.62.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4b49fd8fe9f9ac23b78437da94c54aa7e9996fbb220bac024a67469ce5d0825f"}, + {file = "grpcio-1.62.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:482ae2ae78679ba9ed5752099b32e5fe580443b4f798e1b71df412abf43375db"}, + {file = "grpcio-1.62.1-cp311-cp311-win32.whl", hash = "sha256:1faa02530b6c7426404372515fe5ddf66e199c2ee613f88f025c6f3bd816450c"}, + {file = "grpcio-1.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bd90b8c395f39bc82a5fb32a0173e220e3f401ff697840f4003e15b96d1befc"}, + {file = "grpcio-1.62.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:b134d5d71b4e0837fff574c00e49176051a1c532d26c052a1e43231f252d813b"}, + {file = "grpcio-1.62.1-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:d1f6c96573dc09d50dbcbd91dbf71d5cf97640c9427c32584010fbbd4c0e0037"}, + {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:359f821d4578f80f41909b9ee9b76fb249a21035a061a327f91c953493782c31"}, + {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a485f0c2010c696be269184bdb5ae72781344cb4e60db976c59d84dd6354fac9"}, + {file = "grpcio-1.62.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b50b09b4dc01767163d67e1532f948264167cd27f49e9377e3556c3cba1268e1"}, + {file = "grpcio-1.62.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3227c667dccbe38f2c4d943238b887bac588d97c104815aecc62d2fd976e014b"}, + {file = "grpcio-1.62.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3952b581eb121324853ce2b191dae08badb75cd493cb4e0243368aa9e61cfd41"}, + {file = "grpcio-1.62.1-cp312-cp312-win32.whl", hash = "sha256:83a17b303425104d6329c10eb34bba186ffa67161e63fa6cdae7776ff76df73f"}, + {file = "grpcio-1.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:6696ffe440333a19d8d128e88d440f91fb92c75a80ce4b44d55800e656a3ef1d"}, + {file = "grpcio-1.62.1-cp37-cp37m-linux_armv7l.whl", hash = "sha256:e3393b0823f938253370ebef033c9fd23d27f3eae8eb9a8f6264900c7ea3fb5a"}, + {file = "grpcio-1.62.1-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:83e7ccb85a74beaeae2634f10eb858a0ed1a63081172649ff4261f929bacfd22"}, + {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:882020c87999d54667a284c7ddf065b359bd00251fcd70279ac486776dbf84ec"}, + {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a10383035e864f386fe096fed5c47d27a2bf7173c56a6e26cffaaa5a361addb1"}, + {file = "grpcio-1.62.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:960edebedc6b9ada1ef58e1c71156f28689978188cd8cff3b646b57288a927d9"}, + {file = "grpcio-1.62.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:23e2e04b83f347d0aadde0c9b616f4726c3d76db04b438fd3904b289a725267f"}, + {file = "grpcio-1.62.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:978121758711916d34fe57c1f75b79cdfc73952f1481bb9583399331682d36f7"}, + {file = "grpcio-1.62.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9084086190cc6d628f282e5615f987288b95457292e969b9205e45b442276407"}, + {file = "grpcio-1.62.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:22bccdd7b23c420a27fd28540fb5dcbc97dc6be105f7698cb0e7d7a420d0e362"}, + {file = "grpcio-1.62.1-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:8999bf1b57172dbc7c3e4bb3c732658e918f5c333b2942243f10d0d653953ba9"}, + {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:d9e52558b8b8c2f4ac05ac86344a7417ccdd2b460a59616de49eb6933b07a0bd"}, + {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1714e7bc935780bc3de1b3fcbc7674209adf5208ff825799d579ffd6cd0bd505"}, + {file = "grpcio-1.62.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8842ccbd8c0e253c1f189088228f9b433f7a93b7196b9e5b6f87dba393f5d5d"}, + {file = "grpcio-1.62.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1f1e7b36bdff50103af95a80923bf1853f6823dd62f2d2a2524b66ed74103e49"}, + {file = "grpcio-1.62.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bba97b8e8883a8038606480d6b6772289f4c907f6ba780fa1f7b7da7dfd76f06"}, + {file = "grpcio-1.62.1-cp38-cp38-win32.whl", hash = "sha256:a7f615270fe534548112a74e790cd9d4f5509d744dd718cd442bf016626c22e4"}, + {file = "grpcio-1.62.1-cp38-cp38-win_amd64.whl", hash = "sha256:e6c8c8693df718c5ecbc7babb12c69a4e3677fd11de8886f05ab22d4e6b1c43b"}, + {file = "grpcio-1.62.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:73db2dc1b201d20ab7083e7041946910bb991e7e9761a0394bbc3c2632326483"}, + {file = "grpcio-1.62.1-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:407b26b7f7bbd4f4751dbc9767a1f0716f9fe72d3d7e96bb3ccfc4aace07c8de"}, + {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f8de7c8cef9261a2d0a62edf2ccea3d741a523c6b8a6477a340a1f2e417658de"}, + {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd5c8a1af40ec305d001c60236308a67e25419003e9bb3ebfab5695a8d0b369"}, + {file = "grpcio-1.62.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0477cb31da67846a33b1a75c611f88bfbcd427fe17701b6317aefceee1b96f"}, + {file = "grpcio-1.62.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:60dcd824df166ba266ee0cfaf35a31406cd16ef602b49f5d4dfb21f014b0dedd"}, + {file = "grpcio-1.62.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:973c49086cabab773525f6077f95e5a993bfc03ba8fc32e32f2c279497780585"}, + {file = "grpcio-1.62.1-cp39-cp39-win32.whl", hash = "sha256:12859468e8918d3bd243d213cd6fd6ab07208195dc140763c00dfe901ce1e1b4"}, + {file = "grpcio-1.62.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7209117bbeebdfa5d898205cc55153a51285757902dd73c47de498ad4d11332"}, + {file = "grpcio-1.62.1.tar.gz", hash = "sha256:6c455e008fa86d9e9a9d85bb76da4277c0d7d9668a3bfa70dbe86e9f3c759947"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.62.1)"] + +[[package]] +name = "grpcio-health-checking" +version = "1.62.1" +description = "Standard Health Checking Service for gRPC" +optional = true +python-versions = ">=3.6" +files = [ + {file = "grpcio-health-checking-1.62.1.tar.gz", hash = "sha256:9e56180a941b1d32a077d7491e0611d0483c396358afd5349bf00152612e4583"}, + {file = "grpcio_health_checking-1.62.1-py3-none-any.whl", hash = "sha256:9ce761c09fc383e7aa2f7e6c0b0b65d5a1157c1b98d1f5871f7c38aca47d49b9"}, +] + +[package.dependencies] +grpcio = ">=1.62.1" +protobuf = ">=4.21.6" + +[[package]] +name = "grpcio-status" +version = "1.62.1" +description = "Status proto mapping for gRPC" +optional = true +python-versions = ">=3.6" +files = [ + {file = "grpcio-status-1.62.1.tar.gz", hash = "sha256:3431c8abbab0054912c41df5c72f03ddf3b7a67be8a287bb3c18a3456f96ff77"}, + {file = "grpcio_status-1.62.1-py3-none-any.whl", hash = "sha256:af0c3ab85da31669f21749e8d53d669c061ebc6ce5637be49a46edcb7aa8ab17"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.62.1" +protobuf = ">=4.21.6" + +[[package]] +name = "grpcio-tools" +version = "1.62.1" +description = "Protobuf code generator for gRPC" +optional = true +python-versions = ">=3.7" +files = [ + {file = "grpcio-tools-1.62.1.tar.gz", hash = "sha256:a4991e5ee8a97ab791296d3bf7e8700b1445635cc1828cc98df945ca1802d7f2"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:f2b404bcae7e2ef9b0b9803b2a95119eb7507e6dc80ea4a64a78be052c30cebc"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:fdd987a580b4474769adfd40144486f54bcc73838d5ec5d3647a17883ea78e76"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:07af1a6442e2313cff22af93c2c4dd37ae32b5239b38e0d99e2cbf93de65429f"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41384c9ee18e61ef20cad2774ef71bd8854b63efce263b5177aa06fccb84df1f"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c38006f7702d2ff52122e4c77a47348709374050c76216e84b30a9f06e45afa"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:08fecc3c5b4e6dd3278f2b9d12837e423c7dcff551ca1e587018b4a0fc5f8019"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a01e8dcd0f041f6fa6d815c54a2017d032950e310c41d514a8bc041e872c4d12"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-win32.whl", hash = "sha256:dd933b8e0b3c13fe3543d58f849a6a5e0d7987688cb6801834278378c724f695"}, + {file = "grpcio_tools-1.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:2b04844a9382f1bde4b4174e476e654ab3976168d2469cb4b29e352f4f35a5aa"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:024380536ba71a96cdf736f0954f6ad03f5da609c09edbcc2ca02fdd639e0eed"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:21f14b99e0cd38ad56754cc0b62b2bf3cf75f9f7fc40647da54669e0da0726fe"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:975ac5fb482c23f3608c16e06a43c8bab4d79c2e2564cdbc25cf753c6e998775"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50739aaab0c8076ad5957204e71f2e0c9876e11fd8338f7f09de12c2d75163c5"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:598c54318f0326cf5020aa43fc95a15e933aba4a71943d3bff2677d2d21ddfa1"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f309bdb33a61f8e049480d41498ee2e525cfb5e959958b326abfdf552bf9b9cb"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f358effd3c11d66c150e0227f983d54a5cd30e14038566dadcf25f9f6844e6e8"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-win32.whl", hash = "sha256:b76aead9b73f1650a091870fe4e9ed15ac4d8ed136f962042367255199c23594"}, + {file = "grpcio_tools-1.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:d66a5d47eaa427039752fa0a83a425ff2a487b6a0ac30556fd3be2f3a27a0130"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:575535d039b97d63e6a9abee626d6c7cd47bd8cb73dd00a5c84a98254a2164a4"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:22644c90e43d1a888477899af917979e17364fdd6e9bbb92679cd6a54c4d36c3"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:156d3e1b227c16e903003a56881dbe60e40f2b4bd66f0bc3b27c53e466e6384d"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ad7c5691625a85327e5b683443baf73ae790fd5afc938252041ed5cd665e377"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e140bbc08eea8abf51c0274f45fb1e8350220e64758998d7f3c7f985a0b2496"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7444fcab861911525470d398e5638b70d5cbea3b4674a3de92b5c58c5c515d4d"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e643cd14a5d1e59865cba68a5a6f0175d987f36c5f4cb0db80dee9ed60b4c174"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-win32.whl", hash = "sha256:1344a773d2caa9bb7fbea7e879b84f33740c808c34a5bd2a2768e526117a6b44"}, + {file = "grpcio_tools-1.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:2eea1db3748b2f37b4dce84d8e0c15d9bc811094807cabafe7b0ea47f424dfd5"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-linux_armv7l.whl", hash = "sha256:45d2e6cf04d27286b6f73e6e20ba3f0a1f6d8f5535e5dcb1356200419bb457f4"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:46ae58e6926773e7315e9005f0f17aacedbc0895a8752bec087d24efa2f1fb21"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:4c28086df31478023a36f45e50767872ab3aed2419afff09814cb61c88b77db4"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4fba5b339f4797548591036c9481e6895bf920fab7d3dc664d2697f8fb7c0bf"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23eb3d47f78f509fcd201749b1f1e44b76f447913f7fbb3b8bae20f109086295"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fd5d47707bd6bc2b707ece765c362d2a1d2e8f6cd92b04c99fab49a929f3610c"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d1924a6a943df7c73b9ef0048302327c75962b567451479710da729ead241228"}, + {file = "grpcio_tools-1.62.1-cp37-cp37m-win_amd64.whl", hash = "sha256:fe71ca30aabe42591e84ecb9694c0297dc699cc20c5b24d2cb267fb0fc01f947"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:1819fd055c1ae672d1d725ec75eefd1f700c18acba0ed9332202be31d69c401d"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:5dbe1f7481dd14b6d477b4bace96d275090bc7636b9883975a08b802c94e7b78"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:771c051c5ece27ad03e4f2e33624a925f0ad636c01757ab7dbb04a37964af4ba"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98209c438b38b6f1276dbc27b1c04e346a75bfaafe72a25a548f2dc5ce71d226"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2152308e5321cb90fb45aaa84d03d6dedb19735a8779aaf36c624f97b831842d"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ed1f27dc2b2262c8b8d9036276619c1bb18791311c16ccbf1f31b660f2aad7cf"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2744947b6c5e907af21133431809ccca535a037356864e32c122efed8cb9de1f"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-win32.whl", hash = "sha256:13b20e269d14ad629ff9a2c9a2450f3dbb119d5948de63b27ffe624fa7aea85a"}, + {file = "grpcio_tools-1.62.1-cp38-cp38-win_amd64.whl", hash = "sha256:999823758e9eacd0095863d06cd6d388be769f80c9abb65cdb11c4f2cfce3fea"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:941f8a5c31986053e75fa466bcfa743c2bf1b513b7978cf1f4ab4e96a8219d27"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:b9c02c88c77ef6057c6cbeea8922d7c2424aabf46bfc40ddf42a32765ba91061"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:6abd4eb3ccb444383a40156139acc3aaa73745d395139cb6bc8e2a3429e1e627"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:449503213d142f8470b331a1c2f346f8457f16c7fe20f531bc2500e271f7c14c"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a11bcf609d00cfc9baed77ab308223cabc1f0b22a05774a26dd4c94c0c80f1f"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5d7bdea33354b55acf40bb4dd3ba7324d6f1ef6b4a1a4da0807591f8c7e87b9a"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d03b645852d605f43003020e78fe6d573cae6ee6b944193e36b8b317e7549a20"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-win32.whl", hash = "sha256:52b185dfc3bf32e70929310367dbc66185afba60492a6a75a9b1141d407e160c"}, + {file = "grpcio_tools-1.62.1-cp39-cp39-win_amd64.whl", hash = "sha256:63a273b70896d3640b7a883eb4a080c3c263d91662d870a2e9c84b7bbd978e7b"}, +] + +[package.dependencies] +grpcio = ">=1.62.1" +protobuf = ">=4.21.6,<5.0dev" +setuptools = "*" + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "h2" +version = "4.1.0" +description = "HTTP/2 State-Machine based protocol implementation" +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, + {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, +] + +[package.dependencies] +hpack = ">=4.0,<5" +hyperframe = ">=6.0,<7" + +[[package]] +name = "hpack" +version = "4.0.0" +description = "Pure-Python HPACK header compression" +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, + {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "hvac" +version = "2.1.0" +description = "HashiCorp Vault API client" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "hvac-2.1.0-py3-none-any.whl", hash = "sha256:73bc91e58c3fc7c6b8107cdaca9cb71fa0a893dfd80ffbc1c14e20f24c0c29d7"}, + {file = "hvac-2.1.0.tar.gz", hash = "sha256:b48bcda11a4ab0a7b6c47232c7ba7c87fda318ae2d4a7662800c465a78742894"}, +] + +[package.dependencies] +requests = ">=2.27.1,<3.0.0" + +[package.extras] +parser = ["pyhcl (>=0.4.4,<0.5.0)"] + +[[package]] +name = "hyperframe" +version = "6.0.1" +description = "HTTP/2 framing layer for Python" +optional = true +python-versions = ">=3.6.1" +files = [ + {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, + {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, +] + +[[package]] +name = "ibm-db" +version = "3.2.3" +description = "Python DBI driver for DB2 (LUW, zOS, i5) and IDS" +optional = true +python-versions = "*" +files = [ + {file = "ibm_db-3.2.3-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:3399466141c29704f4e8ba709a67ba27ab413239c0244c3c4510126e946ff603"}, + {file = "ibm_db-3.2.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e12ff6426d4f718e1ff6615e64a2880bd570826f19a031c82dbf296714cafd7d"}, + {file = "ibm_db-3.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442a416a47e0d6ae3de671d227906487a1d731f36da8dc9ba341bd384b97f973"}, + {file = "ibm_db-3.2.3-cp310-cp310-win32.whl", hash = "sha256:8f508caca6407947f4156cae853942d1079736505231246ee51475d7f5af1792"}, + {file = "ibm_db-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:91154c151784be5234c9f327239f1a98fc4e4a5f112c3c94189e04cfab3d5cb0"}, + {file = "ibm_db-3.2.3-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:4fbf78b69d61997dad8ee1fdc273d0b287b43f25fe2ee8c945c034bddb527f1d"}, + {file = "ibm_db-3.2.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a13f20b40ca856ec2a5638f8f4e65287c23ff5e1d808fa58fd8d208678a00323"}, + {file = "ibm_db-3.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5a8ab31130beea18dcd3dd447d6e35ec840ccaed1d3add8ed04ac5c4f44f94c"}, + {file = "ibm_db-3.2.3-cp311-cp311-win32.whl", hash = "sha256:afa8c0a55be2b27ff7f3d50ae0b332562d3048af17557b86854e8e67429fdf0a"}, + {file = "ibm_db-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:d46fb0554631c18fc1f5b615112c68c1b250b7f977dc10cdb53db9258ca69f20"}, + {file = "ibm_db-3.2.3-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7c5011f47edf179c04b67e3472c25f40103679936b17a04dc00a9a7282aeb2b6"}, + {file = "ibm_db-3.2.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b10593eda579395d84254165dc5f5e5eff97d87b9a491181b8632f3db7aa17de"}, + {file = "ibm_db-3.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0170e4c09fb5cf146d26cce0700a40e6a3ab767996f8b4c668847ad1ddc3a06"}, + {file = "ibm_db-3.2.3-cp312-cp312-win32.whl", hash = "sha256:69b2ebf47122eff50497ba48dee7a32087e2698a771c5d86fc683e7baecd5e96"}, + {file = "ibm_db-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7c18f230f8202a386873c73bfe9b00d1c052c35e9a501e07700cb83e7a59c48f"}, + {file = "ibm_db-3.2.3-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:9fdb360b9de86422f8827774680f28ffeba98b702e86f689acaa0f97b62e1693"}, + {file = "ibm_db-3.2.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34bc14c9f4d7f56ab8c7650e06ff3982695dafea2aa90a3a3533e3bcd5ea7be8"}, + {file = "ibm_db-3.2.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067c2a76e230ffe703a89829c4063b9fa951230c91001846c5221bb6ef4a1ef"}, + {file = "ibm_db-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:4141333b42e10eaf97c5712205e873fc4977bd77c2aef416385620f2a01cc32f"}, + {file = "ibm_db-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a1b981485d82d9d23d2c19de2fa1087a6ed5ea134944b1ab10eb0b7758cec512"}, + {file = "ibm_db-3.2.3-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:60d7db80d645eb41c1ddfa9279550784566bdd701c4c52da206e28f9f91e8030"}, + {file = "ibm_db-3.2.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:def6c72fcd31fc3e4fe91ffac94db2f6c3365ae4fe7bc1c284fca741f7a0861e"}, + {file = "ibm_db-3.2.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:611c1ea3c32067083072365eae86b939edb4bc730f6016b670f2264220ac2d63"}, + {file = "ibm_db-3.2.3-cp38-cp38-win32.whl", hash = "sha256:238460936016ec6bbe43dd5612829a6ad19a2f483dde57294869c48809e4c902"}, + {file = "ibm_db-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:6217177a6246ddf86463e090200e7c60459a62af5513b78793ac9f196ef34571"}, + {file = "ibm_db-3.2.3-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4611a10dc4b9eca06aadca5ea697c9af71b16ba0f1076fa7dd66d1698a23d2a6"}, + {file = "ibm_db-3.2.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f736bbd6fc2bec483f82b8e3243a12737fb46bbd0f50b1378c67a28cf2f9649"}, + {file = "ibm_db-3.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b90e5fc0ae75567539cd95d5ded86f7c5507084f6aa52eb16ea0dcc88b25382"}, + {file = "ibm_db-3.2.3-cp39-cp39-win32.whl", hash = "sha256:60db181462194cc1d5fa22514cb73d84c4edf79c12d98c8d16796e72ad179c8b"}, + {file = "ibm_db-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:48008a611a6ca724261866c81680f638e1a4116efb21da4fbc26188679a124ca"}, + {file = "ibm_db-3.2.3.tar.gz", hash = "sha256:ec7075246849437ed79c60447b05a4bee78a3f6ca2646f4e60a028333c72957a"}, +] + +[[package]] +name = "ibm-db-sa" +version = "0.4.1" +description = "SQLAlchemy support for IBM Data Servers" +optional = true +python-versions = "*" +files = [ + {file = "ibm_db_sa-0.4.1-py3-none-any.whl", hash = "sha256:49926ba9799e6ebd9ddd847141537c83d179ecf32fe24b7e997ac4614d3f616a"}, + {file = "ibm_db_sa-0.4.1.tar.gz", hash = "sha256:a46df130a3681646490925cf4e1bca12b46283f71eea39b70b4f9a56e95341ac"}, +] + +[package.dependencies] +ibm-db = ">=2.0.0" +sqlalchemy = ">=0.7.3" + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "7.0.2" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-7.0.2-py3-none-any.whl", hash = "sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100"}, + {file = "importlib_metadata-7.0.2.tar.gz", hash = "sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "influxdb" +version = "5.3.1" +description = "InfluxDB client" +optional = true +python-versions = "*" +files = [ + {file = "influxdb-5.3.1-py2.py3-none-any.whl", hash = "sha256:65040a1f53d1a2a4f88a677e89e3a98189a7d30cf2ab61c318aaa89733280747"}, + {file = "influxdb-5.3.1.tar.gz", hash = "sha256:46f85e7b04ee4b3dee894672be6a295c94709003a7ddea8820deec2ac4d8b27a"}, +] + +[package.dependencies] +msgpack = "*" +python-dateutil = ">=2.6.0" +pytz = "*" +requests = ">=2.17.0" +six = ">=1.10.0" + +[package.extras] +test = ["mock", "nose", "nose-cov", "requests-mock"] + +[[package]] +name = "influxdb-client" +version = "1.41.0" +description = "InfluxDB 2.0 Python client library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "influxdb_client-1.41.0-py3-none-any.whl", hash = "sha256:420d7c5b7ed0f778de0eac923aded3ea3a4eb6b247e3fbb7a187e0a577a5a3be"}, + {file = "influxdb_client-1.41.0.tar.gz", hash = "sha256:4b85bad3991f3de24818366c87c8868a64917fea2d21bbcc2b579fbe5d904990"}, +] + +[package.dependencies] +certifi = ">=14.05.14" +python-dateutil = ">=2.5.3" +reactivex = ">=4.0.4" +setuptools = ">=21.0.0" +urllib3 = ">=1.26.0" + +[package.extras] +async = ["aiocsv (>=1.2.2)", "aiohttp (>=3.8.1)"] +ciso = ["ciso8601 (>=2.1.1)"] +extra = ["numpy", "pandas (>=1.0.0)"] +test = ["aioresponses (>=0.7.3)", "coverage (>=4.0.3)", "flake8 (>=5.0.3)", "httpretty (==1.0.5)", "jinja2 (==3.1.3)", "nose (>=1.3.7)", "pluggy (>=0.3.1)", "psutil (>=5.6.3)", "py (>=1.4.31)", "pytest (>=5.0.0)", "pytest-cov (>=3.0.0)", "pytest-timeout (>=2.1.0)", "randomize (>=0.13)", "sphinx (==1.8.5)", "sphinx-rtd-theme"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isodate" +version = "0.6.1" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = true +python-versions = "*" +files = [ + {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, + {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "jaraco-classes" +version = "3.3.1" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jaraco.classes-3.3.1-py3-none-any.whl", hash = "sha256:86b534de565381f6b3c1c830d13f931d7be1a75f0081c57dff615578676e2206"}, + {file = "jaraco.classes-3.3.1.tar.gz", hash = "sha256:cb28a5ebda8bc47d8c8015307d93163464f9f2b91ab4006e09ff0ce07e8bfb30"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jeepney" +version = "0.8.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, + {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, +] + +[package.extras] +test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["async_generator", "trio"] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = true +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + +[[package]] +name = "jwcrypto" +version = "1.5.6" +description = "Implementation of JOSE Web standards" +optional = true +python-versions = ">= 3.8" +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] + +[package.dependencies] +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" + +[[package]] +name = "kafka-python-ng" +version = "2.2.0" +description = "Pure Python client for Apache Kafka" +optional = false +python-versions = ">=3.8" +files = [ + {file = "kafka-python-ng-2.2.0.tar.gz", hash = "sha256:31d7082fd0ea78702a1eb3c20b5cbb3663d599d916d64a2a517a55ef7c9ebe58"}, + {file = "kafka_python_ng-2.2.0-py2.py3-none-any.whl", hash = "sha256:8f7f1f18ee0d09d905530e8990cf27b0cda0c05faf8098d74284c1069c5e6097"}, +] + +[package.extras] +boto = ["botocore"] +crc32c = ["crc32c"] +lz4 = ["lz4"] +snappy = ["python-snappy"] +zstd = ["zstandard"] + +[[package]] +name = "keyring" +version = "24.3.1" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "keyring-24.3.1-py3-none-any.whl", hash = "sha256:df38a4d7419a6a60fea5cef1e45a948a3e8430dd12ad88b0f423c5c143906218"}, + {file = "keyring-24.3.1.tar.gz", hash = "sha256:c3327b6ffafc0e8befbdb597cacdb4928ffe5c1212f7645f186e6d9957a898db"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +completion = ["shtab (>=1.1.0)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "kubernetes" +version = "29.0.0" +description = "Kubernetes python client" +optional = true +python-versions = ">=3.6" +files = [ + {file = "kubernetes-29.0.0-py2.py3-none-any.whl", hash = "sha256:ab8cb0e0576ccdfb71886366efb102c6a20f268d817be065ce7f9909c631e43e"}, + {file = "kubernetes-29.0.0.tar.gz", hash = "sha256:c4812e227ae74d07d53c88293e564e54b850452715a59a927e7e1bc6b9a60459"}, +] + +[package.dependencies] +certifi = ">=14.05.14" +google-auth = ">=1.0.1" +oauthlib = ">=3.2.2" +python-dateutil = ">=2.5.3" +pyyaml = ">=5.4.1" +requests = "*" +requests-oauthlib = "*" +six = ">=1.9.0" +urllib3 = ">=1.24.2" +websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" + +[package.extras] +adal = ["adal (>=1.0.2)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "marshmallow" +version = "3.21.3" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "marshmallow-3.21.3-py3-none-any.whl", hash = "sha256:86ce7fb914aa865001a4b2092c4c2872d13bc347f3d42673272cabfdbad386f1"}, + {file = "marshmallow-3.21.3.tar.gz", hash = "sha256:4f57c5e050a54d66361e826f94fba213eb10b67b2fdb02c3e0343ce207ba1662"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] +docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.3.7)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "milvus-lite" +version = "2.4.7" +description = "A lightweight version of Milvus wrapped with Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "milvus_lite-2.4.7-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c828190118b104b05b8c8e0b5a4147811c86b54b8fb67bc2e726ad10fc0b544e"}, + {file = "milvus_lite-2.4.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e1537633c39879714fb15082be56a4b97f74c905a6e98e302ec01320561081af"}, + {file = "milvus_lite-2.4.7-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f016474d663045787dddf1c3aad13b7d8b61fd329220318f858184918143dcbf"}, +] + +[[package]] +name = "minio" +version = "7.2.5" +description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage" +optional = true +python-versions = "*" +files = [ + {file = "minio-7.2.5-py3-none-any.whl", hash = "sha256:ed9176c96d4271cb1022b9ecb8a538b1e55b32ae06add6de16425cab99ef2304"}, + {file = "minio-7.2.5.tar.gz", hash = "sha256:59d8906e2da248a9caac34d4958a859cc3a44abbe6447910c82b5abfa9d6a2e1"}, +] + +[package.dependencies] +argon2-cffi = "*" +certifi = "*" +pycryptodome = "*" +typing-extensions = "*" +urllib3 = "*" + +[[package]] +name = "monotonic" +version = "1.6" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +optional = true +python-versions = "*" +files = [ + {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"}, + {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, +] + +[[package]] +name = "more-itertools" +version = "10.2.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.2.0.tar.gz", hash = "sha256:8fccb480c43d3e99a00087634c06dd02b0d50fbf088b380de5a41a015ec239e1"}, + {file = "more_itertools-10.2.0-py3-none-any.whl", hash = "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684"}, +] + +[[package]] +name = "msgpack" +version = "1.0.8" +description = "MessagePack serializer" +optional = true +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, + {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, + {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, + {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, + {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, + {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, + {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, + {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, + {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, + {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, + {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, +] + +[[package]] +name = "mypy" +version = "1.7.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, + {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, + {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, + {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, + {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, + {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, + {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, + {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, + {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, + {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, + {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, + {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, + {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, + {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, + {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, + {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, + {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, + {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, + {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, + {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, + {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, + {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, + {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, + {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, + {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, + {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, + {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nats-py" +version = "2.7.2" +description = "NATS client for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "nats-py-2.7.2.tar.gz", hash = "sha256:0c97b4a57bed0ef1ff9ae6c19bc115ec7ca8ede5ab3e001fd00a377056a547cf"}, +] + +[package.extras] +aiohttp = ["aiohttp"] +fast-parse = ["fast-mail-parser"] +nkeys = ["nkeys"] + +[[package]] +name = "neo4j" +version = "5.18.0" +description = "Neo4j Bolt driver for Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "neo4j-5.18.0.tar.gz", hash = "sha256:4014406ae5b8b485a8ba46c9f00b6f5b4aaf88e7c3a50603445030c2aab701c9"}, +] + +[package.dependencies] +pytz = "*" + +[package.extras] +numpy = ["numpy (>=1.7.0,<2.0.0)"] +pandas = ["numpy (>=1.7.0,<2.0.0)", "pandas (>=1.1.0,<3.0.0)"] +pyarrow = ["pyarrow (>=1.0.0)"] + +[[package]] +name = "nh3" +version = "0.2.15" +description = "Python bindings to the ammonia HTML sanitization library." +optional = false +python-versions = "*" +files = [ + {file = "nh3-0.2.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9c0d415f6b7f2338f93035bba5c0d8c1b464e538bfbb1d598acd47d7969284f0"}, + {file = "nh3-0.2.15-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6f42f99f0cf6312e470b6c09e04da31f9abaadcd3eb591d7d1a88ea931dca7f3"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac19c0d68cd42ecd7ead91a3a032fdfff23d29302dbb1311e641a130dfefba97"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0d77272ce6d34db6c87b4f894f037d55183d9518f948bba236fe81e2bb4e28"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8d595df02413aa38586c24811237e95937ef18304e108b7e92c890a06793e3bf"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86e447a63ca0b16318deb62498db4f76fc60699ce0a1231262880b38b6cff911"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3277481293b868b2715907310c7be0f1b9d10491d5adf9fce11756a97e97eddf"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60684857cfa8fdbb74daa867e5cad3f0c9789415aba660614fe16cd66cbb9ec7"}, + {file = "nh3-0.2.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b803a5875e7234907f7d64777dfde2b93db992376f3d6d7af7f3bc347deb305"}, + {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d02d0ff79dfd8208ed25a39c12cbda092388fff7f1662466e27d97ad011b770"}, + {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f3b53ba93bb7725acab1e030bc2ecd012a817040fd7851b332f86e2f9bb98dc6"}, + {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1e97221cedaf15a54f5243f2c5894bb12ca951ae4ddfd02a9d4ea9df9e1a29d"}, + {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5167a6403d19c515217b6bcaaa9be420974a6ac30e0da9e84d4fc67a5d474c5"}, + {file = "nh3-0.2.15-cp37-abi3-win32.whl", hash = "sha256:427fecbb1031db085eaac9931362adf4a796428ef0163070c484b5a768e71601"}, + {file = "nh3-0.2.15-cp37-abi3-win_amd64.whl", hash = "sha256:bc2d086fb540d0fa52ce35afaded4ea526b8fc4d3339f783db55c95de40ef02e"}, + {file = "nh3-0.2.15.tar.gz", hash = "sha256:d1e30ff2d8d58fb2a14961f7aac1bbb1c51f9bdd7da727be35c63826060b0bf3"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = true +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "opensearch-py" +version = "2.4.2" +description = "Python client for OpenSearch" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +files = [ + {file = "opensearch-py-2.4.2.tar.gz", hash = "sha256:564f175af134aa885f4ced6846eb4532e08b414fff0a7976f76b276fe0e69158"}, + {file = "opensearch_py-2.4.2-py2.py3-none-any.whl", hash = "sha256:7867319132133e2974c09f76a54eb1d502b989229be52da583d93ddc743ea111"}, +] + +[package.dependencies] +certifi = ">=2022.12.07" +python-dateutil = "*" +requests = ">=2.4.0,<3.0.0" +six = "*" +urllib3 = ">=1.26.18" + +[package.extras] +async = ["aiohttp (>=3,<4)"] +develop = ["black", "botocore", "coverage (<8.0.0)", "jinja2", "mock", "myst-parser", "pytest (>=3.0.0)", "pytest-cov", "pytest-mock (<4.0.0)", "pytz", "pyyaml", "requests (>=2.0.0,<3.0.0)", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] +docs = ["aiohttp (>=3,<4)", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] +kerberos = ["requests-kerberos"] + +[[package]] +name = "opentelemetry-api" +version = "1.16.0" +description = "OpenTelemetry Python API" +optional = true +python-versions = ">=3.7" +files = [ + {file = "opentelemetry_api-1.16.0-py3-none-any.whl", hash = "sha256:79e8f0cf88dbdd36b6abf175d2092af1efcaa2e71552d0d2b3b181a9707bf4bc"}, + {file = "opentelemetry_api-1.16.0.tar.gz", hash = "sha256:4b0e895a3b1f5e1908043ebe492d33e33f9ccdbe6d02d3994c2f8721a63ddddb"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +setuptools = ">=16.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.16.0" +description = "OpenTelemetry Collector Protobuf over gRPC Exporter" +optional = true +python-versions = ">=3.7" +files = [ + {file = "opentelemetry_exporter_otlp_proto_grpc-1.16.0-py3-none-any.whl", hash = "sha256:ace2cedc43bc30e1b2475b14f72acf1a1528716965209d31fb0a72c59f0f4fe4"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.16.0.tar.gz", hash = "sha256:0853ea1e566c1fab5633e7f7bca2a650ba445b04ba02f93173920b0f5c561f63"}, +] + +[package.dependencies] +backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""} +googleapis-common-protos = ">=1.52,<2.0" +grpcio = ">=1.0.0,<2.0.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-proto = "1.16.0" +opentelemetry-sdk = ">=1.16.0,<1.17.0" + +[package.extras] +test = ["pytest-grpc"] + +[[package]] +name = "opentelemetry-proto" +version = "1.16.0" +description = "OpenTelemetry Python Proto" +optional = true +python-versions = ">=3.7" +files = [ + {file = "opentelemetry_proto-1.16.0-py3-none-any.whl", hash = "sha256:160326d300faf43c3f72c4a916516ee5b63289ceb9828294b698ef943697cbd5"}, + {file = "opentelemetry_proto-1.16.0.tar.gz", hash = "sha256:e58832dfec64621972a9836f8ae163fb3063946eb02bdf43fae0f76f8cf46d0a"}, +] + +[package.dependencies] +protobuf = ">=3.19,<5.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.16.0" +description = "OpenTelemetry Python SDK" +optional = true +python-versions = ">=3.7" +files = [ + {file = "opentelemetry_sdk-1.16.0-py3-none-any.whl", hash = "sha256:15f03915eec4839f885a5e6ed959cde59b8690c8c012d07c95b4b138c98dc43f"}, + {file = "opentelemetry_sdk-1.16.0.tar.gz", hash = "sha256:4d3bb91e9e209dbeea773b5565d901da4f76a29bf9dbc1c9500be3cabb239a4e"}, +] + +[package.dependencies] +opentelemetry-api = "1.16.0" +opentelemetry-semantic-conventions = "0.37b0" +setuptools = ">=16.0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.37b0" +description = "OpenTelemetry Semantic Conventions" +optional = true +python-versions = ">=3.7" +files = [ + {file = "opentelemetry_semantic_conventions-0.37b0-py3-none-any.whl", hash = "sha256:462982278a42dab01f68641cd89f8460fe1f93e87c68a012a76fb426dcdba5ee"}, + {file = "opentelemetry_semantic_conventions-0.37b0.tar.gz", hash = "sha256:087ce2e248e42f3ffe4d9fa2303111de72bb93baa06a0f4655980bc1557c4228"}, +] + +[[package]] +name = "oracledb" +version = "2.1.1" +description = "Python interface to Oracle Database" +optional = true +python-versions = ">=3.7" +files = [ + {file = "oracledb-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0666088fcca29cfe8a8428888426655d2f7417adfb854ad0af79c40f1bae59aa"}, + {file = "oracledb-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8651ea5b9ef35493aa1bb80786458a26df3d728eb3e55b6bb9ddf2aa83f45be8"}, + {file = "oracledb-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ede73b90c24387b47094aadae477a60e9c076d04275a7a5ae9deef0991e3a2"}, + {file = "oracledb-2.1.1-cp310-cp310-win32.whl", hash = "sha256:962e0fce942eefafe8c52481c1979dd53c6929d479544499d2e32a1eb4111837"}, + {file = "oracledb-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:ec1012362c6ba5a465d87730c14bbbc3bac3a0799cd4371c6514364f2121dd42"}, + {file = "oracledb-2.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a94793243936117b4a8ea4a17672aa77f71c9063a176f0330ea733a48679d82c"}, + {file = "oracledb-2.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cc8e853d33333c5e3cd4c2db0591c374c5fe06ebd713748b65c59be5b252054"}, + {file = "oracledb-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d29cb729cd94e7afbfbef8f0d272bd8e20b16be77ae747686b677e9e9df0ad3f"}, + {file = "oracledb-2.1.1-cp311-cp311-win32.whl", hash = "sha256:c0c29bdafe78d412bec20d91791dc91fc7feb9b63a39138a3ebd5835abf3639d"}, + {file = "oracledb-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:d539300667bf7af839baddb42896175c14d872cc0d903dace7850e91d90387f1"}, + {file = "oracledb-2.1.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:166216272dcaaa2647fde3b7e13f9d3ffe0f05dc70791aacf7ba5e2c65c96aca"}, + {file = "oracledb-2.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eeb64b15c75b4d62fa060516d6bbd9f8f42272d677a48f6293597945836e9f0d"}, + {file = "oracledb-2.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c16782bc37087947052f3d398be6e7d4bb3143a6863a5a1517f63c92c1b2b99"}, + {file = "oracledb-2.1.1-cp312-cp312-win32.whl", hash = "sha256:50791fadf26e97a8cedcc1bf16ed5c5f0f2f1a0254e3725889e52eba1adc8f22"}, + {file = "oracledb-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:29b000e4892f6c4eac6abe099bc43df9ccd586d9a02fa6391e0bd8e4eb88df37"}, + {file = "oracledb-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:48057c0309d2e5f4b78804bd73b4e32654ff912515da17163262142a5f451e0e"}, + {file = "oracledb-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc53191a389a2b6119488635c63e626e76df3394403d0e907260de9ad01c0a6c"}, + {file = "oracledb-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0750de1b381f6d3508904e079491a99d61742db109131dc664d4b3d146ee6f17"}, + {file = "oracledb-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:7262457d256c30a738cf3dde4ec175d2d382c6c7b9e3395665c04c25b34d28b0"}, + {file = "oracledb-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ec05d1a56e9f45741ed60e7c24a8c9cc0f1982241aa555d57b04ed0879b59228"}, + {file = "oracledb-2.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:2611a9bd530829ba2332db8a7083699579263cf3fb1093b0e1f5b5a57239e18e"}, + {file = "oracledb-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bba26b23d3e2905eb4c7627a9a1c31ad72c64442e184173c48f19bde2a92184"}, + {file = "oracledb-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e7f9cb56e2d79a11f4b5a9233067b9eb327a30e6e8dad8349b02eca64482ab5"}, + {file = "oracledb-2.1.1-cp38-cp38-win32.whl", hash = "sha256:1658760585f8c41e6530e25332ccdde3ca0b95b4e7c2a9a69719da0420da7fe6"}, + {file = "oracledb-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:bc013fac5009e6ebc65be58e7d3645aadd04ef8627553cdfe6e66a157963087a"}, + {file = "oracledb-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:479bcec29826ca2a49d1b113ca343ad31db8b94063d50223ab8d32c069faf4b3"}, + {file = "oracledb-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c7b0aa7038d7444ea133a2439dc9b645fb5c81832dd24818c181494423f752"}, + {file = "oracledb-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce1f93bea32abd2d3dcfc17ed0546a20d36bc7a92b36ced58b525165fd63c8a"}, + {file = "oracledb-2.1.1-cp39-cp39-win32.whl", hash = "sha256:95c581d4df786d3aa6e2e47c8519b9036b28c1ea1416cf843b9572ba611a54d6"}, + {file = "oracledb-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:1bf3ba201f21e8183a33b32ec1224b5d65b98ac0b1bf843b98b0b65ef34c1b26"}, + {file = "oracledb-2.1.1.tar.gz", hash = "sha256:e2e817cfa6dff36c7131736f34e2aaec75d726fd38d1910d8318061a278228fa"}, +] + +[package.dependencies] +cryptography = ">=3.2.1" + +[[package]] +name = "orjson" +version = "3.10.0" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = true +python-versions = ">=3.8" +files = [ + {file = "orjson-3.10.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47af5d4b850a2d1328660661f0881b67fdbe712aea905dadd413bdea6f792c33"}, + {file = "orjson-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c90681333619d78360d13840c7235fdaf01b2b129cb3a4f1647783b1971542b6"}, + {file = "orjson-3.10.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:400c5b7c4222cb27b5059adf1fb12302eebcabf1978f33d0824aa5277ca899bd"}, + {file = "orjson-3.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5dcb32e949eae80fb335e63b90e5808b4b0f64e31476b3777707416b41682db5"}, + {file = "orjson-3.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7d507c7493252c0a0264b5cc7e20fa2f8622b8a83b04d819b5ce32c97cf57b"}, + {file = "orjson-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e286a51def6626f1e0cc134ba2067dcf14f7f4b9550f6dd4535fd9d79000040b"}, + {file = "orjson-3.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8acd4b82a5f3a3ec8b1dc83452941d22b4711964c34727eb1e65449eead353ca"}, + {file = "orjson-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:30707e646080dd3c791f22ce7e4a2fc2438765408547c10510f1f690bd336217"}, + {file = "orjson-3.10.0-cp310-none-win32.whl", hash = "sha256:115498c4ad34188dcb73464e8dc80e490a3e5e88a925907b6fedcf20e545001a"}, + {file = "orjson-3.10.0-cp310-none-win_amd64.whl", hash = "sha256:6735dd4a5a7b6df00a87d1d7a02b84b54d215fb7adac50dd24da5997ffb4798d"}, + {file = "orjson-3.10.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9587053e0cefc284e4d1cd113c34468b7d3f17666d22b185ea654f0775316a26"}, + {file = "orjson-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bef1050b1bdc9ea6c0d08468e3e61c9386723633b397e50b82fda37b3563d72"}, + {file = "orjson-3.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d16c6963ddf3b28c0d461641517cd312ad6b3cf303d8b87d5ef3fa59d6844337"}, + {file = "orjson-3.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4251964db47ef090c462a2d909f16c7c7d5fe68e341dabce6702879ec26d1134"}, + {file = "orjson-3.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73bbbdc43d520204d9ef0817ac03fa49c103c7f9ea94f410d2950755be2c349c"}, + {file = "orjson-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:414e5293b82373606acf0d66313aecb52d9c8c2404b1900683eb32c3d042dbd7"}, + {file = "orjson-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:feaed5bb09877dc27ed0d37f037ddef6cb76d19aa34b108db270d27d3d2ef747"}, + {file = "orjson-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5127478260db640323cea131ee88541cb1a9fbce051f0b22fa2f0892f44da302"}, + {file = "orjson-3.10.0-cp311-none-win32.whl", hash = "sha256:b98345529bafe3c06c09996b303fc0a21961820d634409b8639bc16bd4f21b63"}, + {file = "orjson-3.10.0-cp311-none-win_amd64.whl", hash = "sha256:658ca5cee3379dd3d37dbacd43d42c1b4feee99a29d847ef27a1cb18abdfb23f"}, + {file = "orjson-3.10.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4329c1d24fd130ee377e32a72dc54a3c251e6706fccd9a2ecb91b3606fddd998"}, + {file = "orjson-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef0f19fdfb6553342b1882f438afd53c7cb7aea57894c4490c43e4431739c700"}, + {file = "orjson-3.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4f60db24161534764277f798ef53b9d3063092f6d23f8f962b4a97edfa997a0"}, + {file = "orjson-3.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1de3fd5c7b208d836f8ecb4526995f0d5877153a4f6f12f3e9bf11e49357de98"}, + {file = "orjson-3.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f93e33f67729d460a177ba285002035d3f11425ed3cebac5f6ded4ef36b28344"}, + {file = "orjson-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:237ba922aef472761acd697eef77fef4831ab769a42e83c04ac91e9f9e08fa0e"}, + {file = "orjson-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98c1bfc6a9bec52bc8f0ab9b86cc0874b0299fccef3562b793c1576cf3abb570"}, + {file = "orjson-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30d795a24be16c03dca0c35ca8f9c8eaaa51e3342f2c162d327bd0225118794a"}, + {file = "orjson-3.10.0-cp312-none-win32.whl", hash = "sha256:6a3f53dc650bc860eb26ec293dfb489b2f6ae1cbfc409a127b01229980e372f7"}, + {file = "orjson-3.10.0-cp312-none-win_amd64.whl", hash = "sha256:983db1f87c371dc6ffc52931eb75f9fe17dc621273e43ce67bee407d3e5476e9"}, + {file = "orjson-3.10.0-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a667769a96a72ca67237224a36faf57db0c82ab07d09c3aafc6f956196cfa1b"}, + {file = "orjson-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade1e21dfde1d37feee8cf6464c20a2f41fa46c8bcd5251e761903e46102dc6b"}, + {file = "orjson-3.10.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23c12bb4ced1c3308eff7ba5c63ef8f0edb3e4c43c026440247dd6c1c61cea4b"}, + {file = "orjson-3.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2d014cf8d4dc9f03fc9f870de191a49a03b1bcda51f2a957943fb9fafe55aac"}, + {file = "orjson-3.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eadecaa16d9783affca33597781328e4981b048615c2ddc31c47a51b833d6319"}, + {file = "orjson-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd583341218826f48bd7c6ebf3310b4126216920853cbc471e8dbeaf07b0b80e"}, + {file = "orjson-3.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:90bfc137c75c31d32308fd61951d424424426ddc39a40e367704661a9ee97095"}, + {file = "orjson-3.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:13b5d3c795b09a466ec9fcf0bd3ad7b85467d91a60113885df7b8d639a9d374b"}, + {file = "orjson-3.10.0-cp38-none-win32.whl", hash = "sha256:5d42768db6f2ce0162544845facb7c081e9364a5eb6d2ef06cd17f6050b048d8"}, + {file = "orjson-3.10.0-cp38-none-win_amd64.whl", hash = "sha256:33e6655a2542195d6fd9f850b428926559dee382f7a862dae92ca97fea03a5ad"}, + {file = "orjson-3.10.0-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4050920e831a49d8782a1720d3ca2f1c49b150953667eed6e5d63a62e80f46a2"}, + {file = "orjson-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1897aa25a944cec774ce4a0e1c8e98fb50523e97366c637b7d0cddabc42e6643"}, + {file = "orjson-3.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bf565a69e0082ea348c5657401acec3cbbb31564d89afebaee884614fba36b4"}, + {file = "orjson-3.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6ebc17cfbbf741f5c1a888d1854354536f63d84bee537c9a7c0335791bb9009"}, + {file = "orjson-3.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2817877d0b69f78f146ab305c5975d0618df41acf8811249ee64231f5953fee"}, + {file = "orjson-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57d017863ec8aa4589be30a328dacd13c2dc49de1c170bc8d8c8a98ece0f2925"}, + {file = "orjson-3.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22c2f7e377ac757bd3476ecb7480c8ed79d98ef89648f0176deb1da5cd014eb7"}, + {file = "orjson-3.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e62ba42bfe64c60c1bc84799944f80704e996592c6b9e14789c8e2a303279912"}, + {file = "orjson-3.10.0-cp39-none-win32.whl", hash = "sha256:60c0b1bdbccd959ebd1575bd0147bd5e10fc76f26216188be4a36b691c937077"}, + {file = "orjson-3.10.0-cp39-none-win_amd64.whl", hash = "sha256:175a41500ebb2fdf320bf78e8b9a75a1279525b62ba400b2b2444e274c2c8bee"}, + {file = "orjson-3.10.0.tar.gz", hash = "sha256:ba4d8cac5f2e2cff36bea6b6481cdb92b38c202bcec603d6f5ff91960595a1ed"}, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." +optional = true +python-versions = ">=3.7" +files = [ + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + +[[package]] +name = "overrides" +version = "7.7.0" +description = "A decorator to automatically detect mismatch when overriding a method." +optional = true +python-versions = ">=3.6" +files = [ + {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, + {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = false +python-versions = ">=3.7" +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + +[[package]] +name = "pandas" +version = "2.2.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce"}, + {file = "pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99"}, + {file = "pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1"}, + {file = "pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32"}, + {file = "pandas-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57"}, + {file = "pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4"}, + {file = "pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "paramiko" +version = "3.4.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "paramiko-3.4.0-py3-none-any.whl", hash = "sha256:43f0b51115a896f9c00f59618023484cb3a14b98bbceab43394a39c6739b7ee7"}, + {file = "paramiko-3.4.0.tar.gz", hash = "sha256:aac08f26a31dc4dffd92821527d1682d99d52f9ef6851968114a8728f3c274d3"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +pynacl = ">=1.5" + +[package.extras] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=2.0)"] + +[[package]] +name = "pg8000" +version = "1.30.5" +description = "PostgreSQL interface library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pg8000-1.30.5-py3-none-any.whl", hash = "sha256:1abf18da652b0ad8e9cbfe57ed841c350b5330c33d8151303555db1fe5ce57f8"}, + {file = "pg8000-1.30.5.tar.gz", hash = "sha256:072f7ad00cd723695cb2e9fc02c1dfb84c781455e97b8de6f4c4281eea08078c"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.2" +scramp = ">=1.4.4" + +[[package]] +name = "pika" +version = "1.3.2" +description = "Pika Python AMQP Client Library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "pika-1.3.2-py3-none-any.whl", hash = "sha256:0779a7c1fafd805672796085560d290213a465e4f6f76a6fb19e378d8041a14f"}, + {file = "pika-1.3.2.tar.gz", hash = "sha256:b2a327ddddf8570b4965b3576ac77091b850262d34ce8c1d8cb4e4146aa4145f"}, +] + +[package.extras] +gevent = ["gevent"] +tornado = ["tornado"] +twisted = ["twisted"] + +[[package]] +name = "pkginfo" +version = "1.10.0" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097"}, + {file = "pkginfo-1.10.0.tar.gz", hash = "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "portalocker" +version = "2.8.2" +description = "Wraps the portalocker recipe for easy usage" +optional = true +python-versions = ">=3.8" +files = [ + {file = "portalocker-2.8.2-py3-none-any.whl", hash = "sha256:cfb86acc09b9aa7c3b43594e19be1345b9d16af3feb08bf92f23d4dce513a28e"}, + {file = "portalocker-2.8.2.tar.gz", hash = "sha256:2b035aa7828e46c58e9b31390ee1f169b98e1066ab10b9a6a861fe7e25ee4f33"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] + +[[package]] +name = "posthog" +version = "3.5.0" +description = "Integrate PostHog into any python application." +optional = true +python-versions = "*" +files = [ + {file = "posthog-3.5.0-py2.py3-none-any.whl", hash = "sha256:3c672be7ba6f95d555ea207d4486c171d06657eb34b3ce25eb043bfe7b6b5b76"}, + {file = "posthog-3.5.0.tar.gz", hash = "sha256:8f7e3b2c6e8714d0c0c542a2109b83a7549f63b7113a133ab2763a89245ef2ef"}, +] + +[package.dependencies] +backoff = ">=1.10.0" +monotonic = ">=1.5" +python-dateutil = ">2.1" +requests = ">=2.7,<3.0" +six = ">=1.5" + +[package.extras] +dev = ["black", "flake8", "flake8-print", "isort", "pre-commit"] +sentry = ["django", "sentry-sdk"] +test = ["coverage", "flake8", "freezegun (==0.3.15)", "mock (>=2.0.0)", "pylint", "pytest", "pytest-timeout"] + +[[package]] +name = "pre-commit" +version = "3.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "proto-plus" +version = "1.23.0" +description = "Beautiful, Pythonic protocol buffers." +optional = true +python-versions = ">=3.6" +files = [ + {file = "proto-plus-1.23.0.tar.gz", hash = "sha256:89075171ef11988b3fa157f5dbd8b9cf09d65fffee97e29ce403cd8defba19d2"}, + {file = "proto_plus-1.23.0-py3-none-any.whl", hash = "sha256:a829c79e619e1cf632de091013a4173deed13a55f326ef84f05af6f50ff4c82c"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<5.0.0dev" + +[package.extras] +testing = ["google-api-core[grpc] (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "4.25.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-4.25.3-cp310-abi3-win32.whl", hash = "sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa"}, + {file = "protobuf-4.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8"}, + {file = "protobuf-4.25.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d"}, + {file = "protobuf-4.25.3-cp38-cp38-win32.whl", hash = "sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2"}, + {file = "protobuf-4.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4"}, + {file = "protobuf-4.25.3-cp39-cp39-win32.whl", hash = "sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4"}, + {file = "protobuf-4.25.3-cp39-cp39-win_amd64.whl", hash = "sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c"}, + {file = "protobuf-4.25.3-py3-none-any.whl", hash = "sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9"}, + {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"}, +] + +[[package]] +name = "psycopg" +version = "3.1.18" +description = "PostgreSQL database adapter for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg-3.1.18-py3-none-any.whl", hash = "sha256:4d5a0a5a8590906daa58ebd5f3cfc34091377354a1acced269dd10faf55da60e"}, + {file = "psycopg-3.1.18.tar.gz", hash = "sha256:31144d3fb4c17d78094d9e579826f047d4af1da6a10427d91dfcfb6ecdf6f12b"}, +] + +[package.dependencies] +typing-extensions = ">=4.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.1.18)"] +c = ["psycopg-c (==3.1.18)"] +dev = ["black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.4.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] +docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] +pool = ["psycopg-pool"] +test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, +] + +[[package]] +name = "pyasn1" +version = "0.5.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1-0.5.1-py2.py3-none-any.whl", hash = "sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58"}, + {file = "pyasn1-0.5.1.tar.gz", hash = "sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.3.0" +description = "A collection of ASN.1-based protocols modules" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1_modules-0.3.0-py2.py3-none-any.whl", hash = "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d"}, + {file = "pyasn1_modules-0.3.0.tar.gz", hash = "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.6.0" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + +[[package]] +name = "pycryptodome" +version = "3.20.0" +description = "Cryptographic library for Python" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "pycryptodome-3.20.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-win32.whl", hash = "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818"}, + {file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044"}, + {file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4"}, + {file = "pycryptodome-3.20.0-cp35-abi3-win32.whl", hash = "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72"}, + {file = "pycryptodome-3.20.0-cp35-abi3-win_amd64.whl", hash = "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9"}, + {file = "pycryptodome-3.20.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a"}, + {file = "pycryptodome-3.20.0-pp27-pypy_73-win32.whl", hash = "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e"}, + {file = "pycryptodome-3.20.0.tar.gz", hash = "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7"}, +] + +[[package]] +name = "pydantic" +version = "2.6.4" +description = "Data validation using Python type hints" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.3" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.3" +description = "" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"}, + {file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"}, + {file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"}, + {file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"}, + {file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"}, + {file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"}, + {file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"}, + {file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"}, + {file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"}, + {file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"}, + {file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"}, + {file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"}, + {file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"}, + {file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"}, + {file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"}, + {file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"}, + {file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"}, + {file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"}, + {file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"}, + {file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"}, + {file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"}, + {file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"}, + {file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"}, + {file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"}, + {file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"}, + {file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"}, + {file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"}, + {file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"}, + {file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"}, + {file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.17.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[package.extras] +plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = true +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pymilvus" +version = "2.4.3" +description = "Python Sdk for Milvus" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pymilvus-2.4.3-py3-none-any.whl", hash = "sha256:38239e89f8d739f665141d0b80908990b5f59681e889e135c234a4a45669a5c8"}, + {file = "pymilvus-2.4.3.tar.gz", hash = "sha256:703ac29296cdce03d6dc2aaebbe959e57745c141a94150e371dc36c61c226cc1"}, +] + +[package.dependencies] +environs = "<=9.5.0" +grpcio = ">=1.49.1,<=1.63.0" +milvus-lite = ">=2.4.0,<2.5.0" +pandas = ">=1.2.4" +protobuf = ">=3.20.0" +setuptools = ">=67" +ujson = ">=2.0.0" + +[package.extras] +bulk-writer = ["azure-storage-blob", "minio (>=7.0.0)", "pyarrow (>=12.0.0)", "requests"] +dev = ["black", "grpcio (==1.62.2)", "grpcio-testing (==1.62.2)", "grpcio-tools (==1.62.2)", "pytest (>=5.3.4)", "pytest-cov (>=2.8.1)", "pytest-timeout (>=1.3.4)", "ruff (>0.4.0)"] +model = ["milvus-model (>=0.1.0)"] + +[[package]] +name = "pymongo" +version = "4.6.2" +description = "Python driver for MongoDB " +optional = true +python-versions = ">=3.7" +files = [ + {file = "pymongo-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7640d176ee5b0afec76a1bda3684995cb731b2af7fcfd7c7ef8dc271c5d689af"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux1_i686.whl", hash = "sha256:4e2129ec8f72806751b621470ac5d26aaa18fae4194796621508fa0e6068278a"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:c43205e85cbcbdf03cff62ad8f50426dd9d20134a915cfb626d805bab89a1844"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:91ddf95cedca12f115fbc5f442b841e81197d85aa3cc30b82aee3635a5208af2"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:0fbdbf2fba1b4f5f1522e9f11e21c306e095b59a83340a69e908f8ed9b450070"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:097791d5a8d44e2444e0c8c4d6e14570ac11e22bcb833808885a5db081c3dc2a"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:e0b208ebec3b47ee78a5c836e2e885e8c1e10f8ffd101aaec3d63997a4bdcd04"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1849fd6f1917b4dc5dbf744b2f18e41e0538d08dd8e9ba9efa811c5149d665a3"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa0bbbfbd1f8ebbd5facaa10f9f333b20027b240af012748555148943616fdf3"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4522ad69a4ab0e1b46a8367d62ad3865b8cd54cf77518c157631dac1fdc97584"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397949a9cc85e4a1452f80b7f7f2175d557237177120954eff00bf79553e89d3"}, + {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d511db310f43222bc58d811037b176b4b88dc2b4617478c5ef01fea404f8601"}, + {file = "pymongo-4.6.2-cp310-cp310-win32.whl", hash = "sha256:991e406db5da4d89fb220a94d8caaf974ffe14ce6b095957bae9273c609784a0"}, + {file = "pymongo-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:94637941fe343000f728e28d3fe04f1f52aec6376b67b85583026ff8dab2a0e0"}, + {file = "pymongo-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:84593447a5c5fe7a59ba86b72c2c89d813fbac71c07757acdf162fbfd5d005b9"}, + {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aebddb2ec2128d5fc2fe3aee6319afef8697e0374f8a1fcca3449d6f625e7b4"}, + {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f706c1a644ed33eaea91df0a8fb687ce572b53eeb4ff9b89270cb0247e5d0e1"}, + {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18c422e6b08fa370ed9d8670c67e78d01f50d6517cec4522aa8627014dfa38b6"}, + {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d002ae456a15b1d790a78bb84f87af21af1cb716a63efb2c446ab6bcbbc48ca"}, + {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f86ba0c781b497a3c9c886765d7b6402a0e3ae079dd517365044c89cd7abb06"}, + {file = "pymongo-4.6.2-cp311-cp311-win32.whl", hash = "sha256:ac20dd0c7b42555837c86f5ea46505f35af20a08b9cf5770cd1834288d8bd1b4"}, + {file = "pymongo-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:e78af59fd0eb262c2a5f7c7d7e3b95e8596a75480d31087ca5f02f2d4c6acd19"}, + {file = "pymongo-4.6.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6125f73503407792c8b3f80165f8ab88a4e448d7d9234c762681a4d0b446fcb4"}, + {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba052446a14bd714ec83ca4e77d0d97904f33cd046d7bb60712a6be25eb31dbb"}, + {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b65433c90e07dc252b4a55dfd885ca0df94b1cf77c5b8709953ec1983aadc03"}, + {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2160d9c8cd20ce1f76a893f0daf7c0d38af093f36f1b5c9f3dcf3e08f7142814"}, + {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f251f287e6d42daa3654b686ce1fcb6d74bf13b3907c3ae25954978c70f2cd4"}, + {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d227a60b00925dd3aeae4675575af89c661a8e89a1f7d1677e57eba4a3693c"}, + {file = "pymongo-4.6.2-cp312-cp312-win32.whl", hash = "sha256:311794ef3ccae374aaef95792c36b0e5c06e8d5cf04a1bdb1b2bf14619ac881f"}, + {file = "pymongo-4.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:f673b64a0884edcc56073bda0b363428dc1bf4eb1b5e7d0b689f7ec6173edad6"}, + {file = "pymongo-4.6.2-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:fe010154dfa9e428bd2fb3e9325eff2216ab20a69ccbd6b5cac6785ca2989161"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f5f4cd2969197e25b67e24d5b8aa2452d381861d2791d06c493eaa0b9c9fcfe"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9519c9d341983f3a1bd19628fecb1d72a48d8666cf344549879f2e63f54463b"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c68bf4a399e37798f1b5aa4f6c02886188ef465f4ac0b305a607b7579413e366"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a509db602462eb736666989739215b4b7d8f4bb8ac31d0bffd4be9eae96c63ef"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:362a5adf6f3f938a8ff220a4c4aaa93e84ef932a409abecd837c617d17a5990f"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:ee30a9d4c27a88042d0636aca0275788af09cc237ae365cd6ebb34524bddb9cc"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:477914e13501bb1d4608339ee5bb618be056d2d0e7267727623516cfa902e652"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd343ca44982d480f1e39372c48e8e263fc6f32e9af2be456298f146a3db715"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3797e0a628534e07a36544d2bfa69e251a578c6d013e975e9e3ed2ac41f2d95"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97d81d357e1a2a248b3494d52ebc8bf15d223ee89d59ee63becc434e07438a24"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed694c0d1977cb54281cb808bc2b247c17fb64b678a6352d3b77eb678ebe1bd9"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ceaaff4b812ae368cf9774989dea81b9bbb71e5bed666feca6a9f3087c03e49"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7dd63f7c2b3727541f7f37d0fb78d9942eb12a866180fbeb898714420aad74e2"}, + {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e571434633f99a81e081738721bb38e697345281ed2f79c2f290f809ba3fbb2f"}, + {file = "pymongo-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:3e9f6e2f3da0a6af854a3e959a6962b5f8b43bbb8113cd0bff0421c5059b3106"}, + {file = "pymongo-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:3a5280f496297537301e78bde250c96fadf4945e7b2c397d8bb8921861dd236d"}, + {file = "pymongo-4.6.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5f6bcd2d012d82d25191a911a239fd05a8a72e8c5a7d81d056c0f3520cad14d1"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4fa30494601a6271a8b416554bd7cde7b2a848230f0ec03e3f08d84565b4bf8c"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bea62f03a50f363265a7a651b4e2a4429b4f138c1864b2d83d4bf6f9851994be"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b2d445f1cf147331947cc35ec10342f898329f29dd1947a3f8aeaf7e0e6878d1"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:5db133d6ec7a4f7fc7e2bd098e4df23d7ad949f7be47b27b515c9fb9301c61e4"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:9eec7140cf7513aa770ea51505d312000c7416626a828de24318fdcc9ac3214c"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:5379ca6fd325387a34cda440aec2bd031b5ef0b0aa2e23b4981945cff1dab84c"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:579508536113dbd4c56e4738955a18847e8a6c41bf3c0b4ab18b51d81a6b7be8"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3bae553ca39ed52db099d76acd5e8566096064dc7614c34c9359bb239ec4081"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0257e0eebb50f242ca28a92ef195889a6ad03dcdde5bf1c7ab9f38b7e810801"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbafe3a1df21eeadb003c38fc02c1abf567648b6477ec50c4a3c042dca205371"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaecfafb407feb6f562c7f2f5b91f22bfacba6dd739116b1912788cff7124c4a"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e942945e9112075a84d2e2d6e0d0c98833cdcdfe48eb8952b917f996025c7ffa"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f7b98f8d2cf3eeebde738d080ae9b4276d7250912d9751046a9ac1efc9b1ce2"}, + {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8110b78fc4b37dced85081d56795ecbee6a7937966e918e05e33a3900e8ea07d"}, + {file = "pymongo-4.6.2-cp38-cp38-win32.whl", hash = "sha256:df813f0c2c02281720ccce225edf39dc37855bf72cdfde6f789a1d1cf32ffb4b"}, + {file = "pymongo-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:64ec3e2dcab9af61bdbfcb1dd863c70d1b0c220b8e8ac11df8b57f80ee0402b3"}, + {file = "pymongo-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bff601fbfcecd2166d9a2b70777c2985cb9689e2befb3278d91f7f93a0456cae"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f1febca6f79e91feafc572906871805bd9c271b6a2d98a8bb5499b6ace0befed"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d788cb5cc947d78934be26eef1623c78cec3729dc93a30c23f049b361aa6d835"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5c2f258489de12a65b81e1b803a531ee8cf633fa416ae84de65cd5f82d2ceb37"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:fb24abcd50501b25d33a074c1790a1389b6460d2509e4b240d03fd2e5c79f463"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:4d982c6db1da7cf3018183891883660ad085de97f21490d314385373f775915b"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:b2dd8c874927a27995f64a3b44c890e8a944c98dec1ba79eab50e07f1e3f801b"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:4993593de44c741d1e9f230f221fe623179f500765f9855936e4ff6f33571bad"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:658f6c028edaeb02761ebcaca8d44d519c22594b2a51dcbc9bd2432aa93319e3"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68109c13176749fbbbbbdb94dd4a58dcc604db6ea43ee300b2602154aebdd55f"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707d28a822b918acf941cff590affaddb42a5d640614d71367c8956623a80cbc"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f251db26c239aec2a4d57fbe869e0a27b7f6b5384ec6bf54aeb4a6a5e7408234"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57c05f2e310701fc17ae358caafd99b1830014e316f0242d13ab6c01db0ab1c2"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b575fbe6396bbf21e4d0e5fd2e3cdb656dc90c930b6c5532192e9a89814f72d"}, + {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ca5877754f3fa6e4fe5aacf5c404575f04c2d9efc8d22ed39576ed9098d555c8"}, + {file = "pymongo-4.6.2-cp39-cp39-win32.whl", hash = "sha256:8caa73fb19070008e851a589b744aaa38edd1366e2487284c61158c77fdf72af"}, + {file = "pymongo-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:3e03c732cb64b96849310e1d8688fb70d75e2571385485bf2f1e7ad1d309fa53"}, + {file = "pymongo-4.6.2.tar.gz", hash = "sha256:ab7d01ac832a1663dad592ccbd92bb0f0775bc8f98a1923c5e1a7d7fead495af"}, +] + +[package.dependencies] +dnspython = ">=1.16.0,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["certifi", "pymongo[aws]", "pymongocrypt (>=1.6.0,<2.0.0)"] +gssapi = ["pykerberos", "winkerberos (>=0.5.0)"] +ocsp = ["certifi", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +test = ["pytest (>=7)"] +zstd = ["zstandard"] + +[[package]] +name = "pymssql" +version = "2.2.11" +description = "DB-API interface to Microsoft SQL Server for Python. (new Cython-based version)" +optional = true +python-versions = "*" +files = [ + {file = "pymssql-2.2.11-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:692ab328ac290bd2031bc4dd6deae32665dfffda1b12aaa92928d3ebc667d5ad"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:723a4612421027a01b51e42e786678a18c4a27613a3ccecf331c026e0cc41353"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:34ab2373ca607174ad7244cfe955c07b6bc77a1e21d3c3143dbe934dec82c3a4"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bc0ba19b4426c57509f065a03748d9ac230f1543ecdac57175e6ebd213a7bc0"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8d9d42a50f6e8e6b356e4e8b2fa1da725344ec0be6f8a6107b7196e5bd74906"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aec64022a2419fad9f496f8e310522635e39d092970e1d55375ea0be86725174"}, + {file = "pymssql-2.2.11-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c389c8041c94d4058827faf5735df5f8e4c1c1eebdd051859536dc393925a667"}, + {file = "pymssql-2.2.11-cp310-cp310-win32.whl", hash = "sha256:6452326cecd4dcee359a6f8878b827118a8c8523cd24de5b3a971a7a172e4275"}, + {file = "pymssql-2.2.11-cp310-cp310-win_amd64.whl", hash = "sha256:c1bde266dbc91b100abd0311102a6585df09cc963599421cc12fd6b4cfa8e3d3"}, + {file = "pymssql-2.2.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6ddaf0597138179517bdbf5b5aa3caffee65987316dc906359a5d0801d0847ee"}, + {file = "pymssql-2.2.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c26af25991715431559cb5b37f243b8ff676540f504ed0317774dfc71827af1"}, + {file = "pymssql-2.2.11-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:410e8c40b7c1b421e750cf80ccf2da8d802ed815575758ac9a78c5f6cd995723"}, + {file = "pymssql-2.2.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1767239ed45e1fa91d82fc0c63305750530787cd64089cabbe183eb538a35b"}, + {file = "pymssql-2.2.11-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:9a644e4158fed30ae9f3846f2f1c74d36fa1610eb552de35b7f611d063fa3c85"}, + {file = "pymssql-2.2.11-cp311-cp311-win32.whl", hash = "sha256:1956c111debe67f69a9c839b33ce420f0e8def1ef5ff9831c03d8ac840f82376"}, + {file = "pymssql-2.2.11-cp311-cp311-win_amd64.whl", hash = "sha256:0bdd1fb49b0e331e47e83f39d4af784c857e230bfc73519654bab29285c51c63"}, + {file = "pymssql-2.2.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2609bbd3b715822bb4fa6d457b2985d32ad6ab9580fdb61ae6e0eee251791d24"}, + {file = "pymssql-2.2.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c382aea9adaaee189f352d7a493e3f76c13f9337ec2b6aa40e76b114fa13ebac"}, + {file = "pymssql-2.2.11-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5928324a09de7466368c15ece1de4ab5ea968d24943ceade758836f9fc7149f5"}, + {file = "pymssql-2.2.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee8b10f797d0bfec626b803891cf9e98480ee11f2e8459a7616cdb7e4e4bf2de"}, + {file = "pymssql-2.2.11-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1d5aa1a090b17f4ba75ffac3bb371f6c8c869692b653689396f9b470fde06981"}, + {file = "pymssql-2.2.11-cp312-cp312-win32.whl", hash = "sha256:1f7ba71cf81af65c005173f279928bf86700d295f97e4965e169b5764bc6c4f2"}, + {file = "pymssql-2.2.11-cp312-cp312-win_amd64.whl", hash = "sha256:a0ebb0e40c93f8f1e40aad80f512ae4aa89cb1ec8a96964b9afedcff1d5813fd"}, + {file = "pymssql-2.2.11-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0ed115902956efaca9d9a20fa9b2b604e3e11d640416ca74900d215cdcbf3ab"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1a75afa17746972bb61120fb6ea907657fc1ab68250bbbd8b21a00d0720ed0f4"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d2ae69d8e46637a203cfb48e05439fc9e2ff7646fa1f5396aa3577ce52810031"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f13710240457ace5b8c9cca7f4971504656f5703b702895a86386e87c7103801"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7234b0f61dd9ccb2304171b5fd7ed9db133b4ea7c835c9942c9dc5bfc00c1cb"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcd76a8cc757c7cfe2d235f232a20d74ac8cebf9feabcdcbda5ef33157d14b1"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:84aff3235ad1289c4079c548cfcdf7eaaf2475b9f81557351deb42e8f45a9c2d"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b081aa7b02911e3f299f7d1f68ce8ca585a5119d44601bf4483da0aae8c2181"}, + {file = "pymssql-2.2.11-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d315f08c106c884d6b42f70c9518e765a5bc23f6d3a587346bc4e6f198768c7a"}, + {file = "pymssql-2.2.11-cp36-cp36m-win32.whl", hash = "sha256:c8b35b3d5e326729e5edb73d593103d2dbfb474bd36ee95b4e85e1f8271ba98a"}, + {file = "pymssql-2.2.11-cp36-cp36m-win_amd64.whl", hash = "sha256:139c5032e0a2765764987803f1266132fcc5da572848ccc4d29cebba794a4260"}, + {file = "pymssql-2.2.11-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:7bac28aed1d625a002e0289e0c18d1808cecbdc12e2a1a3927dbbaff66e5fff3"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4eeaacc1dbbc678f4e80c6fd6fc279468021fdf2e486adc8631ec0de6b6c0e62"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:428e32e53c554798bc2d0682a169fcb681df6b68544c4aedd1186018ea7e0447"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b621c5e32136dabc2fea25696beab0647ec336d25c04ab6d8eb8c8ee92f0e52"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:658c85474ea01ca3a30de769df06f46681e882524b05c6994cd6fd985c485f27"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:070181361ab94bdaeb14b591a35d853f327bc90c660b04047d474274fbb80357"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:492e49616b58b2d6caf4a2598cb344572870171a7b65ba1ac61a5e248b6a8e1c"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:803122aec31fbd52f5d65ef3b30b3bd2dc7b2a9e3a8223d16078a25805155c45"}, + {file = "pymssql-2.2.11-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:09075e129655ab1178d2d60efb9b3fbf5cdb6da2338ecdb3a92c53a4ad7efa0c"}, + {file = "pymssql-2.2.11-cp37-cp37m-win32.whl", hash = "sha256:b4a8377527702d746c490c2ce67d17f1c351d182b49b82fae6e67ae206bf9663"}, + {file = "pymssql-2.2.11-cp37-cp37m-win_amd64.whl", hash = "sha256:167313d91606dc7a3c05b2ad60491a138b7408a8779599ab6430a48a67f133f0"}, + {file = "pymssql-2.2.11-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:8d418f4dca245421242ed9df59d3bcda0cd081650df6deb1bef7f157b6a6f9dd"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f0c44169df8d23c7ce172bd90ef5deb44caf19f15990e4db266e3193071988a4"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78032e45ea33c55d430b93e55370b900479ea324fae5d5d32486cc0fdc0fedd"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:984d99ee6a2579f86c536b1b0354ad3dc9701e98a4b3953f1301b4695477cd2f"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:287c8f79a7eca0c6787405797bac0f7c502d9be151f3f823aae12042235f8426"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ea4ea296afcae34bc61e4e0ef2f503270fd4bb097b308a07a9194f1f063aa1"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:a114633fa02b7eb5bc63520bf07954106c0ed0ce032449c871abb8b8c435a872"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7332db36a537cbc16640a0c3473a2e419aa5bc1f9953cada3212e7b2587de658"}, + {file = "pymssql-2.2.11-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cd7292d872948c1f67c8cc12158f2c8ed9873d54368139ce1f67b2262ac34029"}, + {file = "pymssql-2.2.11-cp38-cp38-win32.whl", hash = "sha256:fbca115e11685b5891755cc22b3db4348071b8d100a41e1ce93526d9c3dbf2d5"}, + {file = "pymssql-2.2.11-cp38-cp38-win_amd64.whl", hash = "sha256:452b88a4ceca7efb934b5babb365851a3c52e723642092ebc92777397c2cacdb"}, + {file = "pymssql-2.2.11-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:001242cedc73587cbb10aec4069de50febbff3c4c50f9908a215476496b3beab"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:da492482b923b9cc9ad37f0f5592c776279299db2a89c0b7fc931aaefec652d4"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:139a833e6e72a624e4f2cde803a34a616d5661dd9a5b2ae0402d9d8a597b2f1f"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e57fbfad252434d64bdf4b6a935e4241616a4cf8df7af58b9772cd91fce9309a"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5308507c2c4e94ede7e5b164870c1ba2be55abab6daf795b5529e2da4e838b6"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdca43c42d5f370358535b2107140ed550d74f9ef0fc95d2d7fa8c4e40ee48c2"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:fe0cc975aac87b364fdb55cb89642435c3e859dcd99d7260f48af94111ba2673"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4551f50c8a3b6ffbd71f794ee1c0c0134134c5d6414302c2fa28b67fe4470d07"}, + {file = "pymssql-2.2.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ae9818df40588d5a49e7476f05e31cc83dea630d607178d66762ca8cf32e9f77"}, + {file = "pymssql-2.2.11-cp39-cp39-win32.whl", hash = "sha256:15257c7bd89c0283f70d6eaafd9b872201818572b8ba1e8576408ae23ef50c7c"}, + {file = "pymssql-2.2.11-cp39-cp39-win_amd64.whl", hash = "sha256:65bb674c0ba35379bf93d1b2cf06fdc5e7ec56e1d0e9de525bdcf977190b2865"}, + {file = "pymssql-2.2.11.tar.gz", hash = "sha256:15815bf1ff9edb475ec4ef567f23e23c4e828ce119ff5bf98a072b66b8d0ac1b"}, +] + +[[package]] +name = "pymysql" +version = "1.1.0" +description = "Pure Python MySQL Driver" +optional = true +python-versions = ">=3.7" +files = [ + {file = "PyMySQL-1.1.0-py3-none-any.whl", hash = "sha256:8969ec6d763c856f7073c4c64662882675702efcb114b4bcbb955aea3a069fa7"}, + {file = "PyMySQL-1.1.0.tar.gz", hash = "sha256:4f13a7df8bf36a51e81dd9f3605fede45a4878fe02f9236349fd82a3f0612f96"}, +] + +[package.dependencies] +cryptography = {version = "*", optional = true, markers = "extra == \"rsa\""} + +[package.extras] +ed25519 = ["PyNaCl (>=1.4.0)"] +rsa = ["cryptography"] + +[[package]] +name = "pynacl" +version = "1.5.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, + {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, +] + +[package.dependencies] +cffi = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "7.4.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.5" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, + {file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "python-arango" +version = "7.9.1" +description = "Python Driver for ArangoDB" +optional = true +python-versions = ">=3.8" +files = [ + {file = "python-arango-7.9.1.tar.gz", hash = "sha256:18f7d365fb6cf45778fa73b559e3865d0a1c00081de65ef00ba238db52e374ab"}, + {file = "python_arango-7.9.1-py3-none-any.whl", hash = "sha256:23ec7b3aad774db5f99df20f6a1036385c85eb5c9864e47628bc622ea812f2f8"}, +] + +[package.dependencies] +importlib-metadata = ">=4.7.1" +packaging = ">=23.1" +PyJWT = "*" +requests = "*" +requests-toolbelt = "*" +setuptools = ">=42" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["black (>=22.3.0)", "flake8 (>=4.0.1)", "isort (>=5.10.1)", "mock", "mypy (>=0.942)", "pre-commit (>=2.17.0)", "pytest (>=7.1.1)", "pytest-cov (>=3.0.0)", "sphinx", "sphinx-rtd-theme", "types-pkg-resources", "types-requests", "types-setuptools"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-keycloak" +version = "3.9.1" +description = "python-keycloak is a Python package providing access to the Keycloak API." +optional = true +python-versions = ">=3.8,<4.0" +files = [ + {file = "python_keycloak-3.9.1-py3-none-any.whl", hash = "sha256:898d1fc73560171d3870251f981e069f854cc67bc0a51b96703355512d8d3cf3"}, + {file = "python_keycloak-3.9.1.tar.gz", hash = "sha256:50c8073172ca0630f3569c6b631134216b60f4e347cc5bb669a57e6ffba50f7e"}, +] + +[package.dependencies] +deprecation = ">=2.1.0" +jwcrypto = ">=1.5.4,<2.0.0" +requests = ">=2.20.0" +requests-toolbelt = ">=0.6.0" + +[package.extras] +docs = ["Sphinx (>=6.1.0,<7.0.0)", "alabaster (>=0.7.12,<0.8.0)", "commonmark (>=0.9.1,<0.10.0)", "m2r2 (>=0.3.2,<0.4.0)", "mock (>=4.0.3,<5.0.0)", "readthedocs-sphinx-ext (>=2.1.9,<3.0.0)", "recommonmark (>=0.7.1,<0.8.0)", "sphinx-autoapi (>=3.0.0,<4.0.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)"] + +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.2" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, + {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "qdrant-client" +version = "1.8.2" +description = "Client library for the Qdrant vector search engine" +optional = true +python-versions = ">=3.8" +files = [ + {file = "qdrant_client-1.8.2-py3-none-any.whl", hash = "sha256:ee5341c0486d09e4346b0f5ef7781436e6d8cdbf1d5ecddfde7adb3647d353a8"}, + {file = "qdrant_client-1.8.2.tar.gz", hash = "sha256:65078d5328bc0393f42a46a31cd319a989b8285bf3958360acf1dffffdf4cc4e"}, +] + +[package.dependencies] +grpcio = ">=1.41.0" +grpcio-tools = ">=1.41.0" +httpx = {version = ">=0.20.0", extras = ["http2"]} +numpy = [ + {version = ">=1.21", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, + {version = ">=1.26", markers = "python_version >= \"3.12\""}, +] +portalocker = ">=2.7.0,<3.0.0" +pydantic = ">=1.10.8" +urllib3 = ">=1.26.14,<3" + +[package.extras] +fastembed = ["fastembed (==0.2.5)"] + +[[package]] +name = "reactivex" +version = "4.0.4" +description = "ReactiveX (Rx) for Python" +optional = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "reactivex-4.0.4-py3-none-any.whl", hash = "sha256:0004796c420bd9e68aad8e65627d85a8e13f293de76656165dffbcb3a0e3fb6a"}, + {file = "reactivex-4.0.4.tar.gz", hash = "sha256:e912e6591022ab9176df8348a653fe8c8fa7a301f26f9931c9d8c78a650e04e8"}, +] + +[package.dependencies] +typing-extensions = ">=4.1.1,<5.0.0" + +[[package]] +name = "readme-renderer" +version = "43.0" +description = "readme_renderer is a library for rendering readme descriptions for Warehouse" +optional = false +python-versions = ">=3.8" +files = [ + {file = "readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9"}, + {file = "readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311"}, +] + +[package.dependencies] +docutils = ">=0.13.1" +nh3 = ">=0.2.14" +Pygments = ">=2.5.1" + +[package.extras] +md = ["cmarkgfm (>=0.8.0)"] + +[[package]] +name = "redis" +version = "5.0.3" +description = "Python client for Redis database and key-value store" +optional = true +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = true +python-versions = ">=3.4" +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rfc3986" +version = "2.0.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = ">=3.7" +files = [ + {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, + {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, +] + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = true +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "s3transfer" +version = "0.10.0" +description = "An Amazon S3 Transfer Manager" +optional = true +python-versions = ">= 3.8" +files = [ + {file = "s3transfer-0.10.0-py3-none-any.whl", hash = "sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e"}, + {file = "s3transfer-0.10.0.tar.gz", hash = "sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b"}, +] + +[package.dependencies] +botocore = ">=1.33.2,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] + +[[package]] +name = "scramp" +version = "1.4.4" +description = "An implementation of the SCRAM protocol." +optional = false +python-versions = ">=3.7" +files = [ + {file = "scramp-1.4.4-py3-none-any.whl", hash = "sha256:b142312df7c2977241d951318b7ee923d6b7a4f75ba0f05b621ece1ed616faa3"}, + {file = "scramp-1.4.4.tar.gz", hash = "sha256:b7022a140040f33cf863ab2657917ed05287a807b917950489b89b9f685d59bc"}, +] + +[package.dependencies] +asn1crypto = ">=1.5.1" + +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, + {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "selenium" +version = "4.18.1" +description = "" +optional = true +python-versions = ">=3.8" +files = [ + {file = "selenium-4.18.1-py3-none-any.whl", hash = "sha256:b24a3cdd2d47c29832e81345bfcde0c12bb608738013e53c781b211b418df241"}, + {file = "selenium-4.18.1.tar.gz", hash = "sha256:a11f67afa8bfac6b77e148c987b33f6b14eb1cae4d352722a75de1f26e3f0ae2"}, +] + +[package.dependencies] +certifi = ">=2021.10.8" +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +typing_extensions = ">=4.9.0" +urllib3 = {version = ">=1.26,<3", extras = ["socks"]} + +[[package]] +name = "setuptools" +version = "69.1.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = true +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "sphinx" +version = "7.2.6" +description = "Python documentation generator" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"}, + {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"}, +] + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.18.1,<0.21" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.14" +requests = ">=2.25.0" +snowballstemmer = ">=2.0" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.9" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] +test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.8" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, + {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.6" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, + {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.5" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, + {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.7" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, + {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.10" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, + {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sqlalchemy" +version = "2.0.28" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0b148ab0438f72ad21cb004ce3bdaafd28465c4276af66df3b9ecd2037bf252"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bbda76961eb8f27e6ad3c84d1dc56d5bc61ba8f02bd20fcf3450bd421c2fcc9c"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feea693c452d85ea0015ebe3bb9cd15b6f49acc1a31c28b3c50f4db0f8fb1e71"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da98815f82dce0cb31fd1e873a0cb30934971d15b74e0d78cf21f9e1b05953f"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a5adf383c73f2d49ad15ff363a8748319ff84c371eed59ffd0127355d6ea1da"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56856b871146bfead25fbcaed098269d90b744eea5cb32a952df00d542cdd368"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-win32.whl", hash = "sha256:943aa74a11f5806ab68278284a4ddd282d3fb348a0e96db9b42cb81bf731acdc"}, + {file = "SQLAlchemy-2.0.28-cp310-cp310-win_amd64.whl", hash = "sha256:c6c4da4843e0dabde41b8f2e8147438330924114f541949e6318358a56d1875a"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46a3d4e7a472bfff2d28db838669fc437964e8af8df8ee1e4548e92710929adc"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3dd67b5d69794cfe82862c002512683b3db038b99002171f624712fa71aeaa"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61e2e41656a673b777e2f0cbbe545323dbe0d32312f590b1bc09da1de6c2a02"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0315d9125a38026227f559488fe7f7cee1bd2fbc19f9fd637739dc50bb6380b2"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:af8ce2d31679006e7b747d30a89cd3ac1ec304c3d4c20973f0f4ad58e2d1c4c9"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:81ba314a08c7ab701e621b7ad079c0c933c58cdef88593c59b90b996e8b58fa5"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-win32.whl", hash = "sha256:1ee8bd6d68578e517943f5ebff3afbd93fc65f7ef8f23becab9fa8fb315afb1d"}, + {file = "SQLAlchemy-2.0.28-cp311-cp311-win_amd64.whl", hash = "sha256:ad7acbe95bac70e4e687a4dc9ae3f7a2f467aa6597049eeb6d4a662ecd990bb6"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d3499008ddec83127ab286c6f6ec82a34f39c9817f020f75eca96155f9765097"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b66fcd38659cab5d29e8de5409cdf91e9986817703e1078b2fdaad731ea66f5"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea30da1e76cb1acc5b72e204a920a3a7678d9d52f688f087dc08e54e2754c67"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:124202b4e0edea7f08a4db8c81cc7859012f90a0d14ba2bf07c099aff6e96462"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e23b88c69497a6322b5796c0781400692eca1ae5532821b39ce81a48c395aae9"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b6303bfd78fb3221847723104d152e5972c22367ff66edf09120fcde5ddc2e2"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-win32.whl", hash = "sha256:a921002be69ac3ab2cf0c3017c4e6a3377f800f1fca7f254c13b5f1a2f10022c"}, + {file = "SQLAlchemy-2.0.28-cp312-cp312-win_amd64.whl", hash = "sha256:b4a2cf92995635b64876dc141af0ef089c6eea7e05898d8d8865e71a326c0385"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e91b5e341f8c7f1e5020db8e5602f3ed045a29f8e27f7f565e0bdee3338f2c7"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45c7b78dfc7278329f27be02c44abc0d69fe235495bb8e16ec7ef1b1a17952db"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eba73ef2c30695cb7eabcdb33bb3d0b878595737479e152468f3ba97a9c22a4"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5df5d1dafb8eee89384fb7a1f79128118bc0ba50ce0db27a40750f6f91aa99d5"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2858bbab1681ee5406650202950dc8f00e83b06a198741b7c656e63818633526"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-win32.whl", hash = "sha256:9461802f2e965de5cff80c5a13bc945abea7edaa1d29360b485c3d2b56cdb075"}, + {file = "SQLAlchemy-2.0.28-cp37-cp37m-win_amd64.whl", hash = "sha256:a6bec1c010a6d65b3ed88c863d56b9ea5eeefdf62b5e39cafd08c65f5ce5198b"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:843a882cadebecc655a68bd9a5b8aa39b3c52f4a9a5572a3036fb1bb2ccdc197"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dbb990612c36163c6072723523d2be7c3eb1517bbdd63fe50449f56afafd1133"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7e4baf9161d076b9a7e432fce06217b9bd90cfb8f1d543d6e8c4595627edb9"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a5354cb4de9b64bccb6ea33162cb83e03dbefa0d892db88a672f5aad638a75"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fffcc8edc508801ed2e6a4e7b0d150a62196fd28b4e16ab9f65192e8186102b6"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aca7b6d99a4541b2ebab4494f6c8c2f947e0df4ac859ced575238e1d6ca5716b"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-win32.whl", hash = "sha256:8c7f10720fc34d14abad5b647bc8202202f4948498927d9f1b4df0fb1cf391b7"}, + {file = "SQLAlchemy-2.0.28-cp38-cp38-win_amd64.whl", hash = "sha256:243feb6882b06a2af68ecf4bec8813d99452a1b62ba2be917ce6283852cf701b"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc4974d3684f28b61b9a90fcb4c41fb340fd4b6a50c04365704a4da5a9603b05"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87724e7ed2a936fdda2c05dbd99d395c91ea3c96f029a033a4a20e008dd876bf"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68722e6a550f5de2e3cfe9da6afb9a7dd15ef7032afa5651b0f0c6b3adb8815d"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328529f7c7f90adcd65aed06a161851f83f475c2f664a898af574893f55d9e53"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:df40c16a7e8be7413b885c9bf900d402918cc848be08a59b022478804ea076b8"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:426f2fa71331a64f5132369ede5171c52fd1df1bd9727ce621f38b5b24f48750"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-win32.whl", hash = "sha256:33157920b233bc542ce497a81a2e1452e685a11834c5763933b440fedd1d8e2d"}, + {file = "SQLAlchemy-2.0.28-cp39-cp39-win_amd64.whl", hash = "sha256:2f60843068e432311c886c5f03c4664acaef507cf716f6c60d5fde7265be9d7b"}, + {file = "SQLAlchemy-2.0.28-py3-none-any.whl", hash = "sha256:78bb7e8da0183a8301352d569900d9d3594c48ac21dc1c2ec6b3121ed8b6c986"}, + {file = "SQLAlchemy-2.0.28.tar.gz", hash = "sha256:dd53b6c4e6d960600fd6532b79ee28e2da489322fcf6648738134587faf767b6"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "sqlalchemy-cockroachdb" +version = "2.0.2" +description = "CockroachDB dialect for SQLAlchemy" +optional = false +python-versions = "*" +files = [ + {file = "sqlalchemy-cockroachdb-2.0.2.tar.gz", hash = "sha256:119756eb905855d6a11345b99cfe853031a3fe598a9c4bf35a8ddac9f89fe8cc"}, + {file = "sqlalchemy_cockroachdb-2.0.2-py3-none-any.whl", hash = "sha256:0d5d50e805b024cb2ccd85423a5c1a367d1a56a5cd0ea47765233fd47665070d"}, +] + +[package.dependencies] +SQLAlchemy = "*" + +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +optional = true +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "trino" +version = "0.329.0" +description = "Client for the Trino distributed SQL Engine" +optional = true +python-versions = ">=3.8" +files = [ + {file = "trino-0.329.0-py3-none-any.whl", hash = "sha256:74b82a38f16193ad869e63fb837d651e66c044f19a817232787e27c5d44b671f"}, + {file = "trino-0.329.0.tar.gz", hash = "sha256:1d976467726ec3d0fa120a64e61fdb8caf13295db207051e2cc267a952af989b"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = "*" +requests = ">=2.31.0" +tzlocal = "*" + +[package.extras] +all = ["requests-kerberos", "sqlalchemy (>=1.3)"] +external-authentication-token-cache = ["keyring"] +gssapi = ["requests-gssapi"] +kerberos = ["requests-kerberos"] +sqlalchemy = ["sqlalchemy (>=1.3)"] +tests = ["black", "httpretty (<1.1)", "isort", "pre-commit", "pytest", "pytest-runner", "requests-gssapi", "requests-kerberos", "sqlalchemy (>=1.3)"] + +[[package]] +name = "trio" +version = "0.24.0" +description = "A friendly Python library for async concurrency and I/O" +optional = true +python-versions = ">=3.8" +files = [ + {file = "trio-0.24.0-py3-none-any.whl", hash = "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c"}, + {file = "trio-0.24.0.tar.gz", hash = "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d"}, +] + +[package.dependencies] +attrs = ">=20.1.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.11.1" +description = "WebSocket library for Trio" +optional = true +python-versions = ">=3.7" +files = [ + {file = "trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f"}, + {file = "trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638"}, +] + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "twine" +version = "4.0.2" +description = "Collection of utilities for publishing packages on PyPI" +optional = false +python-versions = ">=3.7" +files = [ + {file = "twine-4.0.2-py3-none-any.whl", hash = "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8"}, + {file = "twine-4.0.2.tar.gz", hash = "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8"}, +] + +[package.dependencies] +importlib-metadata = ">=3.6" +keyring = ">=15.1" +pkginfo = ">=1.8.1" +readme-renderer = ">=35.0" +requests = ">=2.20" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +rfc3986 = ">=1.4.0" +rich = ">=12.0.0" +urllib3 = ">=1.26.0" + +[[package]] +name = "types-paramiko" +version = "3.4.0.20240423" +description = "Typing stubs for paramiko" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-paramiko-3.4.0.20240423.tar.gz", hash = "sha256:aaa98dda232c47886563d66743d3a8b66c432790c596bc3bdd3f17f91be2a8c1"}, + {file = "types_paramiko-3.4.0.20240423-py3-none-any.whl", hash = "sha256:c56e0d43399a1b909901b1e0375e0ff6ee62e16cd6e00695024abc2e9fe02035"}, +] + +[package.dependencies] +cryptography = ">=37.0.0" + +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "tzlocal" +version = "5.2" +description = "tzinfo object for the local timezone" +optional = true +python-versions = ">=3.8" +files = [ + {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, + {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "ujson" +version = "5.10.0" +description = "Ultra fast JSON encoder and decoder for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, + {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, + {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, + {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, + {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, + {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, + {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, + {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, + {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, + {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, + {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, + {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, + {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, + {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, + {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, +] + +[[package]] +name = "urllib3" +version = "1.26.18" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, + {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, +] + +[package.dependencies] +PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "urllib3" +version = "2.0.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, + {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, +] + +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "validators" +version = "0.22.0" +description = "Python Data Validation for Humans™" +optional = true +python-versions = ">=3.8" +files = [ + {file = "validators-0.22.0-py3-none-any.whl", hash = "sha256:61cf7d4a62bbae559f2e54aed3b000cea9ff3e2fdbe463f51179b92c58c9585a"}, + {file = "validators-0.22.0.tar.gz", hash = "sha256:77b2689b172eeeb600d9605ab86194641670cdb73b60afd577142a9397873370"}, +] + +[package.extras] +docs-offline = ["myst-parser (>=2.0.0)", "pypandoc-binary (>=1.11)", "sphinx (>=7.1.1)"] +docs-online = ["mkdocs (>=1.5.2)", "mkdocs-git-revision-date-localized-plugin (>=1.2.0)", "mkdocs-material (>=9.2.6)", "mkdocstrings[python] (>=0.22.0)", "pyaml (>=23.7.0)"] +hooks = ["pre-commit (>=3.3.3)"] +package = ["build (>=1.0.0)", "twine (>=4.0.2)"] +runner = ["tox (>=4.11.1)"] +sast = ["bandit[toml] (>=1.7.5)"] +testing = ["pytest (>=7.4.0)"] +tooling = ["black (>=23.7.0)", "pyright (>=1.1.325)", "ruff (>=0.0.287)"] +tooling-extras = ["pyaml (>=23.7.0)", "pypandoc-binary (>=1.11)", "pytest (>=7.4.0)"] + +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "weaviate-client" +version = "4.5.4" +description = "A python native Weaviate client" +optional = true +python-versions = ">=3.8" +files = [ + {file = "weaviate-client-4.5.4.tar.gz", hash = "sha256:fc53dc73cd53df453c5e6dc758e49a6a1549212d6670ddd013392107120692f8"}, + {file = "weaviate_client-4.5.4-py3-none-any.whl", hash = "sha256:f6d3a6b759e5aa0d3350067490526ea38b9274ae4043b4a3ae0064c28d56883f"}, +] + +[package.dependencies] +authlib = ">=1.2.1,<2.0.0" +grpcio = ">=1.57.0,<2.0.0" +grpcio-health-checking = ">=1.57.0,<2.0.0" +grpcio-tools = ">=1.57.0,<2.0.0" +httpx = "0.27.0" +pydantic = ">=2.5.0,<3.0.0" +requests = ">=2.30.0,<3.0.0" +validators = "0.22.0" + +[[package]] +name = "websocket-client" +version = "1.7.0" +description = "WebSocket client for Python with low level API options" +optional = true +python-versions = ">=3.8" +files = [ + {file = "websocket-client-1.7.0.tar.gz", hash = "sha256:10e511ea3a8c744631d3bd77e61eb17ed09304c413ad42cf6ddfa4c7787e8fe6"}, + {file = "websocket_client-1.7.0-py3-none-any.whl", hash = "sha256:f4c3d22fec12a2461427a29957ff07d35098ee2d976d3ba244e688b8b4057588"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = true +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[extras] +arangodb = ["python-arango"] +aws = ["boto3", "httpx"] +azurite = ["azure-storage-blob"] +cassandra = [] +chroma = ["chromadb-client"] +clickhouse = ["clickhouse-driver"] +cockroachdb = [] +cosmosdb = ["azure-cosmos"] +db2 = ["ibm_db_sa", "sqlalchemy"] +elasticsearch = [] +generic = ["httpx"] +google = ["google-cloud-datastore", "google-cloud-pubsub"] +influxdb = ["influxdb", "influxdb-client"] +k3s = ["kubernetes", "pyyaml"] +kafka = [] +keycloak = ["python-keycloak"] +localstack = ["boto3"] +mailpit = ["cryptography"] +memcached = [] +milvus = [] +minio = ["minio"] +mongodb = ["pymongo"] +mqtt = [] +mssql = ["pymssql", "sqlalchemy"] +mysql = ["pymysql", "sqlalchemy"] +nats = ["nats-py"] +neo4j = ["neo4j"] +nginx = [] +ollama = [] +opensearch = ["opensearch-py"] +oracle = ["oracledb", "sqlalchemy"] +oracle-free = ["oracledb", "sqlalchemy"] +postgres = [] +qdrant = ["qdrant-client"] +rabbitmq = ["pika"] +redis = ["redis"] +registry = ["bcrypt"] +scylla = ["cassandra-driver"] +selenium = ["selenium"] +sftp = ["cryptography"] +test-module-import = ["httpx"] +trino = ["trino"] +vault = [] +weaviate = ["weaviate-client"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.9,<4.0" +content-hash = "69d30cc8cd59a8aa0d019c42b1f171e449dabf6959828160d11f3084c5a03f7f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..957a5041e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,342 @@ +[tool.poetry] +name = "testcontainers" +version = "4.7.2" # auto-incremented by release-please +description = "Python library for throwaway instances of anything that can run in a Docker container" +authors = ["Sergey Pirogov "] +maintainers = [ + "Balint Bartha ", + "David Ankin ", + "Vemund Santi " +] +readme = "README.md" +keywords = ["testing", "logging", "docker", "test automation"] +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Intended Audience :: Information Technology", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] +# testcontainers-core is a proper package dependency - only modules needed here +packages = [ + { include = "testcontainers", from = "core" }, + { include = "testcontainers", from = "modules/arangodb" }, + { include = "testcontainers", from = "modules/aws"}, + { include = "testcontainers", from = "modules/azurite" }, + { include = "testcontainers", from = "modules/cassandra" }, + { include = "testcontainers", from = "modules/chroma" }, + { include = "testcontainers", from = "modules/clickhouse" }, + { include = "testcontainers", from = "modules/cockroachdb" }, + { include = "testcontainers", from = "modules/cosmosdb" }, + { include = "testcontainers", from = "modules/db2" }, + { include = "testcontainers", from = "modules/elasticsearch" }, + { include = "testcontainers", from = "modules/generic" }, + { include = "testcontainers", from = "modules/test_module_import"}, + { include = "testcontainers", from = "modules/google" }, + { include = "testcontainers", from = "modules/influxdb" }, + { include = "testcontainers", from = "modules/k3s" }, + { include = "testcontainers", from = "modules/kafka" }, + { include = "testcontainers", from = "modules/keycloak" }, + { include = "testcontainers", from = "modules/localstack" }, + { include = "testcontainers", from = "modules/mailpit" }, + { include = "testcontainers", from = "modules/memcached" }, + { include = "testcontainers", from = "modules/minio" }, + { include = "testcontainers", from = "modules/milvus" }, + { include = "testcontainers", from = "modules/mongodb" }, + { include = "testcontainers", from = "modules/mqtt" }, + { include = "testcontainers", from = "modules/mssql" }, + { include = "testcontainers", from = "modules/mysql" }, + { include = "testcontainers", from = "modules/nats" }, + { include = "testcontainers", from = "modules/neo4j" }, + { include = "testcontainers", from = "modules/nginx" }, + { include = "testcontainers", from = "modules/ollama" }, + { include = "testcontainers", from = "modules/opensearch" }, + { include = "testcontainers", from = "modules/oracle-free" }, + { include = "testcontainers", from = "modules/postgres" }, + { include = "testcontainers", from = "modules/qdrant" }, + { include = "testcontainers", from = "modules/rabbitmq" }, + { include = "testcontainers", from = "modules/redis" }, + { include = "testcontainers", from = "modules/registry" }, + { include = "testcontainers", from = "modules/sftp" }, + { include = "testcontainers", from = "modules/selenium" }, + { include = "testcontainers", from = "modules/scylla" }, + { include = "testcontainers", from = "modules/trino" }, + { include = "testcontainers", from = "modules/vault" }, + { include = "testcontainers", from = "modules/weaviate" }, +] + +[tool.poetry.urls] +"GitHub" = "https://github.com/testcontainers/testcontainers-python" +"Issue Tracker" = "https://github.com/testcontainers/testcontainers-python/issues" + +[tool.poetry.dependencies] +python = ">=3.9,<4.0" +docker = "*" # ">=4.0" +urllib3 = "*" # "<2.0" +wrapt = "*" # "^1.16.0" +typing-extensions = "*" + +# community modules +python-arango = { version = "^7.8", optional = true } +azure-storage-blob = { version = "^12.19", optional = true } +cassandra-driver = { version = "3.29.1", optional = true } +clickhouse-driver = { version = "*", optional = true } +google-cloud-pubsub = { version = ">=2", optional = true } +google-cloud-datastore = { version = ">=2", optional = true } +influxdb = { version = "*", optional = true } +influxdb-client = { version = "*", optional = true } +kubernetes = { version = "*", optional = true } +pyyaml = { version = "*", optional = true } +python-keycloak = { version = "*", optional = true } +boto3 = { version = "*", optional = true } +minio = { version = "*", optional = true } +nats-py = { version = "*", optional = true } +pymongo = { version = "*", optional = true } +sqlalchemy = { version = "*", optional = true } +pymssql = { version = "*", optional = true } +pymysql = { version = "*", extras = ["rsa"], optional = true } +neo4j = { version = "*", optional = true } +opensearch-py = { version = "*", optional = true } +oracledb = { version = "*", optional = true } +pika = { version = "*", optional = true } +redis = { version = "*", optional = true } +selenium = { version = "*", optional = true } +weaviate-client = { version = "^4.5.4", optional = true } +chromadb-client = { version = "*", optional = true } +qdrant-client = { version = "*", optional = true } +bcrypt = { version = "*", optional = true } +httpx = { version = "*", optional = true } +azure-cosmos = { version = "*", optional = true } +cryptography = { version = "*", optional = true } +trino = { version = "*", optional = true } +ibm_db_sa = { version = "*", optional = true } + +[tool.poetry.extras] +arangodb = ["python-arango"] +aws = ["boto3", "httpx"] +azurite = ["azure-storage-blob"] +cassandra = [] +clickhouse = ["clickhouse-driver"] +cosmosdb = ["azure-cosmos"] +cockroachdb = [] +db2 = ["sqlalchemy", "ibm_db_sa"] +elasticsearch = [] +generic = ["httpx"] +test_module_import = ["httpx"] +google = ["google-cloud-pubsub", "google-cloud-datastore"] +influxdb = ["influxdb", "influxdb-client"] +k3s = ["kubernetes", "pyyaml"] +kafka = [] +keycloak = ["python-keycloak"] +localstack = ["boto3"] +mailpit = ["cryptography"] +memcached = [] +minio = ["minio"] +milvus = [] +mongodb = ["pymongo"] +mqtt = [] +mssql = ["sqlalchemy", "pymssql"] +mysql = ["sqlalchemy", "pymysql"] +nats = ["nats-py"] +neo4j = ["neo4j"] +nginx = [] +opensearch = ["opensearch-py"] +ollama = [] +oracle = ["sqlalchemy", "oracledb"] +oracle-free = ["sqlalchemy", "oracledb"] +postgres = [] +qdrant = ["qdrant-client"] +rabbitmq = ["pika"] +redis = ["redis"] +registry = ["bcrypt"] +selenium = ["selenium"] +scylla = ["cassandra-driver"] +sftp = ["cryptography"] +vault = [] +weaviate = ["weaviate-client"] +chroma = ["chromadb-client"] +trino = ["trino"] + +[tool.poetry.group.dev.dependencies] +mypy = "1.7.1" +pre-commit = "^3.6" +pytest = "7.4.3" +pytest-cov = "4.1.0" +sphinx = "7.2.6" +twine = "4.0.2" +anyio = "4.3.0" +# for tests only +psycopg2-binary = "2.9.9" +pg8000 = "1.30.5" +sqlalchemy = "2.0.28" +psycopg = "3.1.18" +cassandra-driver = "3.29.1" +pytest-asyncio = "0.23.5" +kafka-python-ng = "^2.2.0" +hvac = "2.1.0" +pymilvus = "2.4.3" +httpx = "0.27.0" +paho-mqtt = "2.1.0" +sqlalchemy-cockroachdb = "2.0.2" +paramiko = "^3.4.0" +types-paramiko = "^3.4.0.20240423" +pytest-mock = "^3.14.0" + +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" + +[tool.black] +line-length = 120 + +[tool.pytest.ini_options] +addopts = "--tb=short --strict-markers" +log_cli = true +log_cli_level = "INFO" + +[tool.coverage.run] +branch = true +omit = [ + "oracle.py" +] + +[tool.coverage.report] +exclude_lines = [ + "pass", + "raise NotImplementedError" # TODO: used in core/generic.py, not sure we need DbContainer +] + +[tool.ruff] +target-version = "py39" +line-length = 120 +fix = true +src = ["core", "modules/*"] + +[tool.ruff.lint] +fixable = ["I"] +exclude = ["**/tests/**/*.py"] +select = [ + # flake8-2020 + "YTT", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-comprehensions + "C4", + # flake8-debugger + "T10", + # flake8-print + "T20", + # flake8-pytest-style + "PT", + # flake8-simplify + "SIM", + # flake8-tidy-imports + "TID", + # flake8-type-checking + "TCH", + # isort + "I", + # mccabe + "C90", + # pycodestyle + "E", "W", + # pyflakes + "F", + # pygrep-hooks + "PGH", + # pyupgrade + "UP", + # ruff + "RUF", + # TODO: security, enable via line below + # "S", +] +ignore = [ + # line too long (already checked by black) + "E501", + # the must-have __init__.py (we are using package namespaces) + "INP001" +] + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.flake8-type-checking] +strict = true + +[tool.mypy] +python_version = "3.9" +namespace_packages = true +explicit_package_bases = true +pretty = true +show_error_codes = true +strict = true +fast_module_lookup = true +modules = ["testcontainers.core"] +mypy_path = [ + "core", +# "modules/arangodb", +# "modules/azurite", +# "modules/cassandra", +# "modules/clickhouse", +# "modules/elasticsearch", +# "modules/google", +# "modules/k3s", +# "modules/kafka", +# "modules/keycloak", +# "modules/localstack", + "modules/mailpit", +# "modules/minio", +# "modules/mongodb", +# "modules/mssql", +# "modules/mysql", +# "modules/neo4j", +# "modules/nginx", +# "modules/ollama", +# "modules/opensearch", +# "modules/oracle", +# "modules/postgres", +# "modules/rabbitmq", +# "modules/redis", +# "modules/selenium" + "modules/sftp", +# "modules/vault" +# "modules/weaviate" +] +enable_error_code = [ + "ignore-without-code", + "redundant-expr", + "truthy-bool", +] + +[[tool.mypy.overrides]] +module = ['tests.*'] +# in pytest we allow fixtures to be more relaxed, though we check the untyped functions +check_untyped_defs = true +disable_error_code = [ + 'no-untyped-def' +] + +[[tool.mypy.overrides]] +module = ['docker.*'] +# docker still doesn't have type annotations (not even 7.0) +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ['wrapt.*'] +# wrapt doesn't have type annotations +ignore_missing_imports = true + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/release-process.rst b/release-process.rst deleted file mode 100644 index 8cfa7c6d0..000000000 --- a/release-process.rst +++ /dev/null @@ -1,69 +0,0 @@ -Release process -=============== - -Run tests on target branch --------------------------- - -Steps:: - - tox -epep8 - tox -esphinx-docs - - -Decleare package version ------------------------- - -In setup.py bump version to the next:: - - version='X.X.X' to version='X.X.Y' - -Cut off stable branch ---------------------- - -Steps:: - - git checkout -b vX.X.X-stable - git push origin vX.X.X-stable - - -Create GitHub tag ------------------ - -Steps:: - - Releases ---> Draft New Release - Name: python-testrelease version X.X.X stable release - - -Collect changes from previous version -------------------------------------- - -Steps:: - - git log --oneline --decorate - - -Build distribution package --------------------------- - -Steps:: - - python setup.py bdist_wheel - - -Check install capability for the whell --------------------------------------- - -Steps:: - - virtualenv .test_venv - source .test_venv/bin/activate - pip install dist/testcontainer-X.X.X-py2.py3-none-any.whl - - -Submit release to PYPI ----------------------- - -Steps:: - - twine dist/* upload diff --git a/requirements.in b/requirements.in deleted file mode 100644 index d6f22ece5..000000000 --- a/requirements.in +++ /dev/null @@ -1,8 +0,0 @@ --e file:.[docker-compose,mysql,oracle,postgresql,selenium,google-cloud-pubsub,mongo,redis,mssqlserver,neo4j,kafka,rabbitmq,clickhouse,keycloak] -codecov>=2.1.0 -cryptography<37 -flake8<3.8.0 # 3.8.0 adds a dependency on importlib-metadata which conflicts with other packages. -pg8000 -pytest -pytest-cov -sphinx diff --git a/requirements/3.10.txt b/requirements/3.10.txt deleted file mode 100644 index 5ae26cd97..000000000 --- a/requirements/3.10.txt +++ /dev/null @@ -1,286 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: -# -# pip-compile --output-file=requirements/3.10.txt requirements.in -# --e file:. - # via -r requirements.in -alabaster==0.7.12 - # via sphinx -asn1crypto==1.5.1 - # via scramp -async-generator==1.10 - # via - # trio - # trio-websocket -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via - # jsonschema - # outcome - # pytest - # trio -babel==2.10.1 - # via sphinx -bcrypt==3.2.2 - # via paramiko -cachetools==5.0.0 - # via google-auth -certifi==2021.10.8 - # via - # requests - # urllib3 -cffi==1.15.0 - # via - # bcrypt - # cryptography - # pynacl -charset-normalizer==2.0.12 - # via requests -clickhouse-driver==0.2.3 - # via testcontainers -codecov==2.1.12 - # via -r requirements.in -coverage[toml]==6.3.2 - # via - # codecov - # pytest-cov -cryptography==36.0.2 - # via - # -r requirements.in - # paramiko - # pyopenssl - # urllib3 -cx-oracle==8.3.0 - # via testcontainers -deprecated==1.2.13 - # via redis -deprecation==2.1.0 - # via testcontainers -distro==1.7.0 - # via docker-compose -docker[ssh]==5.0.3 - # via - # docker-compose - # testcontainers -docker-compose==1.29.2 - # via testcontainers -dockerpty==0.4.1 - # via docker-compose -docopt==0.6.2 - # via docker-compose -docutils==0.17.1 - # via sphinx -ecdsa==0.17.0 - # via python-jose -entrypoints==0.3 - # via flake8 -flake8==3.7.9 - # via -r requirements.in -google-api-core[grpc]==2.7.3 - # via google-cloud-pubsub -google-auth==2.6.6 - # via google-api-core -google-cloud-pubsub==1.7.1 - # via testcontainers -googleapis-common-protos[grpc]==1.56.0 - # via - # google-api-core - # grpc-google-iam-v1 - # grpcio-status -greenlet==1.1.2 - # via sqlalchemy -grpc-google-iam-v1==0.12.4 - # via google-cloud-pubsub -grpcio==1.46.0 - # via - # google-api-core - # googleapis-common-protos - # grpc-google-iam-v1 - # grpcio-status -grpcio-status==1.46.0 - # via google-api-core -h11==0.13.0 - # via wsproto -idna==3.3 - # via - # requests - # trio - # urllib3 -imagesize==1.3.0 - # via sphinx -iniconfig==1.1.1 - # via pytest -jinja2==3.1.2 - # via sphinx -jsonschema==3.2.0 - # via docker-compose -kafka-python==2.0.2 - # via testcontainers -markupsafe==2.1.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -neo4j==4.4.3 - # via testcontainers -outcome==1.1.0 - # via trio -packaging==21.3 - # via - # deprecation - # pytest - # redis - # sphinx -paramiko==2.10.4 - # via docker -pg8000==1.26.1 - # via -r requirements.in -pika==1.2.1 - # via testcontainers -pluggy==1.0.0 - # via pytest -protobuf==3.20.1 - # via - # google-api-core - # googleapis-common-protos - # grpcio-status -psycopg2-binary==2.9.3 - # via testcontainers -py==1.11.0 - # via pytest -pyasn1==0.4.8 - # via - # pyasn1-modules - # python-jose - # rsa -pyasn1-modules==0.2.8 - # via google-auth -pycodestyle==2.5.0 - # via flake8 -pycparser==2.21 - # via cffi -pyflakes==2.1.1 - # via flake8 -pygments==2.12.0 - # via sphinx -pymongo==4.1.1 - # via testcontainers -pymssql==2.2.5 - # via testcontainers -pymysql==1.0.2 - # via testcontainers -pynacl==1.5.0 - # via paramiko -pyopenssl==22.0.0 - # via urllib3 -pyparsing==3.0.8 - # via packaging -pyrsistent==0.18.1 - # via jsonschema -pysocks==1.7.1 - # via urllib3 -pytest==7.1.2 - # via - # -r requirements.in - # pytest-cov -pytest-cov==3.0.0 - # via -r requirements.in -python-dotenv==0.20.0 - # via docker-compose -python-jose==3.3.0 - # via python-keycloak -python-keycloak==0.27.0 - # via testcontainers -pytz==2022.1 - # via - # babel - # clickhouse-driver - # neo4j -pytz-deprecation-shim==0.1.0.post0 - # via tzlocal -pyyaml==5.4.1 - # via docker-compose -redis==4.3.0 - # via testcontainers -requests==2.27.1 - # via - # codecov - # docker - # docker-compose - # google-api-core - # python-keycloak - # sphinx -rsa==4.8 - # via - # google-auth - # python-jose -scramp==1.4.1 - # via pg8000 -selenium==4.1.5 - # via testcontainers -six==1.16.0 - # via - # dockerpty - # ecdsa - # google-auth - # grpcio - # jsonschema - # paramiko - # websocket-client -sniffio==1.2.0 - # via trio -snowballstemmer==2.2.0 - # via sphinx -sortedcontainers==2.4.0 - # via trio -sphinx==4.5.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlalchemy==1.4.36 - # via testcontainers -texttable==1.6.4 - # via docker-compose -tomli==2.0.1 - # via - # coverage - # pytest -trio==0.20.0 - # via - # selenium - # trio-websocket -trio-websocket==0.9.2 - # via selenium -tzdata==2022.1 - # via pytz-deprecation-shim -tzlocal==4.2 - # via clickhouse-driver -urllib3[secure,socks]==1.26.9 - # via - # requests - # selenium -websocket-client==0.59.0 - # via - # docker - # docker-compose -wrapt==1.14.1 - # via - # deprecated - # testcontainers -wsproto==1.1.0 - # via trio-websocket - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/3.6.txt b/requirements/3.6.txt deleted file mode 100644 index 0acd6776f..000000000 --- a/requirements/3.6.txt +++ /dev/null @@ -1,278 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.6 -# To update, run: -# -# pip-compile --output-file=requirements/3.6.txt requirements.in -# --e file:. - # via -r requirements.in -alabaster==0.7.12 - # via sphinx -asn1crypto==1.5.1 - # via scramp -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via - # jsonschema - # pytest -babel==2.10.1 - # via sphinx -backports.zoneinfo==0.2.1 - # via - # pytz-deprecation-shim - # tzlocal -bcrypt==3.2.2 - # via paramiko -cached-property==1.5.2 - # via docker-compose -cachetools==4.2.4 - # via google-auth -certifi==2021.10.8 - # via requests -cffi==1.15.0 - # via - # bcrypt - # cryptography - # pynacl -charset-normalizer==2.0.12 - # via requests -clickhouse-driver==0.2.3 - # via testcontainers -codecov==2.1.12 - # via -r requirements.in -coverage[toml]==6.2 - # via - # codecov - # pytest-cov -cryptography==36.0.2 - # via - # -r requirements.in - # paramiko -cx-oracle==8.3.0 - # via testcontainers -deprecated==1.2.13 - # via redis -deprecation==2.1.0 - # via testcontainers -distro==1.7.0 - # via docker-compose -docker[ssh]==5.0.3 - # via - # docker-compose - # testcontainers -docker-compose==1.29.2 - # via testcontainers -dockerpty==0.4.1 - # via docker-compose -docopt==0.6.2 - # via docker-compose -docutils==0.17.1 - # via sphinx -ecdsa==0.17.0 - # via python-jose -entrypoints==0.3 - # via flake8 -flake8==3.7.9 - # via -r requirements.in -google-api-core[grpc]==2.7.3 - # via google-cloud-pubsub -google-auth==2.6.6 - # via google-api-core -google-cloud-pubsub==1.7.1 - # via testcontainers -googleapis-common-protos[grpc]==1.56.0 - # via - # google-api-core - # grpc-google-iam-v1 - # grpcio-status -greenlet==1.1.2 - # via sqlalchemy -grpc-google-iam-v1==0.12.4 - # via google-cloud-pubsub -grpcio==1.46.0 - # via - # google-api-core - # googleapis-common-protos - # grpc-google-iam-v1 - # grpcio-status -grpcio-status==1.46.0 - # via google-api-core -idna==3.3 - # via requests -imagesize==1.3.0 - # via sphinx -importlib-metadata==4.8.3 - # via - # jsonschema - # pluggy - # pytest - # redis - # sphinx - # sqlalchemy -importlib-resources==5.4.0 - # via backports.zoneinfo -iniconfig==1.1.1 - # via pytest -jinja2==3.0.3 - # via sphinx -jsonschema==3.2.0 - # via docker-compose -kafka-python==2.0.2 - # via testcontainers -markupsafe==2.0.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -neo4j==4.4.3 - # via testcontainers -packaging==21.3 - # via - # deprecation - # pytest - # redis - # sphinx -paramiko==2.10.4 - # via docker -pg8000==1.26.0 - # via -r requirements.in -pika==1.2.1 - # via testcontainers -pluggy==1.0.0 - # via pytest -protobuf==3.19.4 - # via - # google-api-core - # googleapis-common-protos - # grpcio-status -psycopg2-binary==2.9.3 - # via testcontainers -py==1.11.0 - # via pytest -pyasn1==0.4.8 - # via - # pyasn1-modules - # python-jose - # rsa -pyasn1-modules==0.2.8 - # via google-auth -pycodestyle==2.5.0 - # via flake8 -pycparser==2.21 - # via cffi -pyflakes==2.1.1 - # via flake8 -pygments==2.12.0 - # via sphinx -pymongo==4.1.1 - # via testcontainers -pymssql==2.2.5 - # via testcontainers -pymysql==1.0.2 - # via testcontainers -pynacl==1.5.0 - # via paramiko -pyparsing==3.0.8 - # via packaging -pyrsistent==0.18.0 - # via jsonschema -pytest==7.0.1 - # via - # -r requirements.in - # pytest-cov -pytest-cov==3.0.0 - # via -r requirements.in -python-dotenv==0.20.0 - # via docker-compose -python-jose==3.3.0 - # via python-keycloak -python-keycloak==0.27.0 - # via testcontainers -pytz==2022.1 - # via - # babel - # clickhouse-driver - # neo4j -pytz-deprecation-shim==0.1.0.post0 - # via tzlocal -pyyaml==5.4.1 - # via docker-compose -redis==4.2.2 - # via testcontainers -requests==2.27.1 - # via - # codecov - # docker - # docker-compose - # google-api-core - # python-keycloak - # sphinx -rsa==4.8 - # via - # google-auth - # python-jose -scramp==1.4.1 - # via pg8000 -selenium==3.141.0 - # via testcontainers -six==1.16.0 - # via - # dockerpty - # ecdsa - # google-auth - # grpcio - # jsonschema - # paramiko - # websocket-client -snowballstemmer==2.2.0 - # via sphinx -sphinx==4.5.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlalchemy==1.4.36 - # via testcontainers -texttable==1.6.4 - # via docker-compose -tomli==1.2.3 - # via - # coverage - # pytest -typing-extensions==4.1.1 - # via - # async-timeout - # importlib-metadata - # redis -tzdata==2022.1 - # via pytz-deprecation-shim -tzlocal==4.2 - # via clickhouse-driver -urllib3==1.26.9 - # via - # requests - # selenium -websocket-client==0.59.0 - # via - # docker - # docker-compose -wrapt==1.14.1 - # via - # deprecated - # testcontainers -zipp==3.6.0 - # via - # importlib-metadata - # importlib-resources - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/3.7.txt b/requirements/3.7.txt deleted file mode 100644 index 455fc9e44..000000000 --- a/requirements/3.7.txt +++ /dev/null @@ -1,308 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.7 -# To update, run: -# -# pip-compile --output-file=requirements/3.7.txt requirements.in -# --e file:. - # via -r requirements.in -alabaster==0.7.12 - # via sphinx -asn1crypto==1.5.1 - # via scramp -async-generator==1.10 - # via - # trio - # trio-websocket -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via - # jsonschema - # outcome - # pytest - # trio -babel==2.10.1 - # via sphinx -backports-zoneinfo==0.2.1 - # via - # pytz-deprecation-shim - # tzlocal -bcrypt==3.2.2 - # via paramiko -cached-property==1.5.2 - # via docker-compose -cachetools==5.0.0 - # via google-auth -certifi==2021.10.8 - # via - # requests - # urllib3 -cffi==1.15.0 - # via - # bcrypt - # cryptography - # pynacl -charset-normalizer==2.0.12 - # via requests -clickhouse-driver==0.2.3 - # via testcontainers -codecov==2.1.12 - # via -r requirements.in -coverage[toml]==6.3.2 - # via - # codecov - # pytest-cov -cryptography==36.0.2 - # via - # -r requirements.in - # paramiko - # pyopenssl - # urllib3 -cx-oracle==8.3.0 - # via testcontainers -deprecated==1.2.13 - # via redis -deprecation==2.1.0 - # via testcontainers -distro==1.7.0 - # via docker-compose -docker[ssh]==5.0.3 - # via - # docker-compose - # testcontainers -docker-compose==1.29.2 - # via testcontainers -dockerpty==0.4.1 - # via docker-compose -docopt==0.6.2 - # via docker-compose -docutils==0.17.1 - # via sphinx -ecdsa==0.17.0 - # via python-jose -entrypoints==0.3 - # via flake8 -flake8==3.7.9 - # via -r requirements.in -google-api-core[grpc]==2.7.3 - # via google-cloud-pubsub -google-auth==2.6.6 - # via google-api-core -google-cloud-pubsub==1.7.1 - # via testcontainers -googleapis-common-protos[grpc]==1.56.0 - # via - # google-api-core - # grpc-google-iam-v1 - # grpcio-status -greenlet==1.1.2 - # via sqlalchemy -grpc-google-iam-v1==0.12.4 - # via google-cloud-pubsub -grpcio==1.46.0 - # via - # google-api-core - # googleapis-common-protos - # grpc-google-iam-v1 - # grpcio-status -grpcio-status==1.46.0 - # via google-api-core -h11==0.13.0 - # via wsproto -idna==3.3 - # via - # requests - # trio - # urllib3 -imagesize==1.3.0 - # via sphinx -importlib-metadata==4.11.3 - # via - # jsonschema - # pluggy - # pytest - # redis - # sphinx - # sqlalchemy -iniconfig==1.1.1 - # via pytest -jinja2==3.1.2 - # via sphinx -jsonschema==3.2.0 - # via docker-compose -kafka-python==2.0.2 - # via testcontainers -markupsafe==2.1.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -neo4j==4.4.3 - # via testcontainers -outcome==1.1.0 - # via trio -packaging==21.3 - # via - # deprecation - # pytest - # redis - # sphinx -paramiko==2.10.4 - # via docker -pg8000==1.26.1 - # via -r requirements.in -pika==1.2.1 - # via testcontainers -pluggy==1.0.0 - # via pytest -protobuf==3.20.1 - # via - # google-api-core - # googleapis-common-protos - # grpcio-status -psycopg2-binary==2.9.3 - # via testcontainers -py==1.11.0 - # via pytest -pyasn1==0.4.8 - # via - # pyasn1-modules - # python-jose - # rsa -pyasn1-modules==0.2.8 - # via google-auth -pycodestyle==2.5.0 - # via flake8 -pycparser==2.21 - # via cffi -pyflakes==2.1.1 - # via flake8 -pygments==2.12.0 - # via sphinx -pymongo==4.1.1 - # via testcontainers -pymssql==2.2.5 - # via testcontainers -pymysql==1.0.2 - # via testcontainers -pynacl==1.5.0 - # via paramiko -pyopenssl==22.0.0 - # via urllib3 -pyparsing==3.0.8 - # via packaging -pyrsistent==0.18.1 - # via jsonschema -pysocks==1.7.1 - # via urllib3 -pytest==7.1.2 - # via - # -r requirements.in - # pytest-cov -pytest-cov==3.0.0 - # via -r requirements.in -python-dotenv==0.20.0 - # via docker-compose -python-jose==3.3.0 - # via python-keycloak -python-keycloak==0.27.0 - # via testcontainers -pytz==2022.1 - # via - # babel - # clickhouse-driver - # neo4j -pytz-deprecation-shim==0.1.0.post0 - # via tzlocal -pyyaml==5.4.1 - # via docker-compose -redis==4.2.2 - # via testcontainers -requests==2.27.1 - # via - # codecov - # docker - # docker-compose - # google-api-core - # python-keycloak - # sphinx -rsa==4.8 - # via - # google-auth - # python-jose -scramp==1.4.1 - # via pg8000 -selenium==4.1.3 - # via testcontainers -six==1.16.0 - # via - # dockerpty - # ecdsa - # google-auth - # grpcio - # jsonschema - # paramiko - # websocket-client -sniffio==1.2.0 - # via trio -snowballstemmer==2.2.0 - # via sphinx -sortedcontainers==2.4.0 - # via trio -sphinx==4.5.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlalchemy==1.4.36 - # via testcontainers -texttable==1.6.4 - # via docker-compose -tomli==2.0.1 - # via - # coverage - # pytest -trio==0.20.0 - # via - # selenium - # trio-websocket -trio-websocket==0.9.2 - # via selenium -typing-extensions==4.2.0 - # via - # async-timeout - # h11 - # importlib-metadata - # redis -tzdata==2022.1 - # via pytz-deprecation-shim -tzlocal==4.2 - # via clickhouse-driver -urllib3[secure,socks]==1.26.9 - # via - # requests - # selenium -websocket-client==0.59.0 - # via - # docker - # docker-compose -wrapt==1.14.1 - # via - # deprecated - # testcontainers -wsproto==1.1.0 - # via trio-websocket -zipp==3.8.0 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/3.8.txt b/requirements/3.8.txt deleted file mode 100644 index 7189bc0b4..000000000 --- a/requirements/3.8.txt +++ /dev/null @@ -1,294 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: -# -# pip-compile --output-file=requirements/3.8.txt requirements.in -# --e file:. - # via -r requirements.in -alabaster==0.7.12 - # via sphinx -asn1crypto==1.5.1 - # via scramp -async-generator==1.10 - # via - # trio - # trio-websocket -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via - # jsonschema - # outcome - # pytest - # trio -babel==2.10.1 - # via sphinx -backports-zoneinfo==0.2.1 - # via - # pytz-deprecation-shim - # tzlocal -bcrypt==3.2.2 - # via paramiko -cachetools==5.0.0 - # via google-auth -certifi==2021.10.8 - # via - # requests - # urllib3 -cffi==1.15.0 - # via - # bcrypt - # cryptography - # pynacl -charset-normalizer==2.0.12 - # via requests -clickhouse-driver==0.2.3 - # via testcontainers -codecov==2.1.12 - # via -r requirements.in -coverage[toml]==6.3.2 - # via - # codecov - # pytest-cov -cryptography==36.0.2 - # via - # -r requirements.in - # paramiko - # pyopenssl - # urllib3 -cx-oracle==8.3.0 - # via testcontainers -deprecated==1.2.13 - # via redis -deprecation==2.1.0 - # via testcontainers -distro==1.7.0 - # via docker-compose -docker[ssh]==5.0.3 - # via - # docker-compose - # testcontainers -docker-compose==1.29.2 - # via testcontainers -dockerpty==0.4.1 - # via docker-compose -docopt==0.6.2 - # via docker-compose -docutils==0.17.1 - # via sphinx -ecdsa==0.17.0 - # via python-jose -entrypoints==0.3 - # via flake8 -flake8==3.7.9 - # via -r requirements.in -google-api-core[grpc]==2.7.3 - # via google-cloud-pubsub -google-auth==2.6.6 - # via google-api-core -google-cloud-pubsub==1.7.1 - # via testcontainers -googleapis-common-protos[grpc]==1.56.0 - # via - # google-api-core - # grpc-google-iam-v1 - # grpcio-status -greenlet==1.1.2 - # via sqlalchemy -grpc-google-iam-v1==0.12.4 - # via google-cloud-pubsub -grpcio==1.46.0 - # via - # google-api-core - # googleapis-common-protos - # grpc-google-iam-v1 - # grpcio-status -grpcio-status==1.46.0 - # via google-api-core -h11==0.13.0 - # via wsproto -idna==3.3 - # via - # requests - # trio - # urllib3 -imagesize==1.3.0 - # via sphinx -importlib-metadata==4.11.3 - # via sphinx -iniconfig==1.1.1 - # via pytest -jinja2==3.1.2 - # via sphinx -jsonschema==3.2.0 - # via docker-compose -kafka-python==2.0.2 - # via testcontainers -markupsafe==2.1.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -neo4j==4.4.3 - # via testcontainers -outcome==1.1.0 - # via trio -packaging==21.3 - # via - # deprecation - # pytest - # redis - # sphinx -paramiko==2.10.4 - # via docker -pg8000==1.26.1 - # via -r requirements.in -pika==1.2.1 - # via testcontainers -pluggy==1.0.0 - # via pytest -protobuf==3.20.1 - # via - # google-api-core - # googleapis-common-protos - # grpcio-status -psycopg2-binary==2.9.3 - # via testcontainers -py==1.11.0 - # via pytest -pyasn1==0.4.8 - # via - # pyasn1-modules - # python-jose - # rsa -pyasn1-modules==0.2.8 - # via google-auth -pycodestyle==2.5.0 - # via flake8 -pycparser==2.21 - # via cffi -pyflakes==2.1.1 - # via flake8 -pygments==2.12.0 - # via sphinx -pymongo==4.1.1 - # via testcontainers -pymssql==2.2.5 - # via testcontainers -pymysql==1.0.2 - # via testcontainers -pynacl==1.5.0 - # via paramiko -pyopenssl==22.0.0 - # via urllib3 -pyparsing==3.0.8 - # via packaging -pyrsistent==0.18.1 - # via jsonschema -pysocks==1.7.1 - # via urllib3 -pytest==7.1.2 - # via - # -r requirements.in - # pytest-cov -pytest-cov==3.0.0 - # via -r requirements.in -python-dotenv==0.20.0 - # via docker-compose -python-jose==3.3.0 - # via python-keycloak -python-keycloak==0.27.0 - # via testcontainers -pytz==2022.1 - # via - # babel - # clickhouse-driver - # neo4j -pytz-deprecation-shim==0.1.0.post0 - # via tzlocal -pyyaml==5.4.1 - # via docker-compose -redis==4.2.2 - # via testcontainers -requests==2.27.1 - # via - # codecov - # docker - # docker-compose - # google-api-core - # python-keycloak - # sphinx -rsa==4.8 - # via - # google-auth - # python-jose -scramp==1.4.1 - # via pg8000 -selenium==4.1.3 - # via testcontainers -six==1.16.0 - # via - # dockerpty - # ecdsa - # google-auth - # grpcio - # jsonschema - # paramiko - # websocket-client -sniffio==1.2.0 - # via trio -snowballstemmer==2.2.0 - # via sphinx -sortedcontainers==2.4.0 - # via trio -sphinx==4.5.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlalchemy==1.4.36 - # via testcontainers -texttable==1.6.4 - # via docker-compose -tomli==2.0.1 - # via - # coverage - # pytest -trio==0.20.0 - # via - # selenium - # trio-websocket -trio-websocket==0.9.2 - # via selenium -tzdata==2022.1 - # via pytz-deprecation-shim -tzlocal==4.2 - # via clickhouse-driver -urllib3[secure,socks]==1.26.9 - # via - # requests - # selenium -websocket-client==0.59.0 - # via - # docker - # docker-compose -wrapt==1.14.1 - # via - # deprecated - # testcontainers -wsproto==1.1.0 - # via trio-websocket -zipp==3.8.0 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/3.9.txt b/requirements/3.9.txt deleted file mode 100644 index eef7dbced..000000000 --- a/requirements/3.9.txt +++ /dev/null @@ -1,290 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: -# -# pip-compile --output-file=requirements/3.9.txt requirements.in -# --e file:. - # via -r requirements.in -alabaster==0.7.12 - # via sphinx -asn1crypto==1.5.1 - # via scramp -async-generator==1.10 - # via - # trio - # trio-websocket -async-timeout==4.0.2 - # via redis -attrs==21.4.0 - # via - # jsonschema - # outcome - # pytest - # trio -babel==2.10.1 - # via sphinx -bcrypt==3.2.2 - # via paramiko -cachetools==5.0.0 - # via google-auth -certifi==2021.10.8 - # via - # requests - # urllib3 -cffi==1.15.0 - # via - # bcrypt - # cryptography - # pynacl -charset-normalizer==2.0.12 - # via requests -clickhouse-driver==0.2.3 - # via testcontainers -codecov==2.1.12 - # via -r requirements.in -coverage[toml]==6.3.2 - # via - # codecov - # pytest-cov -cryptography==36.0.2 - # via - # -r requirements.in - # paramiko - # pyopenssl - # urllib3 -cx-oracle==8.3.0 - # via testcontainers -deprecated==1.2.13 - # via redis -deprecation==2.1.0 - # via testcontainers -distro==1.7.0 - # via docker-compose -docker[ssh]==5.0.3 - # via - # docker-compose - # testcontainers -docker-compose==1.29.2 - # via testcontainers -dockerpty==0.4.1 - # via docker-compose -docopt==0.6.2 - # via docker-compose -docutils==0.17.1 - # via sphinx -ecdsa==0.17.0 - # via python-jose -entrypoints==0.3 - # via flake8 -flake8==3.7.9 - # via -r requirements.in -google-api-core[grpc]==2.7.3 - # via google-cloud-pubsub -google-auth==2.6.6 - # via google-api-core -google-cloud-pubsub==1.7.1 - # via testcontainers -googleapis-common-protos[grpc]==1.56.0 - # via - # google-api-core - # grpc-google-iam-v1 - # grpcio-status -greenlet==1.1.2 - # via sqlalchemy -grpc-google-iam-v1==0.12.4 - # via google-cloud-pubsub -grpcio==1.46.0 - # via - # google-api-core - # googleapis-common-protos - # grpc-google-iam-v1 - # grpcio-status -grpcio-status==1.46.0 - # via google-api-core -h11==0.13.0 - # via wsproto -idna==3.3 - # via - # requests - # trio - # urllib3 -imagesize==1.3.0 - # via sphinx -importlib-metadata==4.11.3 - # via sphinx -iniconfig==1.1.1 - # via pytest -jinja2==3.1.2 - # via sphinx -jsonschema==3.2.0 - # via docker-compose -kafka-python==2.0.2 - # via testcontainers -markupsafe==2.1.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -neo4j==4.4.3 - # via testcontainers -outcome==1.1.0 - # via trio -packaging==21.3 - # via - # deprecation - # pytest - # redis - # sphinx -paramiko==2.10.4 - # via docker -pg8000==1.26.1 - # via -r requirements.in -pika==1.2.1 - # via testcontainers -pluggy==1.0.0 - # via pytest -protobuf==3.20.1 - # via - # google-api-core - # googleapis-common-protos - # grpcio-status -psycopg2-binary==2.9.3 - # via testcontainers -py==1.11.0 - # via pytest -pyasn1==0.4.8 - # via - # pyasn1-modules - # python-jose - # rsa -pyasn1-modules==0.2.8 - # via google-auth -pycodestyle==2.5.0 - # via flake8 -pycparser==2.21 - # via cffi -pyflakes==2.1.1 - # via flake8 -pygments==2.12.0 - # via sphinx -pymongo==4.1.1 - # via testcontainers -pymssql==2.2.5 - # via testcontainers -pymysql==1.0.2 - # via testcontainers -pynacl==1.5.0 - # via paramiko -pyopenssl==22.0.0 - # via urllib3 -pyparsing==3.0.8 - # via packaging -pyrsistent==0.18.1 - # via jsonschema -pysocks==1.7.1 - # via urllib3 -pytest==7.1.2 - # via - # -r requirements.in - # pytest-cov -pytest-cov==3.0.0 - # via -r requirements.in -python-dotenv==0.20.0 - # via docker-compose -python-jose==3.3.0 - # via python-keycloak -python-keycloak==0.27.0 - # via testcontainers -pytz==2022.1 - # via - # babel - # clickhouse-driver - # neo4j -pytz-deprecation-shim==0.1.0.post0 - # via tzlocal -pyyaml==5.4.1 - # via docker-compose -redis==4.2.2 - # via testcontainers -requests==2.27.1 - # via - # codecov - # docker - # docker-compose - # google-api-core - # python-keycloak - # sphinx -rsa==4.8 - # via - # google-auth - # python-jose -scramp==1.4.1 - # via pg8000 -selenium==4.1.3 - # via testcontainers -six==1.16.0 - # via - # dockerpty - # ecdsa - # google-auth - # grpcio - # jsonschema - # paramiko - # websocket-client -sniffio==1.2.0 - # via trio -snowballstemmer==2.2.0 - # via sphinx -sortedcontainers==2.4.0 - # via trio -sphinx==4.5.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -sqlalchemy==1.4.36 - # via testcontainers -texttable==1.6.4 - # via docker-compose -tomli==2.0.1 - # via - # coverage - # pytest -trio==0.20.0 - # via - # selenium - # trio-websocket -trio-websocket==0.9.2 - # via selenium -tzdata==2022.1 - # via pytz-deprecation-shim -tzlocal==4.2 - # via clickhouse-driver -urllib3[secure,socks]==1.26.9 - # via - # requests - # selenium -websocket-client==0.59.0 - # via - # docker - # docker-compose -wrapt==1.14.1 - # via - # deprecated - # testcontainers -wsproto==1.1.0 - # via trio-websocket -zipp==3.8.0 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/scripts/diagnostics.py b/scripts/diagnostics.py new file mode 100644 index 000000000..ef8bb6507 --- /dev/null +++ b/scripts/diagnostics.py @@ -0,0 +1,25 @@ +import json + +from testcontainers.core import utils +from testcontainers.core.container import DockerContainer + +result = { + "is_linux": utils.is_linux(), + "is_mac": utils.is_mac(), + "is_windows": utils.is_windows(), + "inside_container": utils.inside_container(), + "default_gateway_ip": utils.default_gateway_ip(), +} + +with DockerContainer("alpine:latest") as container: + client = container.get_docker_client() + result.update( + { + "container_host_ip": container.get_container_host_ip(), + "docker_client_gateway_ip": client.gateway_ip(container._container.id), + "docker_client_bridge_ip": client.bridge_ip(container._container.id), + "docker_client_host": client.host(), + } + ) + +print(json.dumps(result, indent=2)) # noqa: T201 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d5bfb97b9..000000000 --- a/setup.cfg +++ /dev/null @@ -1,13 +0,0 @@ -[bdist_wheel] -universal = 1 - -[metadata] -description-file = README.rst - -[flake8] -max-line-length = 100 -exclude = .git,__pycache__,build,dist,venv,.venv - -[tools:pytest] -log_cli_level = INFO -log_cli = true diff --git a/setup.py b/setup.py deleted file mode 100644 index de1293dc3..000000000 --- a/setup.py +++ /dev/null @@ -1,74 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import setuptools - -with open('README.rst') as fp: - long_description = fp.read() - -# Load the version number -try: - with open('VERSION') as fp: - version = fp.read().strip() -except FileNotFoundError: - version = 'dev' - -setuptools.setup( - name='testcontainers', - packages=setuptools.find_packages(exclude=['tests']), - version=version, - description='Library provides lightweight, throwaway instances of common databases, Selenium ' - 'web browsers, or anything else that can run in a Docker container', - author='Sergey Pirogov', - author_email='automationremarks@gmail.com', - url='https://github.com/testcontainers/testcontainers-python', - keywords=['testing', 'logging', 'docker', 'test automation'], - classifiers=[ - 'License :: OSI Approved :: Apache Software License', - 'Intended Audience :: Information Technology', - 'Intended Audience :: Developers', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: POSIX', - 'Operating System :: Unix', - 'Operating System :: MacOS', - ], - install_requires=[ - 'docker>=4.0.0', - 'wrapt', - 'deprecation', - ], - extras_require={ - 'docker-compose': ['docker-compose'], - 'mysql': ['sqlalchemy', 'pymysql'], - 'oracle': ['sqlalchemy', 'cx_Oracle'], - 'postgresql': ['sqlalchemy', 'psycopg2-binary'], - 'selenium': ['selenium'], - 'google-cloud-pubsub': ['google-cloud-pubsub < 2'], - 'mongo': ['pymongo'], - 'redis': ['redis'], - 'mssqlserver': ['pymssql'], - 'neo4j': ['neo4j'], - 'kafka': ['kafka-python'], - 'rabbitmq': ['pika'], - 'clickhouse': ['clickhouse-driver'], - 'keycloak': ['python-keycloak'], - }, - long_description_content_type="text/x-rst", - long_description=long_description, - python_requires='>=3.6', -) diff --git a/testcontainers/clickhouse.py b/testcontainers/clickhouse.py deleted file mode 100644 index 585c35e49..000000000 --- a/testcontainers/clickhouse.py +++ /dev/null @@ -1,75 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -import os - -import clickhouse_driver -from clickhouse_driver.errors import Error - -from testcontainers.core.generic import DbContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class ClickHouseContainer(DbContainer): - """ - ClickHouse database container. - - Example - ------- - The example spins up a ClickHouse database and connects to it - using the :code:`clickhouse-driver`. - :: - - with ClickHouseContainer("clickhouse/clickhouse-server:21.8") as clickhouse: - with clickhouse_driver.Client.from_url(self.get_connection_url()) as client: - result = client.execute("SELECT version()") - """ - - CLICKHOUSE_USER = os.environ.get("CLICKHOUSE_USER", "test") - CLICKHOUSE_PASSWORD = os.environ.get("CLICKHOUSE_PASSWORD", "test") - CLICKHOUSE_DB = os.environ.get("CLICKHOUSE_DB", "test") - - def __init__( - self, - image="clickhouse/clickhouse-server:latest", - port=9000, - user=None, - password=None, - dbname=None - ): - super().__init__(image=image) - - self.CLICKHOUSE_USER = user or self.CLICKHOUSE_USER - self.CLICKHOUSE_PASSWORD = password or self.CLICKHOUSE_PASSWORD - self.CLICKHOUSE_DB = dbname or self.CLICKHOUSE_DB - self.port_to_expose = port - - @wait_container_is_ready(Error, EOFError) - def _connect(self): - with clickhouse_driver.Client.from_url(self.get_connection_url()) as client: - client.execute("SELECT version()") - - def _configure(self): - self.with_exposed_ports(self.port_to_expose) - self.with_env("CLICKHOUSE_USER", self.CLICKHOUSE_USER) - self.with_env("CLICKHOUSE_PASSWORD", self.CLICKHOUSE_PASSWORD) - self.with_env("CLICKHOUSE_DB", self.CLICKHOUSE_DB) - - def get_connection_url(self, host=None): - return self._create_connection_url( - dialect="clickhouse", - username=self.CLICKHOUSE_USER, - password=self.CLICKHOUSE_PASSWORD, - db_name=self.CLICKHOUSE_DB, - host=host, - port=self.port_to_expose, - ) diff --git a/testcontainers/compose.py b/testcontainers/compose.py deleted file mode 100644 index 1cac63af8..000000000 --- a/testcontainers/compose.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Docker Compose Support -====================== - -Allows to spin up services configured via :code:`docker-compose.yml`. -""" - -import requests -import subprocess - -from testcontainers.core.waiting_utils import wait_container_is_ready -from testcontainers.core.exceptions import NoSuchPortExposed - - -class DockerCompose(object): - """ - Manage docker compose environments. - - Parameters - ---------- - filepath: str - The relative directory containing the docker compose configuration file - compose_file_name: str - The file name of the docker compose configuration file - pull: bool - Attempts to pull images before launching environment - build: bool - Whether to build images referenced in the configuration file - env_file: str - Path to an env file containing environment variables to pass to docker compose - - Example - ------- - :: - - with DockerCompose("/home/project", - compose_file_name=["docker-compose-1.yml", "docker-compose-2.yml"], - pull=True) as compose: - host = compose.get_service_host("hub", 4444) - port = compose.get_service_port("hub", 4444) - driver = webdriver.Remote( - command_executor=("http://{}:{}/wd/hub".format(host,port)), - desired_capabilities=CHROME, - ) - driver.get("http://automation-remarks.com") - stdout, stderr = compose.get_logs() - if stderr: - print("Errors\\n:{}".format(stderr)) - - - .. code-block:: yaml - - hub: - image: selenium/hub - ports: - - "4444:4444" - firefox: - image: selenium/node-firefox - links: - - hub - expose: - - "5555" - chrome: - image: selenium/node-chrome - links: - - hub - expose: - - "5555" - """ - def __init__( - self, - filepath, - compose_file_name="docker-compose.yml", - pull=False, - build=False, - env_file=None): - self.filepath = filepath - self.compose_file_names = compose_file_name if isinstance( - compose_file_name, (list, tuple) - ) else [compose_file_name] - self.pull = pull - self.build = build - self.env_file = env_file - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.stop() - - def docker_compose_command(self): - """ - Returns command parts used for the docker compose commands - - Returns - ------- - list[str] - The docker compose command parts - """ - docker_compose_cmd = ['docker-compose'] - for file in self.compose_file_names: - docker_compose_cmd += ['-f', file] - if self.env_file: - docker_compose_cmd += ['--env-file', self.env_file] - return docker_compose_cmd - - def start(self): - """ - Starts the docker compose environment. - """ - if self.pull: - pull_cmd = self.docker_compose_command() + ['pull'] - self._call_command(cmd=pull_cmd) - - up_cmd = self.docker_compose_command() + ['up', '-d'] - if self.build: - up_cmd.append('--build') - - self._call_command(cmd=up_cmd) - - def stop(self): - """ - Stops the docker compose environment. - """ - down_cmd = self.docker_compose_command() + ['down', '-v'] - self._call_command(cmd=down_cmd) - - def get_logs(self): - """ - Returns all log output from stdout and stderr - - Returns - ------- - tuple[bytes, bytes] - stdout, stderr - """ - logs_cmd = self.docker_compose_command() + ["logs"] - result = subprocess.run( - logs_cmd, - cwd=self.filepath, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return result.stdout, result.stderr - - def exec_in_container(self, service_name, command): - """ - Executes a command in the container of one of the services. - - Parameters - ---------- - service_name: str - Name of the docker compose service to run the command in - command: list[str] - The command to execute - - Returns - ------- - tuple[str, str, int] - stdout, stderr, return code - """ - exec_cmd = self.docker_compose_command() + ['exec', '-T', service_name] + command - result = subprocess.run( - exec_cmd, - cwd=self.filepath, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), result.returncode - - def get_service_port(self, service_name, port): - """ - Returns the mapped port for one of the services. - - Parameters - ---------- - service_name: str - Name of the docker compose service - port: int - The internal port to get the mapping for - - Returns - ------- - str: - The mapped port on the host - """ - return self._get_service_info(service_name, port)[1] - - def get_service_host(self, service_name, port): - """ - Returns the host for one of the services. - - Parameters - ---------- - service_name: str - Name of the docker compose service - port: int - The internal port to get the host for - - Returns - ------- - str: - The hostname for the service - """ - return self._get_service_info(service_name, port)[0] - - def _get_service_info(self, service, port): - port_cmd = self.docker_compose_command() + ["port", service, str(port)] - output = subprocess.check_output(port_cmd, cwd=self.filepath).decode("utf-8") - result = str(output).rstrip().split(":") - if len(result) != 2 or not all(result): - raise NoSuchPortExposed(f"port {port} is not exposed for service {service}") - return result - - def _call_command(self, cmd, filepath=None): - if filepath is None: - filepath = self.filepath - subprocess.call(cmd, cwd=filepath) - - @wait_container_is_ready(requests.exceptions.ConnectionError) - def wait_for(self, url): - """ - Waits for a response from a given URL. This is typically used to - block until a service in the environment has started and is responding. - Note that it does not assert any sort of return code, only check that - the connection was successful. - - Parameters - ---------- - url: str - URL from one of the services in the environment to use to wait on - """ - requests.get(url) - return self diff --git a/testcontainers/core/config.py b/testcontainers/core/config.py deleted file mode 100644 index b17d1f79d..000000000 --- a/testcontainers/core/config.py +++ /dev/null @@ -1,4 +0,0 @@ -from os import environ - -MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120)) -SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1)) diff --git a/testcontainers/core/container.py b/testcontainers/core/container.py deleted file mode 100644 index 849460df8..000000000 --- a/testcontainers/core/container.py +++ /dev/null @@ -1,143 +0,0 @@ -from deprecation import deprecated -from docker.models.containers import Container - -from testcontainers.core.docker_client import DockerClient -from testcontainers.core.exceptions import ContainerStartException -from testcontainers.core.utils import setup_logger, inside_container, is_arm - -logger = setup_logger(__name__) - - -class DockerContainer(object): - def __init__(self, image, docker_client_kw: dict = None, **kwargs): - self.env = {} - self.ports = {} - self.volumes = {} - self.image = image - self._docker = DockerClient(**(docker_client_kw or {})) - self._container = None - self._command = None - self._name = None - self._kwargs = kwargs - - def with_env(self, key: str, value: str) -> 'DockerContainer': - self.env[key] = value - return self - - def with_bind_ports(self, container: int, - host: int = None) -> 'DockerContainer': - self.ports[container] = host - return self - - def with_exposed_ports(self, *ports) -> 'DockerContainer': - for port in list(ports): - self.ports[port] = None - return self - - @deprecated(details='Use `with_kwargs`.') - def with_kargs(self, **kargs) -> 'DockerContainer': - return self.with_kwargs(**kargs) - - def with_kwargs(self, **kwargs) -> 'DockerContainer': - self._kwargs = kwargs - return self - - def maybe_emulate_amd64(self) -> 'DockerContainer': - if is_arm(): - return self.with_kwargs(platform='linux/amd64') - return self - - def start(self): - logger.info("Pulling image %s", self.image) - docker_client = self.get_docker_client() - self._container = docker_client.run(self.image, - command=self._command, - detach=True, - environment=self.env, - ports=self.ports, - name=self._name, - volumes=self.volumes, - **self._kwargs - ) - logger.info("Container started: %s", self._container.short_id) - return self - - def stop(self, force=True, delete_volume=True): - self.get_wrapped_container().remove(force=force, v=delete_volume) - - def __enter__(self): - return self.start() - - def __exit__(self, exc_type, exc_val, exc_tb): - self.stop() - - def __del__(self): - """ - Try to remove the container in all circumstances - """ - if self._container is not None: - try: - self.stop() - except: # noqa: E722 - pass - - def get_container_host_ip(self) -> str: - # infer from docker host - host = self.get_docker_client().host() - if not host: - return "localhost" - - # check testcontainers itself runs inside docker container - if inside_container(): - # If newly spawned container's gateway IP address from the docker - # "bridge" network is equal to detected host address, we should use - # container IP address, otherwise fall back to detected host - # address. Even it's inside container, we need to double check, - # because docker host might be set to docker:dind, usually in CI/CD environment - gateway_ip = self.get_docker_client().gateway_ip(self._container.id) - - if gateway_ip == host: - return self.get_docker_client().bridge_ip(self._container.id) - return gateway_ip - return host - - def get_exposed_port(self, port) -> str: - mapped_port = self.get_docker_client().port(self._container.id, port) - if inside_container(): - gateway_ip = self.get_docker_client().gateway_ip(self._container.id) - host = self.get_docker_client().host() - - if gateway_ip == host: - return port - return mapped_port - - def with_command(self, command: str) -> 'DockerContainer': - self._command = command - return self - - def with_name(self, name: str) -> 'DockerContainer': - self._name = name - return self - - def with_volume_mapping(self, host: str, container: str, - mode: str = 'ro') -> 'DockerContainer': - # '/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'} - mapping = {'bind': container, 'mode': mode} - self.volumes[host] = mapping - return self - - def get_wrapped_container(self) -> Container: - return self._container - - def get_docker_client(self) -> DockerClient: - return self._docker - - def get_logs(self): - if not self._container: - raise ContainerStartException("Container should be started before") - return self._container.logs(stderr=False), self._container.logs(stdout=False) - - def exec(self, command): - if not self._container: - raise ContainerStartException("Container should be started before") - return self.get_wrapped_container().exec_run(command) diff --git a/testcontainers/core/docker_client.py b/testcontainers/core/docker_client.py deleted file mode 100644 index b6513d8fe..000000000 --- a/testcontainers/core/docker_client.py +++ /dev/null @@ -1,82 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -import os -import urllib -import docker -from docker.models.containers import Container -from testcontainers.core.utils import inside_container -from testcontainers.core.utils import default_gateway_ip - - -class DockerClient(object): - def __init__(self, **kwargs): - self.client = docker.from_env(**kwargs) - - def run(self, image: str, - command: str = None, - environment: dict = None, - ports: dict = None, - detach: bool = False, - stdout: bool = True, - stderr: bool = False, - remove: bool = False, **kwargs) -> Container: - return self.client.containers.run(image, - command=command, - stdout=stdout, - stderr=stderr, - remove=remove, - detach=detach, - environment=environment, - ports=ports, - **kwargs) - - def port(self, container_id, port): - port_mappings = self.client.api.port(container_id, port) - if not port_mappings: - raise RuntimeError(f'port mapping for container {container_id} and port {port} is not ' - 'available') - return port_mappings[0]["HostPort"] - - def get_container(self, container_id): - containers = self.client.api.containers(filters={'id': container_id}) - if not containers: - raise RuntimeError(f'could not get container with id {container_id}') - return containers[0] - - def bridge_ip(self, container_id): - container = self.get_container(container_id) - return container['NetworkSettings']['Networks']['bridge']['IPAddress'] - - def gateway_ip(self, container_id): - container = self.get_container(container_id) - return container['NetworkSettings']['Networks']['bridge']['Gateway'] - - def host(self): - # https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644 - # if os env TC_HOST is set, use it - host = os.environ.get('TC_HOST') - if host: - return host - try: - url = urllib.parse.urlparse(self.client.api.base_url) - - except ValueError: - return None - if 'http' in url.scheme or 'tcp' in url.scheme: - return url.hostname - if 'unix' in url.scheme or 'npipe' in url.scheme: - if inside_container(): - ip_address = default_gateway_ip() - if ip_address: - return ip_address - return "localhost" diff --git a/testcontainers/core/waiting_utils.py b/testcontainers/core/waiting_utils.py deleted file mode 100644 index c147ed959..000000000 --- a/testcontainers/core/waiting_utils.py +++ /dev/null @@ -1,101 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - - -import re -import time -import traceback - -import wrapt - -from testcontainers.core import config -from testcontainers.core.exceptions import TimeoutException -from testcontainers.core.utils import setup_logger - -logger = setup_logger(__name__) - - -# Get a tuple of transient exceptions for which we'll retry. Other exceptions will be raised. -TRANSIENT_EXCEPTIONS = (TimeoutError, ConnectionError) - - -def wait_container_is_ready(*transient_exceptions): - """ - Wait until container is ready. - Function that spawn container should be decorated by this method - Max wait is configured by config. Default is 120 sec. - Polling interval is 1 sec. - :return: - """ - - transient_exceptions = TRANSIENT_EXCEPTIONS + tuple(transient_exceptions) - - @wrapt.decorator - def wrapper(wrapped, instance, args, kwargs): - exception = None - logger.info("Waiting to be ready...") - for attempt_no in range(config.MAX_TRIES): - try: - return wrapped(*args, **kwargs) - except transient_exceptions as e: - logger.debug(f"Connection attempt '{attempt_no + 1}' of '{config.MAX_TRIES + 1}' " - f"failed: {traceback.format_exc()}") - time.sleep(config.SLEEP_TIME) - exception = e - raise TimeoutException( - f'Wait time ({config.MAX_TRIES * config.SLEEP_TIME}s) exceeded for {wrapped.__name__}' - f'(args: {args}, kwargs {kwargs}). Exception: {exception}' - ) - - return wrapper - - -@wait_container_is_ready() -def wait_for(condition): - return condition() - - -def wait_for_logs(container, predicate, timeout=None, interval=1): - """ - Wait for the container to emit logs satisfying the predicate. - - Parameters - ---------- - container : DockerContainer - Container whose logs to wait for. - predicate : callable or str - Predicate that should be satisfied by the logs. If a string, the it is used as the pattern - for a multiline regular expression search. - timeout : float or None - Number of seconds to wait for the predicate to be satisfied. Defaults to wait indefinitely. - interval : float - Interval at which to poll the logs. - - Returns - ------- - duration : float - Number of seconds until the predicate was satisfied. - """ - if isinstance(predicate, str): - predicate = re.compile(predicate, re.MULTILINE).search - start = time.time() - while True: - duration = time.time() - start - stdout = container.get_logs()[0].decode() - stderr = container.get_logs()[1].decode() - if predicate(stdout) or predicate(stderr): - return duration - if timeout and duration > timeout: - raise TimeoutError("container did not emit logs satisfying predicate in %.3f seconds" - % timeout) - time.sleep(interval) diff --git a/testcontainers/elasticsearch.py b/testcontainers/elasticsearch.py deleted file mode 100644 index 1e9f6eb1e..000000000 --- a/testcontainers/elasticsearch.py +++ /dev/null @@ -1,58 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from deprecation import deprecated -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready -import urllib - - -class ElasticSearchContainer(DockerContainer): - """ - ElasticSearch container. - - Example - ------- - :: - - with ElasticSearchContainer() as es: - connection_url = es.get_url() - """ - def __init__(self, image="elasticsearch", port_to_expose=9200, **kwargs): - super(ElasticSearchContainer, self).__init__(image, **kwargs) - self.port_to_expose = port_to_expose - self.with_exposed_ports(self.port_to_expose) - self.with_env('transport.host', '127.0.0.1') - self.with_env('http.host', '0.0.0.0') - self.with_env('discovery.zen.minimum_master_nodes', '1') - - @wait_container_is_ready() - def _connect(self): - res = urllib.request.urlopen(self.get_url()) - if res.status != 200: - raise Exception() - - def get_url(self): - host = self.get_container_host_ip() - port = self.get_exposed_port(self.port_to_expose) - return 'http://{}:{}'.format(host, port) - - def start(self): - super().start() - self._connect() - return self - - -@deprecated(details='Use `ElasticSearchContainer` with a capital S instead ' - 'of `ElasticsearchContainer`.') -class ElasticsearchContainer(ElasticSearchContainer): - pass diff --git a/testcontainers/general.py b/testcontainers/general.py deleted file mode 100644 index 04452655f..000000000 --- a/testcontainers/general.py +++ /dev/null @@ -1,23 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from deprecation import deprecated -from testcontainers.core.container import DockerContainer - - -class TestContainer(DockerContainer): - @deprecated(details="Use `DockerContainer`.") - def __init__(self, image, port_to_expose=None): - super(TestContainer, self).__init__(image) - if port_to_expose: - self.port_to_expose = port_to_expose - self.with_exposed_ports(self.port_to_expose) diff --git a/testcontainers/google/__init__.py b/testcontainers/google/__init__.py deleted file mode 100644 index 0a6fa82e8..000000000 --- a/testcontainers/google/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Google Cloud Emulators -====================== - -Allows to spin up google cloud emulators, such as PubSub. -""" - -from .pubsub import PubSubContainer # noqa diff --git a/testcontainers/google/pubsub.py b/testcontainers/google/pubsub.py deleted file mode 100644 index 0400e3668..000000000 --- a/testcontainers/google/pubsub.py +++ /dev/null @@ -1,64 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from ..core.container import DockerContainer - - -class PubSubContainer(DockerContainer): - """ - PubSub container for testing managed message queues. - - Example - ------- - The example will spin up a Google Cloud PubSub emulator that you can use for integration tests. - The :code:`pubsub` instance provides convenience methods :code:`get_publisher` and - :code:`get_subscriber` to connect to the emulator without having to set the environment variable - :code:`PUBSUB_EMULATOR_HOST`. - :: - - def test_docker_run_pubsub(): - config = PubSubContainer('google/cloud-sdk:latest') - with config as pubsub: - publisher = pubsub.get_publisher() - topic_path = publisher.topic_path(pubsub.project, "my-topic") - topic = publisher.create_topic(topic_path) - """ - def __init__(self, image="google/cloud-sdk:latest", - project="test-project", port=8432, **kwargs): - super(PubSubContainer, self).__init__(image=image, **kwargs) - self.project = project - self.port = port - self.with_exposed_ports(self.port) - self.with_command("gcloud beta emulators pubsub start --project=" - "{project} --host-port=0.0.0.0:{port}".format( - project=self.project, port=self.port, - )) - - def get_pubsub_emulator_host(self): - return "{host}:{port}".format(host=self.get_container_host_ip(), - port=self.get_exposed_port(self.port)) - - def _get_channel(self, channel=None): - if channel is None: - import grpc - return grpc.insecure_channel(target=self.get_pubsub_emulator_host()) - - def get_publisher_client(self, **kwargs): - from google.cloud import pubsub - kwargs['channel'] = self._get_channel(kwargs.get('channel')) - return pubsub.PublisherClient(**kwargs) - - def get_subscriber_client(self, **kwargs): - from google.cloud import pubsub - kwargs['channel'] = self._get_channel(kwargs.get('channel')) - return pubsub.SubscriberClient(**kwargs) diff --git a/testcontainers/kafka.py b/testcontainers/kafka.py deleted file mode 100644 index be5cadc1a..000000000 --- a/testcontainers/kafka.py +++ /dev/null @@ -1,85 +0,0 @@ -import tarfile -import time -from io import BytesIO -from textwrap import dedent - -from kafka import KafkaConsumer -from kafka.errors import KafkaError, UnrecognizedBrokerVersion, NoBrokersAvailable - -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class KafkaContainer(DockerContainer): - KAFKA_PORT = 9093 - TC_START_SCRIPT = '/tc-start.sh' - - def __init__(self, image="confluentinc/cp-kafka:5.4.3", port_to_expose=KAFKA_PORT, **kwargs): - super(KafkaContainer, self).__init__(image, **kwargs) - self.port_to_expose = port_to_expose - self.with_exposed_ports(self.port_to_expose) - listeners = 'PLAINTEXT://0.0.0.0:{},BROKER://0.0.0.0:9092'.format(port_to_expose) - self.with_env('KAFKA_LISTENERS', listeners) - self.with_env('KAFKA_LISTENER_SECURITY_PROTOCOL_MAP', - 'BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT') - self.with_env('KAFKA_INTER_BROKER_LISTENER_NAME', 'BROKER') - - self.with_env('KAFKA_BROKER_ID', '1') - self.with_env('KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR', '1') - self.with_env('KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS', '1') - self.with_env('KAFKA_LOG_FLUSH_INTERVAL_MESSAGES', '10000000') - self.with_env('KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS', '0') - - def get_bootstrap_server(self): - host = self.get_container_host_ip() - port = self.get_exposed_port(self.port_to_expose) - return '{}:{}'.format(host, port) - - @wait_container_is_ready(UnrecognizedBrokerVersion, NoBrokersAvailable, KafkaError, ValueError) - def _connect(self): - bootstrap_server = self.get_bootstrap_server() - consumer = KafkaConsumer(group_id='test', bootstrap_servers=[bootstrap_server]) - if not consumer.topics(): - raise KafkaError("Unable to connect with kafka container!") - - def tc_start(self): - host = self.get_container_host_ip() - port = self.get_exposed_port(self.port_to_expose) - listeners = 'PLAINTEXT://{}:{},BROKER://$(hostname -i):9092'.format(host, port) - data = ( - dedent( - """ - #!/bin/bash - echo 'clientPort=2181' > zookeeper.properties - echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties - echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties - zookeeper-server-start zookeeper.properties & - export KAFKA_ZOOKEEPER_CONNECT='localhost:2181' - export KAFKA_ADVERTISED_LISTENERS={} - . /etc/confluent/docker/bash-config - /etc/confluent/docker/configure - /etc/confluent/docker/launch - """.format(listeners) - ) - .strip() - .encode('utf-8') - ) - self.create_file(data, KafkaContainer.TC_START_SCRIPT) - - def start(self): - script = KafkaContainer.TC_START_SCRIPT - command = 'sh -c "while [ ! -f {} ]; do sleep 0.1; done; sh {}"'.format(script, script) - self.with_command(command) - super().start() - self.tc_start() - self._connect() - return self - - def create_file(self, content: bytes, path: str): - with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar: - tarinfo = tarfile.TarInfo(name=path) - tarinfo.size = len(content) - tarinfo.mtime = time.time() - tar.addfile(tarinfo, BytesIO(content)) - archive.seek(0) - self.get_wrapped_container().put_archive("/", archive) diff --git a/testcontainers/keycloak.py b/testcontainers/keycloak.py deleted file mode 100644 index 5f03e238c..000000000 --- a/testcontainers/keycloak.py +++ /dev/null @@ -1,74 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -import os -import requests - -from keycloak import KeycloakAdmin - -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class KeycloakContainer(DockerContainer): - """ - Keycloak container. - - Example - ------- - :: - - with KeycloakContainer() as kc: - keycloak: KeycloakAdmin = kc.get_client() - """ - KEYCLOAK_USER = os.environ.get("KEYCLOAK_USER", "test") - KEYCLOAK_PASSWORD = os.environ.get("KEYCLOAK_PASSWORD", "test") - - def __init__(self, image="jboss/keycloak:latest"): - super(KeycloakContainer, self).__init__(image=image) - self.port_to_expose = 8080 - self.with_exposed_ports(self.port_to_expose) - - def _configure(self): - self.with_env("KEYCLOAK_USER", self.KEYCLOAK_USER) - self.with_env("KEYCLOAK_PASSWORD", self.KEYCLOAK_PASSWORD) - - def get_url(self): - host = self.get_container_host_ip() - port = self.get_exposed_port(self.port_to_expose) - return "http://{}:{}".format(host, port) - - @wait_container_is_ready(requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) - def _connect(self): - url = self.get_url() - response = requests.get("{}/auth".format(url), timeout=1) - response.raise_for_status() - - def start(self): - self._configure() - super().start() - self._connect() - return self - - def get_client(self, **kwargs): - default_kwargs = dict( - server_url="{}/auth/".format(self.get_url()), - username=self.KEYCLOAK_USER, - password=self.KEYCLOAK_PASSWORD, - realm_name="master", - verify=True, - ) - kwargs = { - **default_kwargs, - **kwargs - } - return KeycloakAdmin(**kwargs) diff --git a/testcontainers/localstack.py b/testcontainers/localstack.py deleted file mode 100644 index 1b942f264..000000000 --- a/testcontainers/localstack.py +++ /dev/null @@ -1,59 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from testcontainers.core.waiting_utils import wait_for_logs -from testcontainers.core.container import DockerContainer - - -class LocalStackContainer(DockerContainer): - """ - Localstack container. - - Example - ------- - :: - localstack = LocalStackContainer(image="localstack/localstack:0.11.4") - localstack.with_services("dynamodb", "lambda") - localstack.start() - dynamo_endpoint = localstack.get_url() - dynamo_client = boto3.client("dynamodb", endpoint_url=dynamo_endpoint) - scan_result = dynamo_client.scan(TableName='foo') - # Do something with the scan result - """ - EDGE_PORT = 4566 - IMAGE = 'localstack/localstack:0.11.4' - - def __init__(self, image=IMAGE, **kwargs): - super(LocalStackContainer, self).__init__(image, **kwargs) - self.with_exposed_ports(LocalStackContainer.EDGE_PORT) - - def with_services(self, *services): - """ - Restrict what services to run. By default all localstack services are launched. - :return: the DockerContainer to allow chaining of 'with_*' calls. - """ - return self.with_env('SERVICES', ','.join(services)) - - def get_url(self): - """ - Use this to call localstack instead of real AWS services. - ex: boto3.client('lambda', endpoint_url=localstack.get_url()) - :return: the endpoint where localstack is reachable. - """ - host = self.get_container_host_ip() - port = self.get_exposed_port(LocalStackContainer.EDGE_PORT) - return 'http://{}:{}'.format(host, port) - - def start(self, timeout=60): - super().start() - wait_for_logs(self, r'Ready\.\n', timeout=timeout) - return self diff --git a/testcontainers/mongodb.py b/testcontainers/mongodb.py deleted file mode 100644 index aedb3eb81..000000000 --- a/testcontainers/mongodb.py +++ /dev/null @@ -1,78 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -import os - -from testcontainers.core.generic import DbContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class MongoDbContainer(DbContainer): - """ - Mongo document-based database container. - - Example - ------- - :: - - with MongoDbContainer("mongo:latest") as mongo: - db = mongo.get_connection_client().test - # Insert a database entry - result = db.restaurants.insert_one( - { - "address": { - "street": "2 Avenue", - "zipcode": "10075", - "building": "1480", - "coord": [-73.9557413, 40.7720266] - }, - "borough": "Manhattan", - "cuisine": "Italian", - "name": "Vella", - "restaurant_id": "41704620" - } - ) - # Find the restaurant document - cursor = db.restaurants.find({"borough": "Manhattan"}) - for document in cursor: - # Do something interesting with the document - """ - MONGO_INITDB_ROOT_USERNAME = os.environ.get("MONGO_INITDB_ROOT_USERNAME", "test") - MONGO_INITDB_ROOT_PASSWORD = os.environ.get("MONGO_INITDB_ROOT_PASSWORD", "test") - MONGO_DB = os.environ.get("MONGO_DB", "test") - - def __init__(self, image="mongo:latest", **kwargs): - super(MongoDbContainer, self).__init__(image=image, **kwargs) - self.command = "mongo" - self.port_to_expose = 27017 - self.with_exposed_ports(self.port_to_expose) - - def _configure(self): - self.with_env("MONGO_INITDB_ROOT_USERNAME", self.MONGO_INITDB_ROOT_USERNAME) - self.with_env("MONGO_INITDB_ROOT_PASSWORD", self.MONGO_INITDB_ROOT_PASSWORD) - self.with_env("MONGO_DB", self.MONGO_DB) - - def get_connection_url(self): - return self._create_connection_url( - dialect='mongodb', - username=self.MONGO_INITDB_ROOT_USERNAME, - password=self.MONGO_INITDB_ROOT_PASSWORD, - port=self.port_to_expose, - ) - - @wait_container_is_ready() - def _connect(self): - from pymongo import MongoClient - return MongoClient(self.get_connection_url()) - - def get_connection_client(self): - return self._connect() diff --git a/testcontainers/mssql.py b/testcontainers/mssql.py deleted file mode 100644 index f41fa57b4..000000000 --- a/testcontainers/mssql.py +++ /dev/null @@ -1,44 +0,0 @@ -from os import environ - -from testcontainers.core.generic import DbContainer - - -class SqlServerContainer(DbContainer): - """ - Microsoft Sql Server database container. - - Example - ------- - :: - - with SqlServerContainer() as mssql: - e = sqlalchemy.create_engine(mssql.get_connection_url()) - result = e.execute("select @@VERSION") - - Notes - ----- - Requires `ODBC Driver 17 for SQL Server `_. - """ - def __init__(self, image="mcr.microsoft.com/mssql/server:2019-latest", user="SA", password=None, - port=1433, dbname="tempdb", dialect='mssql+pymssql', **kwargs): - super(SqlServerContainer, self).__init__(image, **kwargs) - - self.SQLSERVER_PASSWORD = password or environ.get("SQLSERVER_PASSWORD", "1Secure*Password1") - self.port_to_expose = port - self.SQLSERVER_USER = user - self.SQLSERVER_DBNAME = dbname - self.dialect = dialect - - def _configure(self): - self.with_exposed_ports(self.port_to_expose) - self.with_env("SA_PASSWORD", self.SQLSERVER_PASSWORD) - self.with_env("SQLSERVER_USER", self.SQLSERVER_USER) - self.with_env("SQLSERVER_DBNAME", self.SQLSERVER_DBNAME) - self.with_env("ACCEPT_EULA", 'Y') - - def get_connection_url(self): - return super()._create_connection_url( - dialect=self.dialect, username=self.SQLSERVER_USER, password=self.SQLSERVER_PASSWORD, - db_name=self.SQLSERVER_DBNAME, port=self.port_to_expose - ) diff --git a/testcontainers/mysql.py b/testcontainers/mysql.py deleted file mode 100644 index 8570589f8..000000000 --- a/testcontainers/mysql.py +++ /dev/null @@ -1,79 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from deprecation import deprecated -from os import environ - -from testcontainers.core.generic import DbContainer - - -class MySqlContainer(DbContainer): - """ - MySql database container. - - Example - ------- - The example will spin up a MySql database to which you can connect with the credentials passed - in the constructor. Alternatively, you may use the :code:`get_connection_url()` method which - returns a sqlalchemy-compatible url in format - :code:`dialect+driver://username:password@host:port/database`. - :: - - with MySqlContainer('mysql:5.7.17') as mysql: - e = sqlalchemy.create_engine(mysql.get_connection_url()) - result = e.execute("select version()") - version, = result.fetchone() - """ - def __init__(self, image="mysql:latest", **kwargs): - super(MySqlContainer, self).__init__(image) - self.port_to_expose = 3306 - self.with_exposed_ports(self.port_to_expose) - self.MYSQL_USER = kwargs.get('MYSQL_USER', environ.get('MYSQL_USER', 'test')) - self.MYSQL_ROOT_PASSWORD = kwargs.get('MYSQL_ROOT_PASSWORD', - environ.get('MYSQL_ROOT_PASSWORD', 'test')) - self.MYSQL_PASSWORD = kwargs.get('MYSQL_PASSWORD', environ.get('MYSQL_PASSWORD', 'test')) - self.MYSQL_DATABASE = kwargs.get('MYSQL_DATABASE', environ.get('MYSQL_DATABASE', 'test')) - - if self.MYSQL_USER == 'root': - self.MYSQL_ROOT_PASSWORD = self.MYSQL_PASSWORD - - def _configure(self): - self.with_env("MYSQL_ROOT_PASSWORD", self.MYSQL_ROOT_PASSWORD) - self.with_env("MYSQL_DATABASE", self.MYSQL_DATABASE) - - if self.MYSQL_USER != "root": - self.with_env("MYSQL_USER", self.MYSQL_USER) - self.with_env("MYSQL_PASSWORD", self.MYSQL_PASSWORD) - - def get_connection_url(self): - return super()._create_connection_url(dialect="mysql+pymysql", - username=self.MYSQL_USER, - password=self.MYSQL_PASSWORD, - db_name=self.MYSQL_DATABASE, - port=self.port_to_expose) - - -class MariaDbContainer(MySqlContainer): - """ - Maria database container, a commercially-supported fork of MySql. - - Example - ------- - :: - - with MariaDbContainer("mariadb:latest") as mariadb: - e = sqlalchemy.create_engine(mariadb.get_connection_url()) - result = e.execute("select version()") - """ - @deprecated(details="Use `MySqlContainer` with 'mariadb:latest' image.") - def __init__(self, image="mariadb:latest", **kwargs): - super(MariaDbContainer, self).__init__(image, **kwargs) diff --git a/testcontainers/neo4j.py b/testcontainers/neo4j.py deleted file mode 100644 index 67a30b260..000000000 --- a/testcontainers/neo4j.py +++ /dev/null @@ -1,89 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import os - -from neo4j import GraphDatabase - -from testcontainers.core.generic import DbContainer -from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs - - -class Neo4jContainer(DbContainer): - """ - Neo4j Graph Database (Standalone) database container. - - Example - ------- - :: - with Neo4jContainer() as neo4j: - with neo4j.get_driver() as driver: - with driver.session() as session: - result = session.run("MATCH (n) RETURN n LIMIT 1") - record = result.single() - - """ - - # The official image requires a change of password on startup. - NEO4J_ADMIN_PASSWORD = os.environ.get("NEO4J_ADMIN_PASSWORD", "password") - - # Default port for the binary Bolt protocol. - DEFAULT_BOLT_PORT = 7687 - - AUTH_FORMAT = "neo4j/{password}" - - NEO4J_STARTUP_TIMEOUT_SECONDS = 10 - - NEO4J_USER = "neo4j" - - def __init__(self, image="neo4j:latest", **kwargs): - super(Neo4jContainer, self).__init__(image, **kwargs) - self.bolt_port = Neo4jContainer.DEFAULT_BOLT_PORT - self.with_exposed_ports(self.bolt_port) - self._driver = None - - def _configure(self): - self.with_env( - "NEO4J_AUTH", - Neo4jContainer.AUTH_FORMAT.format(password=Neo4jContainer.NEO4J_ADMIN_PASSWORD) - ) - - def get_connection_url(self): - return "{dialect}://{host}:{port}".format( - dialect="bolt", - host=self.get_container_host_ip(), - port=self.get_exposed_port(self.bolt_port), - ) - - @wait_container_is_ready() - def _connect(self): - # First we wait for Neo4j to say it's listening - wait_for_logs( - self, - "Remote interface available at", - Neo4jContainer.NEO4J_STARTUP_TIMEOUT_SECONDS, - ) - - # Then we actually check that the container really is listening - with self.get_driver() as driver: - # Drivers may or may not be lazy - # force them to do a round trip to confirm neo4j is working - with driver.session() as session: - session.run("RETURN 1").single() - - def get_driver(self, **kwargs): - return GraphDatabase.driver( - self.get_connection_url(), - auth=(Neo4jContainer.NEO4J_USER, Neo4jContainer.NEO4J_ADMIN_PASSWORD), - **kwargs - ) diff --git a/testcontainers/nginx.py b/testcontainers/nginx.py deleted file mode 100644 index 39cfe82cb..000000000 --- a/testcontainers/nginx.py +++ /dev/null @@ -1,22 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from deprecation import deprecated -from testcontainers.core.container import DockerContainer - - -class NginxContainer(DockerContainer): - @deprecated(details="Use `DockerContainer` with 'nginx:latest' image and expose port 80.") - def __init__(self, image="nginx:latest", port_to_expose=80, **kwargs): - super(NginxContainer, self).__init__(image, **kwargs) - self.port_to_expose = port_to_expose - self.with_exposed_ports(self.port_to_expose) diff --git a/testcontainers/oracle.py b/testcontainers/oracle.py deleted file mode 100644 index b8a426445..000000000 --- a/testcontainers/oracle.py +++ /dev/null @@ -1,29 +0,0 @@ -from testcontainers.core.generic import DbContainer - - -class OracleDbContainer(DbContainer): - """ - Oracle database container. - - Example - ------- - :: - - with OracleDbContainer() as oracle: - e = sqlalchemy.create_engine(oracle.get_connection_url()) - result = e.execute("select 1 from dual") - """ - def __init__(self, image="wnameless/oracle-xe-11g-r2:latest", **kwargs): - super(OracleDbContainer, self).__init__(image=image, **kwargs) - self.container_port = 1521 - self.with_exposed_ports(self.container_port) - self.with_env("ORACLE_ALLOW_REMOTE", "true") - - def get_connection_url(self): - return super()._create_connection_url( - dialect="oracle", username="system", password="oracle", port=self.container_port, - db_name="xe" - ) - - def _configure(self): - pass diff --git a/testcontainers/postgres.py b/testcontainers/postgres.py deleted file mode 100644 index 0081c4a9a..000000000 --- a/testcontainers/postgres.py +++ /dev/null @@ -1,62 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -import os - -from testcontainers.core.generic import DbContainer - - -class PostgresContainer(DbContainer): - """ - Postgres database container. - - Example - ------- - The example spins up a Postgres database and connects to it using the :code:`psycopg` driver. - :: - - with PostgresContainer("postgres:9.5") as postgres: - e = sqlalchemy.create_engine(postgres.get_connection_url()) - result = e.execute("select version()") - """ - POSTGRES_USER = os.environ.get("POSTGRES_USER", "test") - POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "test") - POSTGRES_DB = os.environ.get("POSTGRES_DB", "test") - - def __init__(self, - image="postgres:latest", - port=5432, user=None, - password=None, - dbname=None, - driver="psycopg2", - **kwargs): - super(PostgresContainer, self).__init__(image=image, **kwargs) - self.POSTGRES_USER = user or self.POSTGRES_USER - self.POSTGRES_PASSWORD = password or self.POSTGRES_PASSWORD - self.POSTGRES_DB = dbname or self.POSTGRES_DB - self.port_to_expose = port - self.driver = driver - - self.with_exposed_ports(self.port_to_expose) - - def _configure(self): - self.with_env("POSTGRES_USER", self.POSTGRES_USER) - self.with_env("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) - self.with_env("POSTGRES_DB", self.POSTGRES_DB) - - def get_connection_url(self, host=None): - return super()._create_connection_url(dialect="postgresql+{}".format(self.driver), - username=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, - db_name=self.POSTGRES_DB, - host=host, - port=self.port_to_expose) diff --git a/testcontainers/rabbitmq.py b/testcontainers/rabbitmq.py deleted file mode 100644 index db012fc13..000000000 --- a/testcontainers/rabbitmq.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -from typing import Optional - -import pika -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class RabbitMqContainer(DockerContainer): - """ - Test container for RabbitMQ. - - Example - ------- - The example spins up a RabbitMQ broker and uses the `pika` client library - (https://pypi.org/project/pika/) establish a connection to the broker. - :: - from testcontainer.rabbitmq import RabbitMqContainer - import pika - - with RabbitMqContainer("rabbitmq:3.9.10") as rabbitmq: - - connection = pika.BlockingConnection(rabbitmq.get_connection_params()) - channel = connection.channel() - """ - - RABBITMQ_NODE_PORT = os.environ.get("RABBITMQ_NODE_PORT", 5672) - RABBITMQ_DEFAULT_USER = os.environ.get("RABBITMQ_DEFAULT_USER", "guest") - RABBITMQ_DEFAULT_PASS = os.environ.get("RABBITMQ_DEFAULT_PASS", "guest") - - def __init__( - self, - image: str = "rabbitmq:latest", - port: Optional[int] = None, - username: Optional[str] = None, - password: Optional[str] = None, - **kwargs, - ) -> None: - """Initialize the RabbitMQ test container. - - Args: - image (str, optional): - The docker image from docker hub. Defaults to "rabbitmq:latest". - port (int, optional): - The port to reach the AMQP API. Defaults to 5672. - username (str, optional): - Overwrite the default username which is "guest". - password (str, optional): - Overwrite the default username which is "guest". - """ - super(RabbitMqContainer, self).__init__(image=image, **kwargs) - self.RABBITMQ_NODE_PORT = port or int(self.RABBITMQ_NODE_PORT) - self.RABBITMQ_DEFAULT_USER = username or self.RABBITMQ_DEFAULT_USER - self.RABBITMQ_DEFAULT_PASS = password or self.RABBITMQ_DEFAULT_PASS - - self.with_exposed_ports(self.RABBITMQ_NODE_PORT) - self.with_env("RABBITMQ_NODE_PORT", self.RABBITMQ_NODE_PORT) - self.with_env("RABBITMQ_DEFAULT_USER", self.RABBITMQ_DEFAULT_USER) - self.with_env("RABBITMQ_DEFAULT_PASS", self.RABBITMQ_DEFAULT_PASS) - - @wait_container_is_ready(pika.exceptions.IncompatibleProtocolError) - def readiness_probe(self) -> bool: - """Test if the RabbitMQ broker is ready.""" - connection = pika.BlockingConnection(self.get_connection_params()) - if connection.is_open: - connection.close() - return self - raise RuntimeError("Could not open connection to RabbitMQ broker.") - - def get_connection_params(self) -> pika.ConnectionParameters: - """ - Get connection params as a pika.ConnectionParameters object. - For more details see: - https://pika.readthedocs.io/en/latest/modules/parameters.html - """ - credentials = pika.PlainCredentials(username=self.RABBITMQ_DEFAULT_USER, - password=self.RABBITMQ_DEFAULT_PASS) - - return pika.ConnectionParameters( - host=self.get_container_host_ip(), - port=self.get_exposed_port(self.RABBITMQ_NODE_PORT), - credentials=credentials, - ) - - def start(self): - """Start the test container.""" - super().start() - self.readiness_probe() - return self diff --git a/testcontainers/redis.py b/testcontainers/redis.py deleted file mode 100644 index 749dc70e9..000000000 --- a/testcontainers/redis.py +++ /dev/null @@ -1,57 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -import redis -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready - - -class RedisContainer(DockerContainer): - def __init__(self, image="redis:latest", port_to_expose=6379, password=None, **kwargs): - super(RedisContainer, self).__init__(image, **kwargs) - self.port_to_expose = port_to_expose - self.password = password - self.with_exposed_ports(self.port_to_expose) - if self.password: - self.with_command(f"redis-server --requirepass {self.password}") - - @wait_container_is_ready(redis.exceptions.ConnectionError) - def _connect(self): - client = self.get_client() - if not client.ping(): - raise redis.exceptions.ConnectionError("Could not connect to Redis") - - def get_client(self, **kwargs): - """get redis client - - Parameters - ---------- - kwargs: dict - Keyword arguments passed to `redis.Redis`. - - Returns - ------- - client: redis.Redis - Redis client to connect to the container. - """ - return redis.Redis( - host=self.get_container_host_ip(), - port=self.get_exposed_port(self.port_to_expose), - password=self.password, - **kwargs, - ) - - def start(self): - super().start() - self._connect() - return self diff --git a/testcontainers/selenium.py b/testcontainers/selenium.py deleted file mode 100644 index f9b96753e..000000000 --- a/testcontainers/selenium.py +++ /dev/null @@ -1,77 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -""" -Selenium containers -=================== - -Allows to spin up selenium containers for testing with browsers. -""" - -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_container_is_ready -import urllib3 - - -IMAGES = { - "firefox": "selenium/standalone-firefox-debug:latest", - "chrome": "selenium/standalone-chrome-debug:latest" -} - - -def get_image_name(capabilities): - return IMAGES[capabilities['browserName']] - - -class BrowserWebDriverContainer(DockerContainer): - """ - Selenium browser container for Chrome or Firefox. - - Example - ------- - :: - - from selenium.webdriver import DesiredCapabilities - - with BrowserWebDriverContainer(DesiredCapabilities.CHROME) as chrome: - webdriver = chrome.get_driver() - webdriver.get("http://google.com") - webdriver.find_element_by_name("q").send_keys("Hello") - - You can easily change browser by passing :code:`DesiredCapabilities.FIREFOX` instead. - """ - def __init__(self, capabilities, image=None, **kwargs): - self.capabilities = capabilities - self.image = image or get_image_name(capabilities) - self.port_to_expose = 4444 - self.vnc_port_to_expose = 5900 - super(BrowserWebDriverContainer, self).__init__(image=self.image, **kwargs) - self.with_exposed_ports(self.port_to_expose, self.vnc_port_to_expose) - - def _configure(self): - self.with_env("no_proxy", "localhost") - self.with_env("HUB_ENV_no_proxy", "localhost") - - @wait_container_is_ready(urllib3.exceptions.HTTPError) - def _connect(self): - from selenium import webdriver - return webdriver.Remote( - command_executor=(self.get_connection_url()), - desired_capabilities=self.capabilities) - - def get_driver(self): - return self._connect() - - def get_connection_url(self) -> str: - ip = self.get_container_host_ip() - port = self.get_exposed_port(self.port_to_expose) - return 'http://{}:{}/wd/hub'.format(ip, port) diff --git a/tests/test_core/.env.test b/tests/test_core/.env.test deleted file mode 100644 index 84b2baafd..000000000 --- a/tests/test_core/.env.test +++ /dev/null @@ -1 +0,0 @@ -TAG_TEST_ASSERT_KEY="test_has_passed" diff --git a/tests/test_core/docker-compose-2.yml b/tests/test_core/docker-compose-2.yml deleted file mode 100644 index e360bb010..000000000 --- a/tests/test_core/docker-compose-2.yml +++ /dev/null @@ -1,6 +0,0 @@ -services: - alpine: - image: alpine - command: sleep 3600 - ports: - - "3306:3306" diff --git a/tests/test_core/docker-compose-3.yml b/tests/test_core/docker-compose-3.yml deleted file mode 100644 index 874126c7a..000000000 --- a/tests/test_core/docker-compose-3.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - alpine: - image: alpine - command: sleep 3600 - ports: - - "3306:3306" - environment: - TEST_ASSERT_KEY: ${TAG_TEST_ASSERT_KEY} diff --git a/tests/test_core/docker-compose-4.yml b/tests/test_core/docker-compose-4.yml deleted file mode 100644 index 081e598d1..000000000 --- a/tests/test_core/docker-compose-4.yml +++ /dev/null @@ -1,3 +0,0 @@ -services: - hello-world: - image: "hello-world" diff --git a/tests/test_core/docker-compose.yml b/tests/test_core/docker-compose.yml deleted file mode 100644 index 6c12ae335..000000000 --- a/tests/test_core/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - hub: - image: selenium/hub - ports: - - "4444:4444" - firefox: - image: selenium/node-firefox - links: - - hub - expose: - - "5555" - chrome: - image: selenium/node-chrome - links: - - hub - expose: - - "5555" diff --git a/tests/test_core/test_core.py b/tests/test_core/test_core.py deleted file mode 100644 index 5a6663502..000000000 --- a/tests/test_core/test_core.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest - -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_for_logs - - -def test_raise_timeout(): - with pytest.raises(TimeoutError): - with DockerContainer("alpine").with_command("sleep 2") as container: - wait_for_logs(container, "Hello from Docker!", timeout=1e-3) - - -def test_wait_for_hello(): - with DockerContainer("hello-world") as container: - wait_for_logs(container, "Hello from Docker!") - - -def test_can_get_logs(): - with DockerContainer("hello-world") as container: - wait_for_logs(container, "Hello from Docker!") - stdout, stderr = container.get_logs() - assert stdout, 'There should be something on stdout' diff --git a/tests/test_core/test_db_containers.py b/tests/test_core/test_db_containers.py deleted file mode 100644 index 0aa6640a7..000000000 --- a/tests/test_core/test_db_containers.py +++ /dev/null @@ -1,70 +0,0 @@ -import sqlalchemy -import pytest -from testcontainers.core.utils import is_arm -from testcontainers.mssql import SqlServerContainer -from testcontainers.mysql import MySqlContainer -from testcontainers.oracle import OracleDbContainer -from testcontainers.postgres import PostgresContainer - - -@pytest.mark.skipif(is_arm(), reason='mysql container not available for ARM') -def test_docker_run_mysql(): - config = MySqlContainer('mysql:5.7.17') - with config as mysql: - e = sqlalchemy.create_engine(mysql.get_connection_url()) - result = e.execute("select version()") - for row in result: - assert row[0].startswith('5.7.17') - - -def test_docker_run_postgres(): - postgres_container = PostgresContainer("postgres:9.5") - with postgres_container as postgres: - e = sqlalchemy.create_engine(postgres.get_connection_url()) - result = e.execute("select version()") - for row in result: - assert row[0].lower().startswith("postgresql 9.5") - - -def test_docker_run_postgres_with_driver_pg8000(): - postgres_container = PostgresContainer("postgres:9.5", driver="pg8000") - with postgres_container as postgres: - e = sqlalchemy.create_engine(postgres.get_connection_url()) - e.execute("select 1=1") - - -def test_docker_run_mariadb(): - with MySqlContainer("mariadb:10.6.5").maybe_emulate_amd64() as mariadb: - e = sqlalchemy.create_engine(mariadb.get_connection_url()) - result = e.execute("select version()") - for row in result: - assert row[0].startswith('10.6.5') - - -@pytest.mark.skip(reason="needs oracle client libraries unavailable on Travis") -def test_docker_run_oracle(): - with OracleDbContainer() as oracledb: - e = sqlalchemy.create_engine(oracledb.get_connection_url()) - result = e.execute("select * from V$VERSION") - versions = {'Oracle Database 11g Express Edition Release 11.2.0.2.0 - 64bit Production', - 'PL/SQL Release 11.2.0.2.0 - Production', - 'CORE\t11.2.0.2.0\tProduction', - 'TNS for Linux: Version 11.2.0.2.0 - Production', - 'NLSRTL Version 11.2.0.2.0 - Production'} - assert {row[0] for row in result} == versions - - -def test_docker_run_mssql(): - image = 'mcr.microsoft.com/azure-sql-edge' - dialect = 'mssql+pymssql' - with SqlServerContainer(image, dialect=dialect) as mssql: - e = sqlalchemy.create_engine(mssql.get_connection_url()) - result = e.execute('select @@servicename') - for row in result: - assert row[0] == 'MSSQLSERVER' - - with SqlServerContainer(image, password="1Secure*Password2", dialect=dialect) as mssql: - e = sqlalchemy.create_engine(mssql.get_connection_url()) - result = e.execute('select @@servicename') - for row in result: - assert row[0] == 'MSSQLSERVER' diff --git a/tests/test_core/test_docker_compose.py b/tests/test_core/test_docker_compose.py deleted file mode 100644 index ce8ec02ce..000000000 --- a/tests/test_core/test_docker_compose.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest.mock import patch - -import pytest - -from testcontainers.compose import DockerCompose -from testcontainers.core.docker_client import DockerClient -from testcontainers.core.exceptions import NoSuchPortExposed -from testcontainers.core.waiting_utils import wait_for_logs - - -ROOT = "tests/test_core" - - -def test_can_spawn_service_via_compose(): - with DockerCompose(ROOT) as compose: - host = compose.get_service_host("hub", 4444) - port = compose.get_service_port("hub", 4444) - assert host == "0.0.0.0" - assert port == "4444" - - -def test_can_pull_images_before_spawning_service_via_compose(): - with DockerCompose(ROOT, pull=True) as compose: - host = compose.get_service_host("hub", 4444) - port = compose.get_service_port("hub", 4444) - assert host == "0.0.0.0" - assert port == "4444" - - -def test_can_build_images_before_spawning_service_via_compose(): - with patch.object(DockerCompose, "_call_command") as call_mock: - with DockerCompose(ROOT, build=True) as compose: - ... - - assert compose.build - docker_compose_cmd = call_mock.call_args_list[0][1]["cmd"] - assert "docker-compose" in docker_compose_cmd - assert "up" in docker_compose_cmd - assert "--build" in docker_compose_cmd - - -def test_can_throw_exception_if_no_port_exposed(): - with DockerCompose(ROOT) as compose: - with pytest.raises(NoSuchPortExposed): - compose.get_service_host("hub", 5555) - - -def test_compose_wait_for_container_ready(): - with DockerCompose(ROOT) as compose: - docker = DockerClient() - compose.wait_for("http://%s:4444/wd/hub" % docker.host()) - - -def test_compose_can_wait_for_logs(): - with DockerCompose(filepath=ROOT, compose_file_name="docker-compose-4.yml") as compose: - wait_for_logs(compose, "Hello from Docker!") - - -def test_can_parse_multiple_compose_files(): - with DockerCompose(filepath=ROOT, - compose_file_name=["docker-compose.yml", "docker-compose-2.yml"]) as compose: - host = compose.get_service_host("alpine", 3306) - port = compose.get_service_port("alpine", 3306) - assert host == "0.0.0.0" - assert port == "3306" - - host = compose.get_service_host("hub", 4444) - port = compose.get_service_port("hub", 4444) - assert host == "0.0.0.0" - assert port == "4444" - - -def test_can_get_logs(): - with DockerCompose(ROOT) as compose: - docker = DockerClient() - compose.wait_for("http://%s:4444/wd/hub" % docker.host()) - stdout, stderr = compose.get_logs() - assert stdout, 'There should be something on stdout' - - -def test_can_pass_env_params_by_env_file(): - with DockerCompose(ROOT, compose_file_name='docker-compose-3.yml', - env_file='.env.test') as compose: - stdout, *_ = compose.exec_in_container("alpine", ["printenv"]) - assert stdout.splitlines()[0], 'test_has_passed' - - -def test_can_exec_commands(): - with DockerCompose(ROOT) as compose: - result = compose.exec_in_container('hub', ['echo', 'my_test']) - assert result[0] == 'my_test\n', "The echo should be successful" - assert result[1] == '', "stderr should be empty" - assert result[2] == 0, 'The exit code should be successful' diff --git a/tests/test_core/test_new_docker_api.py b/tests/test_core/test_new_docker_api.py deleted file mode 100644 index 644840b0d..000000000 --- a/tests/test_core/test_new_docker_api.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import re -from pathlib import Path - -from testcontainers import mysql -from testcontainers.core.container import DockerContainer - - -def setup_module(m): - os.environ["MYSQL_USER"] = "demo" - os.environ["MYSQL_DATABASE"] = "custom_db" - - -def test_docker_custom_image(): - container = DockerContainer("mysql:5.7.17") - container.with_exposed_ports(3306) - container.with_env("MYSQL_ROOT_PASSWORD", "root") - - with container: - port = container.get_exposed_port(3306) - assert int(port) > 0 - - -def test_docker_env_variables(): - container = mysql.MySqlContainer("mariadb:10.6.5")\ - .with_bind_ports(3306, 32785).maybe_emulate_amd64() - with container: - url = container.get_connection_url() - pattern = r'mysql\+pymysql:\/\/demo:test@[\w,.]+:(3306|32785)\/custom_db' - assert re.match(pattern, url) - - -def test_docker_kwargs(): - code_dir = Path(__file__).parent - container_first = DockerContainer("nginx:latest") - container_first.with_volume_mapping(code_dir, '/code') - - container_second = DockerContainer("nginx:latest") - - with container_first: - container_second.with_kwargs(volumes_from=[container_first._container.short_id]) - with container_second: - files_first = container_first.exec('ls /code').output.decode('utf-8').strip() - files_second = container_second.exec('ls /code').output.decode('utf-8').strip() - assert files_first == files_second diff --git a/tests/test_docker_client.py b/tests/test_docker_client.py deleted file mode 100644 index af498ff69..000000000 --- a/tests/test_docker_client.py +++ /dev/null @@ -1,26 +0,0 @@ -from unittest.mock import MagicMock, patch -import docker -from testcontainers.core.docker_client import DockerClient -from testcontainers.core.container import DockerContainer - - -def test_docker_client_from_env(): - test_kwargs = dict( - test_kw="test_value" - ) - mock_docker = MagicMock(spec=docker) - with patch("testcontainers.core.docker_client.docker", mock_docker): - DockerClient(**test_kwargs) - - mock_docker.from_env.assert_called_with(**test_kwargs) - - -def test_container_docker_client_kw(): - test_kwargs = dict( - test_kw="test_value" - ) - mock_docker = MagicMock(spec=docker) - with patch("testcontainers.core.docker_client.docker", mock_docker): - DockerContainer(image="", docker_client_kw=test_kwargs) - - mock_docker.from_env.assert_called_with(**test_kwargs) diff --git a/tests/test_elasticsearch.py b/tests/test_elasticsearch.py deleted file mode 100644 index f9f769cc5..000000000 --- a/tests/test_elasticsearch.py +++ /dev/null @@ -1,11 +0,0 @@ -import json -import urllib - -from testcontainers.elasticsearch import ElasticSearchContainer - - -def test_docker_run_elasticsearch(): - version = '7.16.1' - with ElasticSearchContainer(f'elasticsearch:{version}') as es: - resp = urllib.request.urlopen(es.get_url()) - assert json.loads(resp.read().decode())['version']['number'] == version diff --git a/tests/test_google.py b/tests/test_google.py deleted file mode 100644 index 48c920181..000000000 --- a/tests/test_google.py +++ /dev/null @@ -1,28 +0,0 @@ -from testcontainers.google import PubSubContainer -from testcontainers.core.waiting_utils import wait_for_logs -from queue import Queue - - -def test_pubsub_container(): - with PubSubContainer() as pubsub: - wait_for_logs(pubsub, r"Server started, listening on \d+", timeout=10) - # Create a new topic - publisher = pubsub.get_publisher_client() - topic_path = publisher.topic_path(pubsub.project, "my-topic") - publisher.create_topic(topic_path) - - # Create a subscription - subscriber = pubsub.get_subscriber_client() - subscription_path = subscriber.subscription_path(pubsub.project, - "my-subscription") - subscriber.create_subscription(subscription_path, topic_path) - - # Publish a message - publisher.publish(topic_path, b"Hello world!") - - # Receive the message - queue = Queue() - subscriber.subscribe(subscription_path, queue.put) - message = queue.get(timeout=1) - assert message.data == b"Hello world!" - message.ack() diff --git a/tests/test_kafka.py b/tests/test_kafka.py deleted file mode 100644 index 4efda4377..000000000 --- a/tests/test_kafka.py +++ /dev/null @@ -1,30 +0,0 @@ -from kafka import KafkaConsumer, KafkaProducer, TopicPartition -from testcontainers.kafka import KafkaContainer - - -def test_kafka_producer_consumer(): - with KafkaContainer() as container: - produce_and_consume_kafka_message(container) - - -def test_kafka_producer_consumer_custom_port(): - with KafkaContainer(port_to_expose=9888) as container: - assert container.port_to_expose == 9888 - produce_and_consume_kafka_message(container) - - -def produce_and_consume_kafka_message(container): - topic = 'test-topic' - bootstrap_server = container.get_bootstrap_server() - - producer = KafkaProducer(bootstrap_servers=[bootstrap_server]) - producer.send(topic, b"verification message") - producer.flush() - producer.close() - - consumer = KafkaConsumer(bootstrap_servers=[bootstrap_server]) - tp = TopicPartition(topic, 0) - consumer.assign([tp]) - consumer.seek_to_beginning() - assert consumer.end_offsets([tp])[tp] == 1, \ - "Expected exactly one test message to be present on test topic !" diff --git a/tests/test_keycloak.py b/tests/test_keycloak.py deleted file mode 100644 index 8e764130e..000000000 --- a/tests/test_keycloak.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from testcontainers.keycloak import KeycloakContainer - - -@pytest.mark.parametrize(["version"], [("16.1.1", )]) -def test_docker_run_keycloak(version): - with KeycloakContainer('jboss/keycloak:{}'.format(version)) as kc: - kc.get_client().users_count() diff --git a/tests/test_localstack.py b/tests/test_localstack.py deleted file mode 100644 index 8650a5a3c..000000000 --- a/tests/test_localstack.py +++ /dev/null @@ -1,15 +0,0 @@ -import json -import urllib - -from testcontainers.localstack import LocalStackContainer - - -def test_docker_run_localstack(): - with LocalStackContainer() as localstack: - resp = urllib.request.urlopen('{}/health'.format(localstack.get_url())) - services = json.loads(resp.read().decode())['services'] - - # Check that all services are running - assert all(value == 'running' for value in services.values()) - # Check that some of the services keys - assert all(test_service in services.keys() for test_service in ['dynamodb', 'sns', 'sqs']) diff --git a/tests/test_mongodb.py b/tests/test_mongodb.py deleted file mode 100644 index a1e03e660..000000000 --- a/tests/test_mongodb.py +++ /dev/null @@ -1,62 +0,0 @@ -from pymongo import MongoClient -from pymongo.errors import OperationFailure -import pytest -from testcontainers.core.container import DockerContainer -from testcontainers.core.waiting_utils import wait_for -from testcontainers.mongodb import MongoDbContainer - - -def test_docker_generic_db(): - with DockerContainer("mongo:latest").with_bind_ports(27017, 27017) as mongo_container: - def connect(): - return MongoClient("mongodb://{}:{}".format(mongo_container.get_container_host_ip(), - mongo_container.get_exposed_port(27017))) - - db = wait_for(connect).primer - result = db.restaurants.insert_one( - { - "address": { - "street": "2 Avenue", - "zipcode": "10075", - "building": "1480", - "coord": [-73.9557413, 40.7720266] - }, - "borough": "Manhattan", - "cuisine": "Italian", - "name": "Vella", - "restaurant_id": "41704620" - } - ) - assert result.inserted_id - cursor = db.restaurants.find({"borough": "Manhattan"}) - for document in cursor: - assert document - - -def test_docker_run_mongodb(): - with MongoDbContainer("mongo:latest") as mongo: - db = mongo.get_connection_client().test - doc = { - "address": { - "street": "2 Avenue", - "zipcode": "10075", - "building": "1480", - "coord": [-73.9557413, 40.7720266] - }, - "borough": "Manhattan", - "cuisine": "Italian", - "name": "Vella", - "restaurant_id": "41704620" - } - db.restaurants.insert_one(doc) - cursor = db.restaurants.find({"borough": "Manhattan"}) - assert cursor.next()['restaurant_id'] == doc['restaurant_id'] - - -def test_docker_run_mongodb_connect_without_credentials(): - with MongoDbContainer() as mongo: - connection_url = "mongodb://{}:{}".format(mongo.get_container_host_ip(), - mongo.get_exposed_port(mongo.port_to_expose)) - db = MongoClient(connection_url).test - with pytest.raises(OperationFailure): - db.restaurants.insert_one({}) diff --git a/tests/test_neo4j.py b/tests/test_neo4j.py deleted file mode 100644 index 8c90ca0e9..000000000 --- a/tests/test_neo4j.py +++ /dev/null @@ -1,16 +0,0 @@ -from testcontainers.neo4j import Neo4jContainer - - -def test_docker_run_neo4j_latest(): - with Neo4jContainer() as neo4j: - with neo4j.get_driver() as driver: - with driver.session() as session: - result = session.run( - """ - CALL dbms.components() - YIELD name, versions, edition - UNWIND versions as version - RETURN name, version, edition - """) - record = result.single() - assert record["name"].startswith("Neo4j") diff --git a/tests/test_nginx.py b/tests/test_nginx.py deleted file mode 100644 index e2af43932..000000000 --- a/tests/test_nginx.py +++ /dev/null @@ -1,14 +0,0 @@ -import requests - -from testcontainers.nginx import NginxContainer - - -def test_docker_run_nginx(): - nginx_container = NginxContainer("nginx:1.13.8") - with nginx_container as nginx: - port = nginx.port_to_expose - url = "http://{}:{}/".format(nginx.get_container_host_ip(), - nginx.get_exposed_port(port)) - r = requests.get(url) - assert(r.status_code == 200) - assert('Welcome to nginx!' in r.text) diff --git a/tests/test_redis.py b/tests/test_redis.py deleted file mode 100644 index 9bf946442..000000000 --- a/tests/test_redis.py +++ /dev/null @@ -1,36 +0,0 @@ -import time - -from testcontainers.redis import RedisContainer - - -def test_docker_run_redis(): - config = RedisContainer() - with config as redis: - client = redis.get_client() - p = client.pubsub() - p.subscribe('test') - client.publish('test', 'new_msg') - msg = wait_for_message(p) - assert 'data' in msg - assert b'new_msg', msg['data'] - - -def test_docker_run_redis_with_password(): - config = RedisContainer(password="mypass") - with config as redis: - client = redis.get_client(decode_responses=True) - client.set("hello", "world") - assert client.get("hello") == "world" - - -def wait_for_message(pubsub, timeout=1, ignore_subscribe_messages=True): - now = time.time() - timeout = now + timeout - while now < timeout: - message = pubsub.get_message( - ignore_subscribe_messages=ignore_subscribe_messages) - if message is not None: - return message - time.sleep(0.01) - now = time.time() - return None diff --git a/tests/test_selenium.py b/tests/test_selenium.py deleted file mode 100644 index 9958ecff4..000000000 --- a/tests/test_selenium.py +++ /dev/null @@ -1,9 +0,0 @@ - -def test_selenium_custom_image(): - from testcontainers.selenium import BrowserWebDriverContainer - from selenium.webdriver import DesiredCapabilities - - image = "selenium/standalone-chrome:latest" - chrome = BrowserWebDriverContainer(DesiredCapabilities.CHROME, image=image) - assert "image" in dir(chrome), "`image` attribute was not instantialized." - assert chrome.image == image, "`image` attribute was not set to the user provided value" diff --git a/tests/test_webdriver.py b/tests/test_webdriver.py deleted file mode 100644 index e7282aa1b..000000000 --- a/tests/test_webdriver.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest -from selenium.webdriver import DesiredCapabilities -from testcontainers.selenium import BrowserWebDriverContainer -from testcontainers.core.utils import is_arm - - -@pytest.mark.parametrize("caps", [DesiredCapabilities.CHROME, DesiredCapabilities.FIREFOX]) -def test_webdriver_container_container(caps): - if is_arm(): - pytest.skip('https://github.com/SeleniumHQ/docker-selenium/issues/1076') - - with BrowserWebDriverContainer(caps).maybe_emulate_amd64() as chrome: - webdriver = chrome.get_driver() - webdriver.get("http://google.com") - webdriver.find_element_by_name("q").send_keys("Hello")