├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .readthedocs.yaml ├── .travis.yml ├── AUTHORS.rst ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── Makefile ├── _static │ └── logo.png ├── conf.py ├── index.rst ├── install.rst ├── make.bat └── usage.rst ├── houseplant.png ├── pyproject.toml ├── src └── houseplant │ ├── __init__.py │ ├── __version__.py │ ├── cli.py │ ├── clickhouse_client.py │ ├── houseplant.py │ └── utils.py ├── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_clickhouse_client.py └── test_houseplant.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * houseplant version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.x" 18 | - name: Install pypa/build 19 | run: >- 20 | python3 -m 21 | pip install 22 | build 23 | --user 24 | - name: Build a binary wheel and a source tarball 25 | run: python3 -m build 26 | - name: Store the distribution packages 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: python-package-distributions 30 | path: dist/ 31 | 32 | publish-to-pypi: 33 | name: >- 34 | Publish Python 🐍 distribution 📦 to PyPI 35 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | environment: 40 | name: pypi 41 | url: https://pypi.org/p/houseplant 42 | permissions: 43 | id-token: write # IMPORTANT: mandatory for trusted publishing 44 | 45 | steps: 46 | - name: Download all the dists 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: python-package-distributions 50 | path: dist/ 51 | - name: Publish distribution 📦 to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | 54 | github-release: 55 | name: >- 56 | Sign the Python 🐍 distribution 📦 with Sigstore 57 | and upload them to GitHub Release 58 | needs: 59 | - publish-to-pypi 60 | runs-on: ubuntu-latest 61 | 62 | permissions: 63 | contents: write # IMPORTANT: mandatory for making GitHub Releases 64 | id-token: write # IMPORTANT: mandatory for sigstore 65 | 66 | steps: 67 | - name: Download all the dists 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | - name: Sign the dists with Sigstore 73 | uses: sigstore/gh-action-sigstore-python@v3.0.0 74 | with: 75 | inputs: >- 76 | ./dist/*.tar.gz 77 | ./dist/*.whl 78 | - name: Create GitHub Release 79 | env: 80 | GITHUB_TOKEN: ${{ github.token }} 81 | run: >- 82 | gh release create 83 | "$GITHUB_REF_NAME" 84 | --repo "$GITHUB_REPOSITORY" 85 | --notes "" 86 | - name: Upload artifact signatures to GitHub Release 87 | env: 88 | GITHUB_TOKEN: ${{ github.token }} 89 | # Upload to GitHub Release using the `gh` CLI. 90 | # `dist/` contains the built packages, and the 91 | # sigstore-produced signatures and certificates. 92 | run: >- 93 | gh release upload 94 | "$GITHUB_REF_NAME" dist/** 95 | --repo "$GITHUB_REPOSITORY" 96 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | clickhouse: 14 | image: clickhouse/clickhouse-server:latest 15 | ports: 16 | - 9000:9000 17 | options: >- 18 | --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1" 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 27 | 28 | env: 29 | UV_PYTHON: ${{ matrix.python-version }} 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: astral-sh/setup-uv@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | - name: Install dependencies 38 | run: uv sync --all-extras 39 | 40 | - name: Check formatting with Ruff 41 | run: ruff check 42 | 43 | - name: Run tests 44 | run: pytest tests/ -v --cov=houseplant 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # Dask worker cache 75 | dask-worker-space/ 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # IDE settings 108 | .vscode/ 109 | .idea/ 110 | 111 | # Houseplant 112 | ch/ 113 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # Optionally, but recommended, 18 | # declare the Python requirements required to build your documentation 19 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - method: pip 23 | path: . 24 | extra_requirements: 25 | - dev 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.com 2 | 3 | language: python 4 | python: 5 | - 3.8 6 | - 3.7 7 | - 3.6 8 | 9 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 10 | install: pip install -U tox-travis 11 | 12 | # Command to run tests, e.g. python setup.py test 13 | script: tox 14 | 15 | 16 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * June 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Contributor Covenant Code of Conduct 3 | ==================================== 4 | 5 | Our Pledge 6 | ---------- 7 | 8 | In the interest of fostering an open and welcoming environment, we as 9 | contributors and maintainers pledge to make participation in our project and 10 | our community a harassment-free experience for everyone, regardless of age, body 11 | size, disability, ethnicity, sex characteristics, gender identity and expression, 12 | level of experience, education, socio-economic status, nationality, personal 13 | appearance, race, religion, or sexual identity and orientation. 14 | 15 | Our Standards 16 | ------------- 17 | 18 | Examples of behavior that contributes to creating a positive environment 19 | include: 20 | 21 | * Using welcoming and inclusive language 22 | * Being respectful of differing viewpoints and experiences 23 | * Gracefully accepting constructive criticism 24 | * Focusing on what is best for the community 25 | * Showing empathy towards other community members 26 | 27 | Examples of unacceptable behavior by participants include: 28 | 29 | * The use of sexualized language or imagery and unwelcome sexual attention or 30 | advances 31 | * Trolling, insulting/derogatory comments, and personal or political attacks 32 | * Public or private harassment 33 | * Publishing others' private information, such as a physical or electronic 34 | address, without explicit permission 35 | * Other conduct which could reasonably be considered inappropriate in a 36 | professional setting 37 | 38 | Our Responsibilities 39 | -------------------- 40 | 41 | Project maintainers are responsible for clarifying the standards of acceptable 42 | behavior and are expected to take appropriate and fair corrective action in 43 | response to any instances of unacceptable behavior. 44 | 45 | Project maintainers have the right and responsibility to remove, edit, or 46 | reject comments, commits, code, wiki edits, issues, and other contributions 47 | that are not aligned to this Code of Conduct, or to ban temporarily or 48 | permanently any contributor for other behaviors that they deem inappropriate, 49 | threatening, offensive, or harmful. 50 | 51 | Scope 52 | ----- 53 | 54 | This Code of Conduct applies within all project spaces, and it also applies when 55 | an individual is representing the project or its community in public spaces. 56 | Examples of representing a project or community include using an official 57 | project e-mail address, posting via an official social media account, or acting 58 | as an appointed representative at an online or offline event. Representation of 59 | a project may be further defined and clarified by project maintainers. 60 | 61 | Enforcement 62 | ----------- 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 66 | complaints will be reviewed and investigated and will result in a response that 67 | is deemed necessary and appropriate to the circumstances. The project team is 68 | obligated to maintain confidentiality with regard to the reporter of an incident. 69 | Further details of specific enforcement policies may be posted separately. 70 | 71 | Project maintainers who do not follow or enforce the Code of Conduct in good 72 | faith may face temporary or permanent repercussions as determined by other 73 | members of the project's leadership. 74 | 75 | Attribution 76 | ----------- 77 | 78 | This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.4, 79 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 80 | 81 | For answers to common questions about this code of conduct, see 82 | https://www.contributor-covenant.org/faq 83 | 84 | .. _`Contributor Covenant`: https://www.contributor-covenant.org 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/juneHQ/houseplant/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | houseplant could always use more documentation, whether as part of the 42 | official houseplant docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/juneHQ/houseplant/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `houseplant` for local development. 61 | 62 | 1. Fork the `houseplant` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/houseplant.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv houseplant 70 | $ cd houseplant/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ make lint 83 | $ make test 84 | Or 85 | $ make test-all 86 | 87 | To get flake8 and tox, just pip install them into your virtualenv. 88 | 89 | 6. Commit your changes and push your branch to GitHub:: 90 | 91 | $ git add . 92 | $ git commit -m "Your detailed description of your changes." 93 | $ git push origin name-of-your-bugfix-or-feature 94 | 95 | 7. Submit a pull request through the GitHub website. 96 | 97 | Pull Request Guidelines 98 | ----------------------- 99 | 100 | Before you submit a pull request, check that it meets these guidelines: 101 | 102 | 1. The pull request should include tests. 103 | 2. If the pull request adds functionality, the docs should be updated. Put 104 | your new functionality into a function with a docstring, and add the 105 | feature to the list in README.rst. 106 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 107 | https://travis-ci.com/juneHQ/houseplant/pull_requests 108 | and make sure that the tests pass for all supported Python versions. 109 | 110 | Tips 111 | ---- 112 | 113 | To run a subset of tests:: 114 | 115 | $ pytest tests.test_houseplant 116 | 117 | 118 | Deploying 119 | --------- 120 | 121 | A reminder for the maintainers on how to deploy. 122 | Make sure all your changes are committed (including an entry in HISTORY.rst). 123 | Then run:: 124 | 125 | $ bump2version patch # possible: major / minor / patch 126 | $ git push 127 | $ git push --tags 128 | 129 | Travis will then deploy to PyPI if tests pass. 130 | 131 | Code of Conduct 132 | --------------- 133 | 134 | Please note that this project is released with a `Contributor Code of Conduct`_. 135 | By participating in this project you agree to abide by its terms. 136 | 137 | .. _`Contributor Code of Conduct`: CODE_OF_CONDUCT.rst 138 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.2.5 (2025-02-27) 6 | ------------------ 7 | 8 | * Add `CLICKHOUSE_VERIFY` environment variable. 9 | 10 | 0.2.4 (2025-01-24) 11 | ------------------ 12 | 13 | * Fix init command. 14 | * Fix `CLICKHOUSE_DB` variable name in docs. 15 | 16 | 0.2.3 (2025-01-12) 17 | ------------------ 18 | 19 | * Add support for query settings in migrations. 20 | 21 | 0.2.2 (2024-12-09) 22 | ------------------ 23 | 24 | * Add support for `.env` files in the current directory. 25 | 26 | 0.2.1 (2024-12-06) 27 | ------------------ 28 | 29 | * Add `CLICKHOUSE_SECURE` environment variable. 30 | 31 | 0.2.0 (2024-12-06) 32 | ------------------ 33 | 34 | * Fix schema generation. 35 | * Add --version arg to CLI. 36 | * Add `db:schema:load` command. 37 | * Add better error handling for ClickHouse connection issues. 38 | 39 | 0.1.1 (2024-12-03) 40 | ------------------ 41 | 42 | * First release on PyPI. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, June 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | define BROWSER_PYSCRIPT 6 | import os, webbrowser, sys 7 | 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | 14 | define PRINT_HELP_PYSCRIPT 15 | import re, sys 16 | 17 | for line in sys.stdin: 18 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 19 | if match: 20 | target, help = match.groups() 21 | print("%-20s %s" % (target, help)) 22 | endef 23 | export PRINT_HELP_PYSCRIPT 24 | 25 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 26 | 27 | help: 28 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 29 | 30 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 31 | 32 | clean-build: ## remove build artifacts 33 | rm -fr build/ 34 | rm -fr dist/ 35 | rm -fr .eggs/ 36 | find . -name '*.egg-info' -exec rm -fr {} + 37 | find . -name '*.egg' -exec rm -f {} + 38 | 39 | clean-pyc: ## remove Python file artifacts 40 | find . -name '*.pyc' -exec rm -f {} + 41 | find . -name '*.pyo' -exec rm -f {} + 42 | find . -name '*~' -exec rm -f {} + 43 | find . -name '__pycache__' -exec rm -fr {} + 44 | 45 | clean-test: ## remove test and coverage artifacts 46 | rm -fr .tox/ 47 | rm -f .coverage 48 | rm -fr htmlcov/ 49 | rm -fr .pytest_cache 50 | 51 | lint/flake8: ## check style with flake8 52 | flake8 houseplant tests 53 | 54 | 55 | lint: lint/flake8 ## check style 56 | 57 | test: ## run tests quickly with the default Python 58 | pytest 59 | 60 | test-all: ## run tests on every Python version with tox 61 | tox 62 | 63 | coverage: ## check code coverage quickly with the default Python 64 | coverage run --source houseplant -m pytest 65 | coverage report -m 66 | coverage html 67 | $(BROWSER) htmlcov/index.html 68 | 69 | docs: ## generate Sphinx HTML documentation, including API docs 70 | rm -f docs/houseplant.rst 71 | rm -f docs/modules.rst 72 | sphinx-apidoc -o docs/ houseplant 73 | $(MAKE) -C docs clean 74 | $(MAKE) -C docs html 75 | $(BROWSER) docs/_build/html/index.html 76 | 77 | servedocs: docs ## compile the docs watching for changes 78 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 79 | 80 | release: dist ## package and upload a release 81 | twine upload dist/* 82 | 83 | dist: clean ## builds source and wheel package 84 | python setup.py sdist 85 | python setup.py bdist_wheel 86 | ls -l dist 87 | 88 | install: clean ## install the package to the active Python's site-packages 89 | python setup.py install 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Houseplant: Database Migrations for ClickHouse 6 | 7 | [![PyPI version](https://img.shields.io/pypi/v/houseplant.svg)](https://pypi.python.org/pypi/houseplant) 8 | [![image](https://img.shields.io/pypi/l/houseplant.svg)](https://pypi.org/project/houseplant/) 9 | [![image](https://img.shields.io/pypi/pyversions/houseplant.svg)](https://pypi.org/project/houseplant/) 10 | 11 | **Houseplant** is a CLI tool that helps you manage database migrations for ClickHouse. 12 | 13 | --- 14 | 15 | **Here's how you can manage your ClickHouse migrations.** 16 | 17 |
18 | $ houseplant init
19 | ✨ Project initialized successfully!
20 | 
21 | $ houseplant generate "add events"
22 | ✨ Generated migration: ch/migrations/20240101000000_add_events.yml
23 | 
24 | $ houseplant migrate:status
25 | Database: june_development
26 | 
27 | ┏━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
28 | ┃ Status ┃ Migration ID   ┃ Migration Name ┃
29 | ┡━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
30 | │   up   │ 20240101000000 │ add events     │
31 | └────────┴────────────────┴────────────────┘
32 | 
33 | $ houseplant migrate
34 | ✓ Applied migration 20241121003230_add_events.yml
35 | 
36 | $ houseplant migrate:up VERSION=20241121003230
37 | ✓ Applied migration 20241121003230_add_events.yml
38 | 
39 | $ houseplant migrate:down VERSION=20241121003230
40 | ✓ Rolled back migration 20241121003230_add_events.yml
41 | 
42 | 43 | ## Why Houseplant? 44 | 45 | - **Schema Management**: Houseplant automatically tracks and manages your ClickHouse schema changes, making it easy to evolve your data model over time 46 | - **Developer Experience**: Write migrations in YAML format, making them easy to read, review, and maintain 47 | - **Environment Support**: Different configurations for development, testing, and production environments 48 | - **Rich CLI**: Comes with an intuitive command-line interface for all migration operations 49 | 50 | ## Installation 51 | 52 | You can install Houseplant using pip: 53 | 54 |
55 | $ pip install houseplant
56 | 
57 | 58 | ## Configuration 59 | 60 | Houseplant uses the following environment variables to connect to your ClickHouse instance: 61 | 62 | - `HOUSEPLANT_ENV`: The current environment 63 | - `CLICKHOUSE_HOST`: Host address of your ClickHouse server (default: "localhost") 64 | - `CLICKHOUSE_PORT`: Port number for ClickHouse (default: 9000) 65 | - `CLICKHOUSE_DB`: Database name (default: "development") 66 | - `CLICKHOUSE_USER`: Username for authentication (default: "default") 67 | - `CLICKHOUSE_PASSWORD`: Password for authentication (default: "") 68 | - `CLICKHOUSE_SECURE`: Enable secure connection via the `secure` flag of ClickHouse client (default: False) 69 | - `CLICKHOUSE_VERIFY`: Enable certificate verifiaction `verify` flag of ClickHouse client (default: False) 70 | 71 | ## Contributing 72 | 73 | Contributions are welcome! Please feel free to submit a Pull Request. 74 | 75 | ## License 76 | 77 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 78 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = houseplant 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juneHQ/houseplant/8511058682363e0a1a380721500ff7037fbf2957/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # houseplant documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | import tomli 24 | 25 | sys.path.insert(0, os.path.abspath("..")) 26 | 27 | # Read version from pyproject.toml 28 | with open("../pyproject.toml", "rb") as f: 29 | pyproject = tomli.load(f) 30 | 31 | # -- General configuration --------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # 35 | # needs_sphinx = '1.0' 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 39 | extensions = [ 40 | "sphinx.ext.autodoc", 41 | "sphinx.ext.viewcode", 42 | "sphinx.ext.napoleon", 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ["_templates"] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = ".rst" 53 | 54 | # The master toctree document. 55 | master_doc = "index" 56 | 57 | # General information about the project. 58 | project = "houseplant" 59 | copyright = "2024, June" 60 | author = "June" 61 | 62 | # The version info for the project you're documenting, acts as replacement 63 | # for |version| and |release|, also used in various other places throughout 64 | # the built documents. 65 | # 66 | # The short X.Y version. 67 | version = pyproject["project"]["version"] 68 | # The full version, including alpha/beta/rc tags. 69 | release = version 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = "en" 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | # This patterns also effect to html_static_path and html_extra_path 81 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = "sphinx" 85 | 86 | # If true, `todo` and `todoList` produce output, else they produce nothing. 87 | todo_include_todos = False 88 | 89 | 90 | # -- Options for HTML output ------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | # 95 | html_theme = "alabaster" 96 | 97 | html_logo = "_static/logo.png" 98 | 99 | # Theme options are theme-specific and customize the look and feel of a 100 | # theme further. For a list of options available for each theme, see the 101 | # documentation. 102 | # 103 | html_theme_options = { 104 | "logo_text_align": "center", 105 | "github_user": "juneHQ", 106 | "github_repo": "houseplant", 107 | "github_button": True, 108 | "github_type": "star", 109 | "fixed_sidebar": True, 110 | "show_powered_by": False, 111 | "show_relbars": True, 112 | } 113 | 114 | # Add any paths that contain custom static files (such as style sheets) here, 115 | # relative to this directory. They are copied after the builtin static files, 116 | # so a file named "default.css" will overwrite the builtin "default.css". 117 | html_static_path = ["_static"] 118 | 119 | 120 | # -- Options for HTMLHelp output --------------------------------------- 121 | 122 | # Output file base name for HTML help builder. 123 | htmlhelp_basename = "houseplantdoc" 124 | 125 | 126 | # -- Options for LaTeX output ------------------------------------------ 127 | 128 | latex_elements = { 129 | # The paper size ('letterpaper' or 'a4paper'). 130 | # 131 | # 'papersize': 'letterpaper', 132 | # The font size ('10pt', '11pt' or '12pt'). 133 | # 134 | # 'pointsize': '10pt', 135 | # Additional stuff for the LaTeX preamble. 136 | # 137 | # 'preamble': '', 138 | # Latex figure (float) alignment 139 | # 140 | # 'figure_align': 'htbp', 141 | } 142 | 143 | # Grouping the document tree into LaTeX files. List of tuples 144 | # (source start file, target name, title, author, documentclass 145 | # [howto, manual, or own class]). 146 | latex_documents = [ 147 | (master_doc, "houseplant.tex", "houseplant Documentation", "June", "manual"), 148 | ] 149 | 150 | 151 | # -- Options for manual page output ------------------------------------ 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [(master_doc, "houseplant", "houseplant Documentation", [author], 1)] 156 | 157 | 158 | # -- Options for Texinfo output ---------------------------------------- 159 | 160 | # Grouping the document tree into Texinfo files. List of tuples 161 | # (source start file, target name, title, author, 162 | # dir menu entry, description, category) 163 | texinfo_documents = [ 164 | ( 165 | master_doc, 166 | "houseplant", 167 | "houseplant Documentation", 168 | author, 169 | "houseplant", 170 | "Database Migrations for ClickHouse", 171 | "Miscellaneous", 172 | ), 173 | ] 174 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Houseplant: Database Migrations for ClickHouse 2 | ============================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/houseplant.svg 5 | :target: https://pypi.python.org/pypi/houseplant 6 | 7 | .. image:: https://img.shields.io/pypi/l/houseplant.svg 8 | :target: https://pypi.org/project/houseplant/ 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/houseplant.svg 11 | :target: https://pypi.org/project/houseplant/ 12 | 13 | Houseplant is a CLI tool that helps you manage database migrations for ClickHouse. 14 | 15 | Here's how you can manage your ClickHouse migrations: 16 | 17 | .. code-block:: console 18 | 19 | $ houseplant init 20 | ✨ Project initialized successfully! 21 | 22 | $ houseplant generate "add events" 23 | ✨ Generated migration: ch/migrations/20240101000000_add_events.yml 24 | 25 | $ houseplant migrate:status 26 | Database: june_development 27 | 28 | ┏━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ 29 | ┃ Status ┃ Migration ID ┃ Migration Name ┃ 30 | ┡━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩ 31 | │ up │ 20240101000000 │ add events │ 32 | └────────┴────────────────┴────────────────┘ 33 | 34 | $ houseplant migrate 35 | ✓ Applied migration 20241121003230_add_events.yml 36 | 37 | $ houseplant migrate:up VERSION=20241121003230 38 | ✓ Applied migration 20241121003230_add_events.yml 39 | 40 | $ houseplant migrate:down VERSION=20241121003230 41 | ✓ Rolled back migration 20241121003230_add_events.yml 42 | 43 | Why Houseplant? 44 | --------------- 45 | 46 | - **Schema Management**: Houseplant automatically tracks and manages your ClickHouse schema changes, making it easy to evolve your data model over time 47 | - **Developer Experience**: Write migrations in YAML format, making them easy to read, review, and maintain 48 | - **Environment Support**: Different configurations for development, testing, and production environments 49 | - **Rich CLI**: Comes with an intuitive command-line interface for all migration operations 50 | 51 | The User Guide 52 | -------------- 53 | 54 | .. toctree:: 55 | :maxdepth: 2 56 | 57 | install 58 | usage 59 | 60 | Indices and tables 61 | ================== 62 | * :ref:`genindex` 63 | * :ref:`modindex` 64 | * :ref:`search` 65 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | You can install Houseplant using pip: 8 | 9 | .. code-block:: console 10 | 11 | $ pip install houseplant 12 | 13 | From Source 14 | ----------- 15 | 16 | You can clone the `Github repo`_: 17 | 18 | .. code-block:: console 19 | 20 | $ git clone git://github.com/juneHQ/houseplant 21 | 22 | And then, you can install it with: 23 | 24 | .. code-block:: console 25 | 26 | $ pip install -e . 27 | 28 | .. _Github repo: https://github.com/yourusername/houseplant 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=houseplant 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Houseplant is a CLI tool that helps you manage database migrations for ClickHouse. 6 | 7 | Basic Commands 8 | -------------- 9 | 10 | Initialize a New Project 11 | ~~~~~~~~~~~~~~~~~~~~~~~~ 12 | 13 | To create a new Houseplant project:: 14 | 15 | $ houseplant init 16 | 17 | This will create the following structure:: 18 | 19 | ch/ 20 | ├── migrations/ 21 | └── schema.sql 22 | 23 | Generate a Migration 24 | ~~~~~~~~~~~~~~~~~~~~ 25 | 26 | To generate a new migration:: 27 | 28 | $ houseplant generate "create users table" 29 | 30 | This will create a new migration file in ``ch/migrations/`` with a timestamp prefix, for example:: 31 | 32 | ch/migrations/20240320123456_create_users_table.yml 33 | 34 | Migration File Structure 35 | ~~~~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | Migration files use YAML format and support different environments:: 38 | 39 | version: "20240320123456" 40 | name: create_users_table 41 | table: users 42 | 43 | development: &development 44 | up: | 45 | CREATE TABLE {table} ( 46 | id UInt64, 47 | name String 48 | ) ENGINE = MergeTree() 49 | ORDER BY id 50 | down: | 51 | DROP TABLE {table} 52 | 53 | test: 54 | <<: *development 55 | 56 | production: 57 | up: | 58 | CREATE TABLE ON CLUSTER '{cluster}' {table} ( 59 | id UInt64, 60 | name String 61 | ) ENGINE = ReplicatedMergeTree() 62 | ORDER BY id 63 | down: | 64 | DROP TABLE ON CLUSTER '{cluster}' {table} 65 | 66 | Running Migrations 67 | ------------------ 68 | 69 | Check Migration Status 70 | ~~~~~~~~~~~~~~~~~~~~~~ 71 | 72 | To see the status of all migrations:: 73 | 74 | $ houseplant migrate:status 75 | 76 | This will show which migrations have been applied and which are pending. 77 | 78 | Apply Migrations 79 | ~~~~~~~~~~~~~~~~ 80 | 81 | To run all pending migrations:: 82 | 83 | $ houseplant migrate:up 84 | 85 | To migrate to a specific version:: 86 | 87 | $ houseplant migrate:up VERSION=20240320123456 88 | 89 | Rollback Migrations 90 | ~~~~~~~~~~~~~~~~~~~ 91 | 92 | To roll back the last migration:: 93 | 94 | $ houseplant migrate:down 95 | 96 | To roll back to a specific version:: 97 | 98 | $ houseplant migrate:down VERSION=20240320123456 99 | 100 | Schema Management 101 | ----------------- 102 | 103 | Load Schema 104 | ~~~~~~~~~~~ 105 | 106 | To load existing schema migrations into the database without applying them:: 107 | 108 | $ houseplant db:schema:load 109 | 110 | This is useful when setting up a new environment where the database and tables already exist. 111 | 112 | Update Schema 113 | ~~~~~~~~~~~~~ 114 | 115 | The schema file (``ch/schema.sql``) is automatically updated after each migration. It contains: 116 | 117 | - Table definitions 118 | - Materialized view definitions 119 | - Dictionary definitions 120 | 121 | Environment Support 122 | ------------------- 123 | 124 | Houseplant supports different environments through the ``HOUSEPLANT_ENV`` environment variable: 125 | 126 | - development (default) 127 | - test 128 | - production 129 | 130 | Set the environment before running commands:: 131 | 132 | $ HOUSEPLANT_ENV=production houseplant migrate 133 | 134 | Configuration 135 | ------------- 136 | 137 | Houseplant uses the following environment variables: 138 | 139 | - ``HOUSEPLANT_ENV``: The current environment (default: "development") 140 | - ``CLICKHOUSE_HOST``: ClickHouse server host 141 | - ``CLICKHOUSE_PORT``: ClickHouse server port 142 | - ``CLICKHOUSE_USER``: ClickHouse username 143 | - ``CLICKHOUSE_PASSWORD``: ClickHouse password 144 | - ``CLICKHOUSE_DB``: ClickHouse database name 145 | -------------------------------------------------------------------------------- /houseplant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juneHQ/houseplant/8511058682363e0a1a380721500ff7037fbf2957/houseplant.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "houseplant" 3 | version = "0.2.5" 4 | description = "Database Migrations for ClickHouse." 5 | readme = "README.md" 6 | authors = [{name = "June", email = "eng@june.so"}] 7 | maintainers = [{name = "June", email = "eng@june.so"}] 8 | license = {text = "MIT license"} 9 | classifiers = [ 10 | "License :: OSI Approved :: MIT License", 11 | "Topic :: Database", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | ] 17 | dependencies = [ 18 | "clickhouse-driver >= 0.2.9, < 0.3", 19 | "pyyaml >= 6.0.2, < 6.1", 20 | "typer >= 0.14.0, < 0.15", 21 | "python-dotenv >= 1.0.1, < 1.1", 22 | ] 23 | 24 | [build-system] 25 | requires = ["setuptools"] 26 | build-backend = "setuptools.build_meta" 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/juneHQ/houseplant" 30 | Changelog = "https://github.com/juneHQ/houseplant/releases" 31 | Issues = "https://github.com/juneHQ/houseplant/issues" 32 | CI = "https://github.com/juneHQ/houseplant/actions" 33 | 34 | [project.scripts] 35 | houseplant = "houseplant.cli:app" 36 | 37 | [project.optional-dependencies] 38 | dev = [ 39 | "ruff==0.8.6", # linting 40 | "pytest==8.3.4", # testing 41 | "pytest-cov==6.0.0", # testing 42 | "pytest-mock==3.14.0", # testing 43 | "sphinx", # documentation 44 | "tomli", # documentation 45 | ] 46 | -------------------------------------------------------------------------------- /src/houseplant/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for houseplant.""" 2 | 3 | __all__ = ["__version__", "Houseplant"] 4 | 5 | from .__version__ import __version__ 6 | from .houseplant import Houseplant 7 | -------------------------------------------------------------------------------- /src/houseplant/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 2, 5) 2 | PRERELEASE = None # alpha, beta or rc 3 | REVISION = None 4 | 5 | 6 | def generate_version(version, prerelease=None, revision=None): 7 | version_parts = [".".join(map(str, version))] 8 | if prerelease is not None: 9 | version_parts.append(f"-{prerelease}") 10 | if revision is not None: 11 | version_parts.append(f".{revision}") 12 | return "".join(version_parts) 13 | 14 | 15 | __title__ = "houseplant" 16 | __description__ = "Database Migrations for ClickHouse." 17 | __url__ = "https://github.com/juneHQ/houseplant" 18 | __version__ = generate_version(VERSION, prerelease=PRERELEASE, revision=REVISION) 19 | __author__ = "June" 20 | __author_email__ = "eng@june.so" 21 | __license__ = "MIT License" 22 | -------------------------------------------------------------------------------- /src/houseplant/cli.py: -------------------------------------------------------------------------------- 1 | """Console script for houseplant.""" 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | import typer 8 | from dotenv import load_dotenv 9 | from rich.console import Console 10 | 11 | from houseplant import Houseplant, __version__ 12 | 13 | # Load environment variables from .env file in current directory 14 | load_dotenv(Path.cwd() / ".env") 15 | 16 | app = typer.Typer( 17 | add_completion=False, 18 | no_args_is_help=True, 19 | help="Database Migrations for ClickHouse", 20 | ) 21 | 22 | 23 | def get_houseplant() -> Houseplant: 24 | houseplant = Houseplant() 25 | houseplant._check_migrations_dir() 26 | houseplant.db._check_clickhouse_connection() 27 | return houseplant 28 | 29 | 30 | def version_callback(value: bool): 31 | if value: 32 | console = Console() 33 | console.print(f"houseplant version {__version__}") 34 | raise typer.Exit() 35 | 36 | 37 | @app.callback() 38 | def common( 39 | version: Optional[bool] = typer.Option( 40 | None, 41 | "--version", 42 | "-v", 43 | help="Show version and exit.", 44 | callback=version_callback, 45 | is_eager=True, 46 | ), 47 | ): 48 | pass 49 | 50 | 51 | @app.command() 52 | def init(): 53 | """Initialize a new houseplant project.""" 54 | hp = Houseplant() 55 | hp.init() 56 | 57 | 58 | @app.command(name="generate") 59 | def generate(name: str): 60 | """Generate a new migration.""" 61 | hp = get_houseplant() 62 | hp.generate(name) 63 | 64 | 65 | @app.command(name="migrate:status") 66 | def migrate_status(): 67 | """Show status of database migrations.""" 68 | hp = get_houseplant() 69 | hp.migrate_status() 70 | 71 | 72 | @app.command(name="migrate") 73 | def migrate(version: Optional[str] = typer.Argument(None)): 74 | """Run migrations up to specified version.""" 75 | hp = get_houseplant() 76 | hp.migrate(version) 77 | 78 | 79 | @app.command(name="migrate:up") 80 | def migrate_up(version: Optional[str] = typer.Argument(None)): 81 | """Run migrations up to specified version.""" 82 | hp = get_houseplant() 83 | version = version or os.getenv("VERSION") 84 | hp.migrate_up(version) 85 | 86 | 87 | @app.command(name="migrate:down") 88 | def migrate_down(version: Optional[str] = typer.Argument(None)): 89 | """Roll back migrations to specified version.""" 90 | hp = get_houseplant() 91 | version = version or os.getenv("VERSION") 92 | hp.migrate_down(version) 93 | 94 | 95 | @app.command(name="db:schema:load") 96 | def db_schema_load(): 97 | """Load the schema migrations from migrations directory.""" 98 | hp = get_houseplant() 99 | hp.db_schema_load() 100 | 101 | 102 | @app.command(hidden=True) 103 | def main(): 104 | """Console script for houseplant.""" 105 | console = Console() 106 | console.print( 107 | "Replace this message by putting your code into " "houseplant.cli.main" 108 | ) 109 | console.print("See Typer documentation at https://typer.tiangolo.com/") 110 | 111 | 112 | if __name__ == "__main__": 113 | app() 114 | -------------------------------------------------------------------------------- /src/houseplant/clickhouse_client.py: -------------------------------------------------------------------------------- 1 | """ClickHouse database operations module.""" 2 | 3 | import os 4 | 5 | from clickhouse_driver import Client 6 | from clickhouse_driver.errors import NetworkError, ServerException 7 | from rich.console import Console 8 | 9 | 10 | class RichFormattedError: 11 | """Mixin for exceptions that use Rich formatting.""" 12 | 13 | def __init__(self, message, error_type="Error"): 14 | super().__init__(message) 15 | self.message = message 16 | console = Console(stderr=True) 17 | console.print(f"[red bold]ClickHouse {error_type}:[/] {message}") 18 | 19 | 20 | class ClickHouseConnectionError(RichFormattedError, Exception): 21 | """Raised when ClickHouse connection fails.""" 22 | 23 | def __init__(self, message): 24 | super().__init__(message, error_type="Connection Error") 25 | 26 | 27 | class ClickHouseAuthenticationError(RichFormattedError, Exception): 28 | """Raised when ClickHouse authentication fails.""" 29 | 30 | def __init__(self, message): 31 | super().__init__(message, error_type="Authentication Error") 32 | 33 | 34 | class ClickHouseDatabaseNotFoundError(RichFormattedError, Exception): 35 | """Raised when ClickHouse database does not exist.""" 36 | 37 | def __init__(self, database): 38 | message = f"Database '{database}' does not exist" 39 | super().__init__(message, error_type="Database Error") 40 | 41 | 42 | class ClickHouseClient: 43 | def __init__( 44 | self, host=None, port=None, database=None, user=None, password=None, secure=None 45 | ): 46 | self.host = host or os.getenv("CLICKHOUSE_HOST", "localhost") 47 | # Parse port from host:port string if present, otherwise use port parameter or default 48 | if ":" in self.host: 49 | self.host, port_str = self.host.split(":") 50 | self.port = int(port_str) 51 | else: 52 | self.port = int(port or os.getenv("CLICKHOUSE_PORT", 9000)) 53 | 54 | self.database = database or os.getenv("CLICKHOUSE_DB", "development") 55 | 56 | self.user = user or os.getenv("CLICKHOUSE_USER", "default") 57 | self.password = password or os.getenv("CLICKHOUSE_PASSWORD", "") 58 | 59 | # Use SSL port by default if secure 60 | self.secure = secure or os.getenv("CLICKHOUSE_SECURE", "n").lower() 61 | self.secure = self.secure in ("true", "t", "yes", "y", "1") 62 | self.port = 9440 if self.secure else self.port 63 | 64 | # Disable verification unless specified otherwise 65 | self.verify = os.getenv("CLICKHOUSE_VERIFY", "n").lower() 66 | self.verify = self.verify in ("true", "t", "yes", "y", "1") 67 | 68 | self.client = Client( 69 | host=self.host, 70 | port=self.port, 71 | database=self.database, 72 | user=self.user, 73 | password=self.password, 74 | secure=self.secure, 75 | verify=self.verify, 76 | ) 77 | 78 | self._cluster = None 79 | 80 | def _check_clickhouse_connection(self): 81 | """Check connection to ClickHouse and raise appropriate errors.""" 82 | try: 83 | self.client.execute("SELECT 1") 84 | except NetworkError: 85 | raise ClickHouseConnectionError( 86 | f"Could not connect to database at {self.host}:{self.port}" 87 | ) 88 | except ServerException as e: 89 | if "Authentication failed" in str(e): 90 | raise ClickHouseAuthenticationError( 91 | f"Authentication failed for user {os.getenv('CLICKHOUSE_USER', 'default')}" 92 | ) 93 | elif "Database" in str(e) and "does not exist" in str(e): 94 | raise ClickHouseDatabaseNotFoundError(self.database) 95 | else: 96 | raise e 97 | 98 | @property 99 | def cluster(self): 100 | if self._cluster is None: 101 | self._cluster = os.getenv("CLICKHOUSE_CLUSTER") 102 | return self._cluster 103 | 104 | @cluster.setter 105 | def cluster(self, value): 106 | self._cluster = value 107 | 108 | def init_migrations_table_query(self): 109 | """Initialize the schema migrations table.""" 110 | table_definition = """ 111 | CREATE TABLE IF NOT EXISTS schema_migrations {cluster} ( 112 | version LowCardinality(String), 113 | active UInt8 NOT NULL DEFAULT 1, 114 | created_at DateTime64(6, 'UTC') NOT NULL DEFAULT now64() 115 | ) 116 | ENGINE = {engine} 117 | PRIMARY KEY(version) 118 | ORDER BY (version) 119 | """ 120 | 121 | cluster_clause = "ON CLUSTER '{cluster}'" if self.cluster is not None else "" 122 | engine = ( 123 | "ReplicatedReplacingMergeTree(created_at)" 124 | if self.cluster is not None 125 | else "ReplacingMergeTree(created_at)" 126 | ) 127 | 128 | return table_definition.format(cluster=cluster_clause, engine=engine) 129 | 130 | def init_migrations_table(self): 131 | self.client.execute(self.init_migrations_table_query()) 132 | 133 | def get_database_schema(self): 134 | """Get the database schema organized by object type and sorted by migration date.""" 135 | # Get all applied migrations in order 136 | applied_migrations = self.get_applied_migrations() 137 | latest_version = applied_migrations[-1][0] if applied_migrations else "0" 138 | 139 | # Initialize schema structure 140 | schema = { 141 | "version": latest_version, 142 | "tables": [], 143 | "materialized_views": [], 144 | "dictionaries": [], 145 | } 146 | 147 | # Get all database objects 148 | tables = self.get_database_tables() 149 | materialized_views = self.get_database_materialized_views() 150 | dictionaries = self.get_database_dictionaries() 151 | 152 | for table in tables: 153 | table_name = table[0] 154 | create_stmt = self.client.execute(f"SHOW CREATE TABLE {table_name}")[0][0] 155 | schema["tables"].append(create_stmt) 156 | 157 | for materialized_view in materialized_views: 158 | materialized_view_name = materialized_view[0] 159 | create_stmt = self.client.execute( 160 | f"SHOW CREATE MATERIALIZED VIEW {materialized_view_name}" 161 | )[0][0] 162 | schema["materialized_views"].append(create_stmt) 163 | 164 | for dictionary in dictionaries: 165 | dictionary_name = dictionary[0] 166 | create_stmt = self.client.execute( 167 | f"SHOW CREATE DICTIONARY {dictionary_name}" 168 | )[0][0] 169 | schema["dictionaries"].append(create_stmt) 170 | 171 | # Sort each category by migration date 172 | for category in ["tables", "materialized_views", "dictionaries"]: 173 | schema[category].sort() 174 | 175 | return schema 176 | 177 | def get_latest_migration(self): 178 | """Get the latest migration version.""" 179 | # First check if the table exists 180 | table_exists = self.client.execute(""" 181 | SELECT name 182 | FROM system.tables 183 | WHERE database = currentDatabase() 184 | AND name = 'schema_migrations' 185 | """) 186 | 187 | if not table_exists: 188 | return None 189 | 190 | result = self.client.execute(""" 191 | SELECT MAX(version) FROM schema_migrations WHERE active = 1 192 | """) 193 | return result[0][0] if result else None 194 | 195 | def get_database_tables(self): 196 | """Get the database tables with their engines, indexes and partitioning.""" 197 | return self.client.execute(""" 198 | SELECT 199 | name 200 | FROM system.tables 201 | WHERE database = currentDatabase() 202 | AND position('MergeTree' IN engine) > 0 203 | AND engine NOT IN ('MaterializedView', 'Dictionary') 204 | AND name != 'schema_migrations' 205 | ORDER BY name 206 | """) 207 | 208 | def get_database_materialized_views(self): 209 | """Get the database materialized views.""" 210 | return self.client.execute(""" 211 | SELECT 212 | name 213 | FROM system.tables 214 | WHERE database = currentDatabase() 215 | AND engine = 'MaterializedView' 216 | AND name != 'schema_migrations' 217 | ORDER BY name 218 | """) 219 | 220 | def get_database_dictionaries(self): 221 | """Get the database dictionaries.""" 222 | return self.client.execute(""" 223 | SELECT 224 | name 225 | FROM system.tables 226 | WHERE database = currentDatabase() 227 | AND engine = 'Dictionary' 228 | AND name != 'schema_migrations' 229 | ORDER BY name 230 | """) 231 | 232 | def get_applied_migrations(self): 233 | """Get list of applied migrations.""" 234 | return self.client.execute(""" 235 | SELECT version 236 | FROM schema_migrations FINAL 237 | WHERE active = 1 238 | ORDER BY version 239 | """) 240 | 241 | def execute_migration(self, sql: str, query_settings: dict = None): 242 | """Execute a migration SQL statement.""" 243 | # Split multiple statements and execute them separately 244 | statements = [stmt.strip() for stmt in sql.split(";") if stmt.strip()] 245 | for statement in statements: 246 | self.client.execute(statement, settings=query_settings) 247 | 248 | def mark_migration_applied(self, version: str): 249 | """Mark a migration as applied.""" 250 | self.client.execute( 251 | """ 252 | INSERT INTO schema_migrations (version, active) 253 | VALUES (%(version)s, 1) 254 | """, 255 | {"version": version}, 256 | ) 257 | 258 | self.client.execute( 259 | """ 260 | OPTIMIZE TABLE schema_migrations FINAL 261 | """ 262 | ) 263 | 264 | def mark_migration_rolled_back(self, version: str): 265 | """Mark a migration as rolled back.""" 266 | self.client.execute( 267 | """ 268 | INSERT INTO schema_migrations (version, active, created_at) 269 | VALUES ( 270 | %(version)s, 271 | 0, 272 | now64() 273 | ) 274 | """, 275 | {"version": version}, 276 | ) 277 | 278 | self.client.execute( 279 | """ 280 | OPTIMIZE TABLE schema_migrations FINAL 281 | """ 282 | ) 283 | -------------------------------------------------------------------------------- /src/houseplant/houseplant.py: -------------------------------------------------------------------------------- 1 | """Main module.""" 2 | 3 | import os 4 | from datetime import datetime 5 | 6 | import yaml 7 | from rich.console import Console 8 | from rich.table import Table 9 | 10 | from .clickhouse_client import ClickHouseClient 11 | from .utils import MIGRATIONS_DIR, get_migration_files 12 | 13 | 14 | class Houseplant: 15 | def __init__(self): 16 | self.console = Console() 17 | self.db = ClickHouseClient() 18 | self.env = os.getenv("HOUSEPLANT_ENV", "development") 19 | 20 | def _check_migrations_dir(self): 21 | """Check if migrations directory exists and raise formatted error if not.""" 22 | if not os.path.exists(MIGRATIONS_DIR): 23 | self.console.print("[red]Error:[/red] Migrations directory not found") 24 | self.console.print( 25 | "\nPlease run [bold]houseplant init[/bold] to create a new project " 26 | "or ensure you're in the correct directory." 27 | ) 28 | raise SystemExit(1) 29 | 30 | def init(self): 31 | """Initialize a new houseplant project.""" 32 | with self.console.status("[bold green]Initializing new houseplant project..."): 33 | os.makedirs("ch/migrations", exist_ok=True) 34 | open("ch/schema.sql", "a").close() 35 | 36 | self.db.init_migrations_table() 37 | 38 | self.console.print("✨ Project initialized successfully!") 39 | 40 | def migrate_status(self): 41 | """Show status of database migrations.""" 42 | # Get applied migrations from database 43 | applied_migrations = { 44 | version[0] for version in self.db.get_applied_migrations() 45 | } 46 | 47 | migration_files = get_migration_files() 48 | if not migration_files: 49 | self.console.print("[yellow]No migrations found.[/yellow]") 50 | return 51 | 52 | self.console.print(f"\nDatabase: {self.db.client.connection.database}\n") 53 | 54 | table = Table() 55 | table.add_column("Status", justify="center", style="cyan", no_wrap=True) 56 | table.add_column("Migration ID", justify="left", style="magenta") 57 | table.add_column("Migration Name", justify="left", style="green") 58 | 59 | for migration_file in migration_files: 60 | version = migration_file.split("_")[0] 61 | status = ( 62 | "[green]up[/green]" 63 | if version in applied_migrations 64 | else "[red]down[/red]" 65 | ) 66 | name = " ".join(migration_file.split("_")[1:]).replace(".yml", "") 67 | table.add_row(status, version, name) 68 | 69 | self.console.print(table) 70 | self.console.print("") 71 | 72 | def migrate_up(self, version: str | None = None): 73 | """Run migrations up to specified version.""" 74 | # Remove VERSION= prefix if present 75 | if version and version.startswith("VERSION="): 76 | version = version.replace("VERSION=", "") 77 | 78 | migration_files = get_migration_files() 79 | if not migration_files: 80 | self.console.print("[yellow]No migrations found.[/yellow]") 81 | return 82 | 83 | # Get applied migrations from database 84 | applied_migrations = { 85 | version[0] for version in self.db.get_applied_migrations() 86 | } 87 | 88 | # If specific version requested, verify it exists 89 | if version: 90 | matching_files = [f for f in migration_files if f.split("_")[0] == version] 91 | if not matching_files: 92 | self.console.print(f"[red]Migration version {version} not found[/red]") 93 | return 94 | 95 | with self.console.status( 96 | f"[bold green]Running migration version: {version}..." 97 | ): 98 | for migration_file in migration_files: 99 | migration_version = migration_file.split("_")[0] 100 | 101 | if version and migration_version != version: 102 | continue 103 | 104 | if migration_version in applied_migrations: 105 | continue 106 | 107 | # Load and execute migration 108 | with open(os.path.join(MIGRATIONS_DIR, migration_file), "r") as f: 109 | migration = yaml.safe_load(f) 110 | 111 | table = migration.get("table", "").strip() 112 | if not table: 113 | self.console.print( 114 | "[red]✗[/red] Migration [bold red]failed[/bold red]: " 115 | "'table' field is required in migration file" 116 | ) 117 | return 118 | 119 | table_definition = migration.get("table_definition", "").strip() 120 | table_settings = migration.get("table_settings", "").strip() 121 | 122 | format_args = {"table": table} 123 | if table_definition and table_settings: 124 | format_args.update( 125 | { 126 | "table_definition": table_definition, 127 | "table_settings": table_settings, 128 | } 129 | ) 130 | 131 | sink_table = migration.get("sink_table", "").strip() 132 | view_definition = migration.get("view_definition", "").strip() 133 | view_query = migration.get("view_query", "").strip() 134 | if sink_table and view_definition and view_query: 135 | format_args.update( 136 | { 137 | "sink_table": sink_table, 138 | "view_definition": view_definition, 139 | "view_query": view_query, 140 | } 141 | ) 142 | 143 | # Get migration SQL based on environment 144 | migration_env: dict = migration.get(self.env, {}) 145 | migration_sql = ( 146 | migration_env.get("up", "").format(**format_args).strip() 147 | ) 148 | 149 | if migration_sql: 150 | self.db.execute_migration( 151 | migration_sql, migration_env.get("query_settings") 152 | ) 153 | self.db.mark_migration_applied(migration_version) 154 | self.console.print( 155 | f"[green]✓[/green] Applied migration {migration_file}" 156 | ) 157 | else: 158 | self.console.print( 159 | f"[yellow]⚠[/yellow] Empty migration {migration_file}" 160 | ) 161 | 162 | if version and migration_version == version: 163 | self.update_schema() 164 | break 165 | 166 | def migrate_down(self, version: str | None = None): 167 | """Roll back migrations to specified version.""" 168 | # Remove VERSION= prefix if present 169 | if version and version.startswith("VERSION="): 170 | version = version.replace("VERSION=", "") 171 | 172 | # Get applied migrations from database 173 | applied_migrations = sorted( 174 | [version[0] for version in self.db.get_applied_migrations()], reverse=True 175 | ) 176 | 177 | if not applied_migrations: 178 | self.console.print("[yellow]No migrations to roll back.[/yellow]") 179 | return 180 | 181 | with self.console.status(f"[bold green]Rolling back to version: {version}..."): 182 | for migration_version in applied_migrations: 183 | if version and migration_version < version: 184 | break 185 | 186 | # Find corresponding migration file 187 | migration_file = next( 188 | ( 189 | f 190 | for f in os.listdir(MIGRATIONS_DIR) 191 | if f.startswith(migration_version) and f.endswith(".yml") 192 | ), 193 | None, 194 | ) 195 | 196 | if not migration_file: 197 | self.console.print( 198 | f"[red]Warning: Migration file for version {migration_version} not found[/red]" 199 | ) 200 | continue 201 | 202 | # Load and execute down migration 203 | with open(os.path.join(MIGRATIONS_DIR, migration_file), "r") as f: 204 | migration = yaml.safe_load(f) 205 | 206 | table = migration.get("table", "").strip() 207 | if not table: 208 | self.console.print( 209 | "[red]✗[/red] [bold red] Migration failed[/bold red]: " 210 | "'table' field is required in migration file" 211 | ) 212 | return 213 | 214 | # Get migration SQL based on environment 215 | migration_env = migration.get(self.env, {}) 216 | migration_sql = ( 217 | migration_env.get("down", {}).format(table=table).strip() 218 | ) 219 | 220 | if migration_sql: 221 | self.db.execute_migration( 222 | migration_sql, migration_env.get("query_settings") 223 | ) 224 | self.db.mark_migration_rolled_back(migration_version) 225 | self.update_schema() 226 | self.console.print( 227 | f"[green]✓[/green] Rolled back migration {migration_file}" 228 | ) 229 | 230 | return 231 | 232 | self.console.print( 233 | f"[yellow]⚠[/yellow] Empty down migration {migration_file}" 234 | ) 235 | 236 | def migrate(self, version: str | None = None): 237 | """Run migrations up to specified version.""" 238 | self.migrate_up(version) 239 | 240 | def generate(self, name: str): 241 | """Generate a new migration.""" 242 | with self.console.status("[bold green]Generating migration..."): 243 | timestamp = datetime.now().strftime("%Y%m%d%H%M%S") 244 | 245 | migration_name = name.replace(" ", "_").replace("-", "_").lower() 246 | migration_file = f"ch/migrations/{timestamp}_{migration_name}.yml" 247 | 248 | with open(migration_file, "w") as f: 249 | f.write(f"""version: "{timestamp}" 250 | name: {migration_name} 251 | table: 252 | 253 | development: &development 254 | up: | 255 | down: | 256 | DROP TABLE {{table}} 257 | 258 | test: 259 | <<: *development 260 | 261 | production: 262 | up: | 263 | down: | 264 | DROP TABLE {{table}} 265 | """) 266 | 267 | self.console.print(f"✨ Generated migration: {migration_file}") 268 | 269 | def db_schema_load(self): 270 | """Load schema migrations from migration files without applying them.""" 271 | migration_files = get_migration_files() 272 | if not migration_files: 273 | self.console.print("[yellow]No migrations found.[/yellow]") 274 | return 275 | 276 | with self.console.status("[bold green]Loading schema migrations..."): 277 | for migration_file in migration_files: 278 | migration_version = migration_file.split("_")[0] 279 | self.db.mark_migration_applied(migration_version) 280 | self.console.print( 281 | f"[green]✓[/green] Loaded migration {migration_file}" 282 | ) 283 | 284 | self.console.print("✨ Schema migrations loaded successfully!") 285 | 286 | def update_schema(self): 287 | """Update the schema file with the current database schema.""" 288 | 289 | # Get all applied migrations in order 290 | applied_migrations = self.db.get_applied_migrations() 291 | migration_files = get_migration_files() 292 | latest_version = applied_migrations[-1][0] if applied_migrations else "0" 293 | 294 | # Get all database objects 295 | tables = self.db.get_database_tables() 296 | materialized_views = self.db.get_database_materialized_views() 297 | dictionaries = self.db.get_database_dictionaries() 298 | 299 | # Track processed tables to ensure first migration takes precedence 300 | processed_tables = set() 301 | 302 | # Group statements by type 303 | table_statements = [] 304 | mv_statements = [] 305 | dict_statements = [] 306 | 307 | for migration_version in applied_migrations: 308 | matching_file = next( 309 | (f for f in migration_files if f.startswith(migration_version[0])), None 310 | ) 311 | 312 | if not matching_file: 313 | continue 314 | 315 | migration_file = f"ch/migrations/{matching_file}" 316 | with open(migration_file) as f: 317 | migration_data = yaml.safe_load(f) 318 | 319 | # Extract table name from migration 320 | table_name = migration_data.get("table") 321 | if not table_name: 322 | continue 323 | 324 | # Skip if we've already processed this table 325 | if table_name in processed_tables: 326 | continue 327 | 328 | # Check tables first 329 | for table in tables: 330 | if table[0] == table_name: 331 | create_stmt = self.db.client.execute( 332 | f"SHOW CREATE TABLE {table_name}" 333 | )[0][0] 334 | table_statements.append(create_stmt) 335 | processed_tables.add(table_name) 336 | 337 | # Then materialized views 338 | for mv in materialized_views: 339 | if mv[0] == table_name: 340 | mv_name = mv[0] 341 | create_stmt = self.db.client.execute(f"SHOW CREATE VIEW {mv_name}")[ 342 | 0 343 | ][0] 344 | mv_statements.append(create_stmt) 345 | processed_tables.add(table_name) 346 | 347 | # Finally dictionaries 348 | for ch_dict in dictionaries: 349 | if ch_dict[0] == table_name: 350 | dict_name = ch_dict[0] 351 | create_stmt = self.db.client.execute( 352 | f"SHOW CREATE DICTIONARY {dict_name}" 353 | )[0][0] 354 | dict_statements.append(create_stmt) 355 | processed_tables.add(table_name) 356 | 357 | # Write schema file 358 | with open("ch/schema.sql", "w") as f: 359 | f.write(f"-- version: {latest_version}\n\n") 360 | if table_statements: 361 | f.write("-- TABLES\n\n") 362 | f.write("\n;\n\n".join(table_statements) + ";") 363 | if mv_statements: 364 | f.write("\n\n-- MATERIALIZED VIEWS\n\n") 365 | f.write("\n;\n\n".join(mv_statements) + ";") 366 | if dict_statements: 367 | f.write("\n\n-- DICTIONARIES\n\n") 368 | f.write("\n;\n\n".join(dict_statements) + ";") 369 | -------------------------------------------------------------------------------- /src/houseplant/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | MIGRATIONS_DIR = "ch/migrations" 4 | 5 | 6 | def get_migration_files(): 7 | # Get all local migration files 8 | return sorted([f for f in os.listdir(MIGRATIONS_DIR) if f.endswith(".yml")]) 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for houseplant.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | from clickhouse_driver import Client 5 | 6 | from houseplant.clickhouse_client import ClickHouseClient 7 | 8 | 9 | def check_clickhouse_connection(host="localhost", port=9000, attempts=3): 10 | """Check if ClickHouse is accessible.""" 11 | for _ in range(attempts): 12 | try: 13 | test_client = Client(host=host, port=port) 14 | test_client.execute("SELECT 1") 15 | return True 16 | except Exception: 17 | time.sleep(1) 18 | return False 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | def clickhouse_service(): 23 | """Verify ClickHouse is running and return connection details.""" 24 | if not check_clickhouse_connection(): 25 | raise RuntimeError( 26 | "ClickHouse is not running. Please start ClickHouse before running tests." 27 | ) 28 | 29 | return { 30 | "host": "localhost", 31 | "port": 9000, # Default ClickHouse port 32 | } 33 | 34 | 35 | @pytest.fixture 36 | def ch_client(clickhouse_service): 37 | """Create a fresh database for each test.""" 38 | test_db = f"houseplant_test_{time.time_ns()}" 39 | 40 | ch_client = Client(host=clickhouse_service["host"], port=clickhouse_service["port"]) 41 | 42 | ch_client.execute(f"CREATE DATABASE IF NOT EXISTS {test_db}") 43 | 44 | client = ClickHouseClient( 45 | host=f"{clickhouse_service['host']}:{clickhouse_service['port']}", 46 | database=test_db, 47 | ) 48 | 49 | yield client 50 | 51 | ch_client.execute(f"DROP DATABASE IF EXISTS {test_db}") 52 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Generator 3 | 4 | import pytest 5 | from typer.testing import CliRunner 6 | 7 | from houseplant import Houseplant, __version__ 8 | from houseplant.cli import app 9 | 10 | runner = CliRunner() 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def mock_clickhouse(mocker): 15 | """Mock ClickHouse connection and client for all tests.""" 16 | # Mock the clickhouse_driver.Client 17 | mock_client = mocker.patch("houseplant.clickhouse_client.Client", autospec=True) 18 | 19 | # Create a mock instance with required methods and attributes 20 | client_instance = mocker.Mock() 21 | client_instance.execute.return_value = [[1]] # Default successful response 22 | client_instance.connection = mocker.Mock() 23 | client_instance.connection.database = "test_db" 24 | mock_client.return_value = client_instance 25 | 26 | return client_instance 27 | 28 | 29 | @pytest.fixture 30 | def mock_houseplant(mocker, mock_clickhouse) -> Generator[None, None, None]: 31 | """Mock the Houseplant class to avoid actual operations during testing.""" 32 | # Create a real instance to get access to the original init method 33 | real_instance = Houseplant() 34 | 35 | # Create mock instance 36 | mock_instance = mocker.Mock(spec=Houseplant) 37 | mock_instance.db = mocker.Mock() 38 | mock_instance.db.client = mock_clickhouse 39 | mock_instance.db._check_clickhouse_connection.return_value = None 40 | mock_instance.db.init_migrations_table.return_value = None 41 | mock_instance.console = real_instance.console 42 | 43 | # Create a mock that wraps the real init method 44 | mock_instance.init = mocker.Mock(side_effect=real_instance.init) 45 | 46 | # Mock both the constructor and get_houseplant 47 | mocker.patch("houseplant.cli.Houseplant", return_value=mock_instance) 48 | mocker.patch("houseplant.cli.get_houseplant", return_value=mock_instance) 49 | 50 | yield mock_instance 51 | 52 | 53 | def test_dotenv_loading(tmp_path, monkeypatch): 54 | """Test that .env file is loaded.""" 55 | assert os.getenv("CLICKHOUSE_HOST") is None 56 | 57 | # Create a temporary .env file 58 | env_file = tmp_path / ".env" 59 | env_file.write_text("CLICKHOUSE_HOST=test.host") 60 | 61 | # Set current working directory to tmp_path 62 | monkeypatch.chdir(tmp_path) 63 | 64 | # Import cli module to trigger .env loading 65 | import importlib 66 | 67 | import houseplant.cli 68 | 69 | importlib.reload(houseplant.cli) 70 | 71 | assert os.getenv("CLICKHOUSE_HOST") == "test.host" 72 | 73 | 74 | def test_version_flag(): 75 | """Test the version flag.""" 76 | result = runner.invoke(app, ["--version"]) 77 | assert result.exit_code == 0 78 | assert f"houseplant version {__version__}" in result.stdout 79 | 80 | 81 | def test_init_command(tmp_path, mock_houseplant, monkeypatch): 82 | """Test the init command.""" 83 | # Change to temp directory safely using monkeypatch 84 | monkeypatch.chdir(tmp_path) 85 | 86 | result = runner.invoke(app, ["init"]) 87 | assert result.exit_code == 0 88 | mock_houseplant.init.assert_called_once() 89 | 90 | # Verify directories were created using absolute paths 91 | migrations_dir = tmp_path / "ch" / "migrations" 92 | schema_file = tmp_path / "ch" / "schema.sql" 93 | 94 | assert migrations_dir.exists() 95 | assert schema_file.exists() 96 | 97 | 98 | def test_migrate_status_command(mock_houseplant): 99 | """Test the migrate:status command.""" 100 | result = runner.invoke(app, ["migrate:status"]) 101 | assert result.exit_code == 0 102 | mock_houseplant.migrate_status.assert_called_once() 103 | 104 | 105 | def test_migrate_up_command(mock_houseplant): 106 | """Test the migrate:up command with and without version.""" 107 | # Test without version 108 | result = runner.invoke(app, ["migrate:up"]) 109 | assert result.exit_code == 0 110 | mock_houseplant.migrate_up.assert_called_with(None) 111 | 112 | # Test with version 113 | mock_houseplant.reset_mock() 114 | result = runner.invoke(app, ["migrate:up", "1.0"]) 115 | assert result.exit_code == 0 116 | mock_houseplant.migrate_up.assert_called_with("1.0") 117 | 118 | 119 | def test_migrate_down_command(mock_houseplant): 120 | """Test the migrate:down command with and without version.""" 121 | # Test without version 122 | result = runner.invoke(app, ["migrate:down"]) 123 | assert result.exit_code == 0 124 | mock_houseplant.migrate_down.assert_called_with(None) 125 | 126 | # Test with version 127 | mock_houseplant.reset_mock() 128 | result = runner.invoke(app, ["migrate:down", "1.0"]) 129 | assert result.exit_code == 0 130 | mock_houseplant.migrate_down.assert_called_with("1.0") 131 | 132 | 133 | def test_migrate_command(mock_houseplant): 134 | """Test the migrate command with and without version.""" 135 | # Test without version 136 | result = runner.invoke(app, ["migrate"]) 137 | assert result.exit_code == 0 138 | mock_houseplant.migrate.assert_called_with(None) 139 | 140 | # Test with version 141 | mock_houseplant.reset_mock() 142 | result = runner.invoke(app, ["migrate", "1.0"]) 143 | assert result.exit_code == 0 144 | mock_houseplant.migrate.assert_called_with("1.0") 145 | 146 | 147 | def test_generate_command(mock_houseplant): 148 | """Test the generate command.""" 149 | result = runner.invoke(app, ["generate", "new_migration"]) 150 | assert result.exit_code == 0 151 | mock_houseplant.generate.assert_called_with("new_migration") 152 | 153 | 154 | def test_main_command(): 155 | """Test the main command.""" 156 | result = runner.invoke(app, ["main"]) 157 | assert result.exit_code == 0 158 | assert "Replace this message" in result.stdout 159 | 160 | 161 | def test_cli_entrypoint(): 162 | """Test the CLI entrypoint.""" 163 | import houseplant.cli 164 | 165 | assert hasattr(houseplant.cli, "app") 166 | -------------------------------------------------------------------------------- /tests/test_clickhouse_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from clickhouse_driver.errors import NetworkError, ServerException 3 | 4 | from houseplant.clickhouse_client import ( 5 | ClickHouseAuthenticationError, 6 | ClickHouseConnectionError, 7 | ClickHouseDatabaseNotFoundError, 8 | ) 9 | 10 | 11 | @pytest.fixture 12 | def migrations_table(ch_client): 13 | """Fixture to initialize migrations table.""" 14 | ch_client.init_migrations_table() 15 | return ch_client 16 | 17 | 18 | def test_port_parsing_from_host(): 19 | """Test port parsing from host string.""" 20 | from houseplant.clickhouse_client import ClickHouseClient 21 | 22 | client = ClickHouseClient(host="localhost:1234") 23 | assert client.host == "localhost" 24 | assert client.port == 1234 25 | 26 | 27 | def test_default_port(monkeypatch): 28 | """Test default port selection.""" 29 | from houseplant.clickhouse_client import ClickHouseClient 30 | 31 | # Test default non-secure port 32 | client = ClickHouseClient() 33 | assert client.port == 9000 34 | 35 | # Test default secure port 36 | monkeypatch.setenv("CLICKHOUSE_SECURE", "true") 37 | client = ClickHouseClient() 38 | assert client.port == 9440 39 | 40 | 41 | def test_port_precedence(monkeypatch): 42 | """Test port parameter precedence.""" 43 | from houseplant.clickhouse_client import ClickHouseClient 44 | 45 | # Host:port should override port parameter and env var 46 | monkeypatch.setenv("CLICKHOUSE_PORT", "8000") 47 | client = ClickHouseClient(host="localhost:7000", port=6000) 48 | assert client.port == 7000 49 | 50 | # Host:port should override env var 51 | client = ClickHouseClient(host="localhost:7000") 52 | assert client.port == 7000 53 | 54 | # Port parameter should override env var 55 | client = ClickHouseClient(host="localhost", port=7000) 56 | assert client.port == 7000 57 | 58 | # Env var should be used if no other port specified 59 | client = ClickHouseClient(host="localhost") 60 | assert client.port == 8000 61 | 62 | # Secure port should be used if secure is true 63 | monkeypatch.setenv("CLICKHOUSE_SECURE", "true") 64 | client = ClickHouseClient(host="localhost:7000", port=6000) 65 | assert client.port == 9440 66 | 67 | 68 | def test_connection_error(monkeypatch): 69 | """Test connection error handling.""" 70 | monkeypatch.setenv("CLICKHOUSE_HOST", "invalid_host") 71 | 72 | with pytest.raises(ClickHouseConnectionError) as exc_info: 73 | from houseplant.clickhouse_client import ClickHouseClient 74 | 75 | client = ClickHouseClient() 76 | 77 | def mock_execute(*args, **kwargs): 78 | raise NetworkError("Connection refused") 79 | 80 | monkeypatch.setattr(client.client, "execute", mock_execute) 81 | client._check_clickhouse_connection() 82 | 83 | assert "Could not connect to database at invalid_host" in str(exc_info.value) 84 | 85 | 86 | def test_authentication_error(monkeypatch): 87 | """Test authentication error handling.""" 88 | monkeypatch.setenv("CLICKHOUSE_USER", "invalid_user") 89 | 90 | with pytest.raises(ClickHouseAuthenticationError) as exc_info: 91 | from houseplant.clickhouse_client import ClickHouseClient 92 | 93 | client = ClickHouseClient() 94 | 95 | def mock_execute(*args, **kwargs): 96 | raise ServerException("Authentication failed") 97 | 98 | monkeypatch.setattr(client.client, "execute", mock_execute) 99 | client._check_clickhouse_connection() 100 | 101 | assert "Authentication failed for user invalid_user" in str(exc_info.value) 102 | 103 | 104 | def test_database_not_found_error(monkeypatch): 105 | """Test database not found error handling.""" 106 | monkeypatch.setenv("CLICKHOUSE_DB", "nonexistent_db") 107 | 108 | with pytest.raises(ClickHouseDatabaseNotFoundError) as exc_info: 109 | from houseplant.clickhouse_client import ClickHouseClient 110 | 111 | client = ClickHouseClient() 112 | 113 | def mock_execute(*args, **kwargs): 114 | raise ServerException( 115 | "Code: None. Database 'nonexistent_db' does not exist" 116 | ) 117 | 118 | monkeypatch.setattr(client.client, "execute", mock_execute) 119 | client._check_clickhouse_connection() 120 | 121 | assert "Database 'nonexistent_db' does not exist" in str(exc_info.value) 122 | 123 | 124 | def test_migrations_table_structure(migrations_table): 125 | """Test that migrations table is created with correct structure.""" 126 | result = migrations_table.client.execute(""" 127 | SELECT name, type, default_expression 128 | FROM system.columns 129 | WHERE database = currentDatabase() AND table = 'schema_migrations' 130 | """) 131 | 132 | columns = {row[0]: {"type": row[1], "default": row[2]} for row in result} 133 | 134 | assert "version" in columns 135 | assert "LowCardinality(String)" in columns["version"]["type"] 136 | assert "active" in columns 137 | assert columns["active"]["type"] == "UInt8" 138 | assert columns["active"]["default"] == "1" 139 | assert "created_at" in columns 140 | assert "DateTime64" in columns["created_at"]["type"] 141 | 142 | 143 | def test_migrations_table_structure_with_cluster(ch_client, monkeypatch): 144 | """Test that migrations table is created with correct structure when using cluster.""" 145 | monkeypatch.setenv("CLICKHOUSE_CLUSTER", "test_cluster") 146 | 147 | create_statement = ch_client.init_migrations_table_query() 148 | 149 | assert "ON CLUSTER '{cluster}'" in create_statement 150 | assert "ENGINE = ReplicatedReplacingMergeTree" in create_statement 151 | 152 | 153 | def test_migrations_table_structure_without_cluster(ch_client): 154 | """Test that migrations table is created with correct structure when using cluster.""" 155 | create_statement = ch_client.init_migrations_table_query() 156 | 157 | assert "ON CLUSTER '{cluster}'" not in create_statement 158 | assert "ENGINE = ReplacingMergeTree" in create_statement 159 | 160 | 161 | @pytest.mark.parametrize("test_versions", [["20240101000000", "20240102000000"]]) 162 | def test_get_applied_migrations(migrations_table, test_versions): 163 | """Test retrieving applied migrations.""" 164 | for version in test_versions: 165 | migrations_table.mark_migration_applied(version) 166 | 167 | applied = migrations_table.get_applied_migrations() 168 | applied_versions = [row[0] for row in applied] 169 | 170 | assert applied_versions == test_versions 171 | 172 | 173 | def test_execute_migration(ch_client): 174 | """Test executing a migration SQL statement.""" 175 | test_sql = """ 176 | CREATE TABLE test_table ( 177 | id UInt32, 178 | name String 179 | ) ENGINE = MergeTree() 180 | ORDER BY id 181 | """ 182 | 183 | ch_client.execute_migration(test_sql) 184 | 185 | result = ch_client.client.execute(""" 186 | SELECT name 187 | FROM system.tables 188 | WHERE database = currentDatabase() AND name = 'test_table' 189 | """) 190 | 191 | assert len(result) == 1 192 | assert result[0][0] == "test_table" 193 | 194 | 195 | def test_mark_migration_applied(migrations_table): 196 | """Test marking a migration as applied.""" 197 | test_version = "20240101000000" 198 | migrations_table.mark_migration_applied(test_version) 199 | 200 | result = migrations_table.client.execute( 201 | """ 202 | SELECT version, active 203 | FROM schema_migrations 204 | WHERE version = %(version)s 205 | """, 206 | {"version": test_version}, 207 | ) 208 | 209 | assert len(result) == 1 210 | assert result[0][0] == test_version 211 | assert result[0][1] == 1 212 | 213 | 214 | def test_get_database_schema(ch_client): 215 | """Test getting database schema.""" 216 | test_sql = """ 217 | CREATE TABLE test_table ( 218 | id UInt32, 219 | name String 220 | ) ENGINE = MergeTree() 221 | ORDER BY id 222 | """ 223 | 224 | ch_client.execute_migration(test_sql) 225 | ch_client.init_migrations_table() 226 | 227 | schema = ch_client.get_database_schema() 228 | 229 | assert schema["version"] == "0" 230 | assert len(schema["tables"]) == 1 231 | assert schema["tables"][0].startswith("CREATE TABLE houseplant_test_") 232 | assert "test_table" in schema["tables"][0] 233 | assert "ENGINE = MergeTree" in schema["tables"][0] 234 | -------------------------------------------------------------------------------- /tests/test_houseplant.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from houseplant.houseplant import Houseplant 6 | 7 | 8 | @pytest.fixture 9 | def houseplant(): 10 | return Houseplant() 11 | 12 | 13 | @pytest.fixture 14 | def test_migration(tmp_path): 15 | # Set up test environment 16 | migrations_dir = tmp_path / "ch/migrations" 17 | migrations_dir.mkdir(parents=True) 18 | migration_file = migrations_dir / "20240101000000_test_migration.yml" 19 | 20 | migration_content = """version: "20240101000000" 21 | name: test_migration 22 | table: events 23 | 24 | development: &development 25 | up: | 26 | CREATE TABLE {table} ( 27 | id UInt32, 28 | name String 29 | ) ENGINE = MergeTree() 30 | ORDER BY id 31 | down: | 32 | DROP TABLE {table} 33 | 34 | test: 35 | <<: *development 36 | 37 | production: 38 | up: | 39 | CREATE TABLE {table} ( 40 | id UInt32, 41 | name String 42 | ) ENGINE = ReplicatedMergeTree() 43 | ORDER BY id 44 | down: | 45 | DROP TABLE {table} 46 | """ 47 | 48 | migration_file.write_text(migration_content) 49 | os.chdir(tmp_path) 50 | return migration_content 51 | 52 | 53 | @pytest.fixture 54 | def test_migration_with_table(tmp_path): 55 | # Set up test environment 56 | migrations_dir = tmp_path / "ch/migrations" 57 | migrations_dir.mkdir(parents=True) 58 | migration_file = migrations_dir / "20240101000000_test_settings.yml" 59 | 60 | migration_content = """version: "20240101000000" 61 | name: test_settings 62 | table: settings_table 63 | 64 | table_definition: | 65 | id UInt32, 66 | name String, 67 | created_at DateTime 68 | table_settings: | 69 | ORDER BY (id, created_at) 70 | PARTITION BY toYYYYMM(created_at) 71 | 72 | development: &development 73 | up: | 74 | CREATE TABLE {table} ( 75 | {table_definition} 76 | ) ENGINE = MergeTree() 77 | {table_settings} 78 | down: | 79 | DROP TABLE {table} 80 | 81 | test: 82 | <<: *development 83 | 84 | production: 85 | up: | 86 | CREATE TABLE {table} ON CLUSTER '{{cluster}}' ( 87 | {table_definition} 88 | ) ENGINE = MergeTree() 89 | {table_settings} 90 | down: | 91 | DROP TABLE {table} 92 | """ 93 | 94 | migration_file.write_text(migration_content) 95 | os.chdir(tmp_path) 96 | return migration_content 97 | 98 | 99 | @pytest.fixture 100 | def test_migration_with_view(tmp_path): 101 | # Set up test environment 102 | migrations_dir = tmp_path / "ch/migrations" 103 | migrations_dir.mkdir(parents=True) 104 | migration_file = migrations_dir / "20240101000000_test_view.yml" 105 | 106 | migration_content = """version: "20240101000000" 107 | name: test_view 108 | table: settings_table 109 | sink_table: sink_table 110 | 111 | view_definition: | 112 | id UInt32, 113 | name String, 114 | created_at DateTime 115 | view_query: | 116 | SELECT * FROM events 117 | 118 | development: &development 119 | up: | 120 | CREATE MATERIALIZED VIEW {table} 121 | TO {sink_table} ( 122 | {view_definition} 123 | ) 124 | AS {view_query} 125 | down: | 126 | DROP TABLE {table} 127 | 128 | test: 129 | <<: *development 130 | 131 | production: 132 | up: | 133 | CREATE MATERIALIZED VIEW {table} 134 | ON CLUSTER '{{cluster}}' 135 | TO {sink_table} ( 136 | {view_definition} 137 | ) 138 | AS {view_query} 139 | down: | 140 | DROP TABLE {table} 141 | """ 142 | 143 | migration_file.write_text(migration_content) 144 | os.chdir(tmp_path) 145 | return migration_content 146 | 147 | 148 | @pytest.fixture 149 | def duplicate_migrations(tmp_path): 150 | # Set up test environment 151 | migrations_dir = tmp_path / "ch/migrations" 152 | migrations_dir.mkdir(parents=True) 153 | 154 | # Create two migrations that modify the same table 155 | migration1 = migrations_dir / "20240101000000_first_migration.yml" 156 | migration2 = migrations_dir / "20240102000000_second_migration.yml" 157 | 158 | migration1_content = """version: "{version}" 159 | name: {name} 160 | table: events 161 | 162 | development: &development 163 | up: | 164 | CREATE TABLE events ( 165 | id UInt32, 166 | name String 167 | ) ENGINE = MergeTree() 168 | ORDER BY id 169 | down: | 170 | DROP TABLE events 171 | 172 | test: 173 | <<: *development 174 | 175 | production: 176 | <<: *development 177 | """ 178 | 179 | migration2_content = """version: "{version}" 180 | name: {name} 181 | table: events 182 | 183 | development: &development 184 | up: | 185 | ALTER TABLE events ADD COLUMN description String 186 | down: | 187 | ALTER TABLE events DROP COLUMN description 188 | 189 | test: 190 | <<: *development 191 | 192 | production: 193 | <<: *development 194 | """ 195 | 196 | migration1.write_text( 197 | migration1_content.format(version="20240101000000", name="first_migration") 198 | ) 199 | migration2.write_text( 200 | migration2_content.format(version="20240102000000", name="second_migration") 201 | ) 202 | 203 | os.chdir(tmp_path) 204 | return ("20240101000000", "20240102000000") 205 | 206 | 207 | @pytest.fixture 208 | def migration_with_settings(tmp_path): 209 | migrations_dir = tmp_path / "ch/migrations" 210 | migrations_dir.mkdir(parents=True) 211 | migration_file = migrations_dir / "20240101000000_test_migration_with_settings.yml" 212 | 213 | migration_content = """version: "20240101000000" 214 | name: test_migration_with_settings 215 | table: dynamic_type_table 216 | 217 | development: 218 | query_settings: 219 | enable_dynamic_type: 1 220 | max_table_size_to_drop: 0 221 | up: CREATE TABLE {table} (d Dynamic) ENGINE = MergeTree() ORDER BY d 222 | down: DROP TABLE {table} 223 | """ 224 | 225 | migration_file.write_text(migration_content) 226 | os.chdir(tmp_path) 227 | return migration_content 228 | 229 | 230 | def test_init(houseplant, tmp_path, mocker): 231 | # Mock database calls 232 | mock_init_migrations = mocker.patch.object(houseplant.db, "init_migrations_table") 233 | 234 | # Change to temp directory 235 | os.chdir(tmp_path) 236 | 237 | # Run init 238 | houseplant.init() 239 | 240 | # Verify directories and files were created 241 | assert os.path.exists("ch/migrations") 242 | assert os.path.exists("ch/schema.sql") 243 | 244 | # Verify migrations table was initialized 245 | mock_init_migrations.assert_called_once() 246 | 247 | 248 | def test_migration_with_settings(houseplant, migration_with_settings, mocker): 249 | mock_execute = mocker.patch.object(houseplant.db.client, "execute") 250 | settings = {"settings": {"enable_dynamic_type": 1, "max_table_size_to_drop": 0}} 251 | 252 | houseplant.migrate_up() 253 | assert list(mock_execute.call_args_list[1]) == [ 254 | ( 255 | "CREATE TABLE dynamic_type_table (d Dynamic) ENGINE = MergeTree() ORDER BY d", 256 | ), 257 | settings, 258 | ] 259 | 260 | mocker.patch.object(houseplant.db, "mark_migration_rolled_back") 261 | mocker.patch.object( 262 | houseplant.db, "get_applied_migrations", return_value=[("20240101000000",)] 263 | ) 264 | 265 | houseplant.migrate_down() 266 | assert list(mock_execute.call_args_list[4]) == [ 267 | ("DROP TABLE dynamic_type_table",), 268 | settings, 269 | ] 270 | 271 | 272 | def test_migrate_up_development(houseplant, test_migration, mocker): 273 | # Mock environment and database calls 274 | houseplant.env = "development" 275 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 276 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 277 | mock_get_applied = mocker.patch.object( 278 | houseplant.db, "get_applied_migrations", return_value=[] 279 | ) 280 | 281 | # Run migration 282 | houseplant.migrate_up() 283 | 284 | # Verify correct SQL was executed 285 | expected_sql = """CREATE TABLE events ( 286 | id UInt32, 287 | name String 288 | ) ENGINE = MergeTree() 289 | ORDER BY id""" 290 | 291 | mock_execute.assert_called_once_with(expected_sql, None) 292 | mock_mark_applied.assert_called_once_with("20240101000000") 293 | mock_get_applied.assert_called_once() 294 | 295 | 296 | def test_migrate_up_production(houseplant, test_migration, mocker): 297 | # Mock environment and database calls 298 | houseplant.env = "production" 299 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 300 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 301 | mock_get_applied = mocker.patch.object( 302 | houseplant.db, "get_applied_migrations", return_value=[] 303 | ) 304 | 305 | # Run migration 306 | houseplant.migrate_up() 307 | 308 | # Verify correct SQL was executed 309 | expected_sql = """CREATE TABLE events ( 310 | id UInt32, 311 | name String 312 | ) ENGINE = ReplicatedMergeTree() 313 | ORDER BY id""" 314 | 315 | mock_execute.assert_called_once_with(expected_sql, None) 316 | mock_mark_applied.assert_called_once_with("20240101000000") 317 | mock_get_applied.assert_called_once() 318 | 319 | 320 | def test_migrate_up_with_development_table( 321 | houseplant, test_migration_with_table, mocker 322 | ): 323 | # Mock database calls 324 | houseplant.env = "development" 325 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 326 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 327 | mock_get_applied = mocker.patch.object( 328 | houseplant.db, "get_applied_migrations", return_value=[] 329 | ) 330 | 331 | # Run migration 332 | houseplant.migrate_up() 333 | 334 | # Verify correct SQL was executed with table_definition and table_settings 335 | expected_sql = """CREATE TABLE settings_table ( 336 | id UInt32, 337 | name String, 338 | created_at DateTime 339 | ) ENGINE = MergeTree() 340 | ORDER BY (id, created_at) 341 | PARTITION BY toYYYYMM(created_at)""" 342 | 343 | mock_execute.assert_called_once_with(expected_sql, None) 344 | mock_mark_applied.assert_called_once_with("20240101000000") 345 | mock_get_applied.assert_called_once() 346 | 347 | 348 | def test_migrate_up_with_production_table( 349 | houseplant, test_migration_with_table, mocker 350 | ): 351 | # Mock database calls 352 | houseplant.env = "production" 353 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 354 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 355 | mock_get_applied = mocker.patch.object( 356 | houseplant.db, "get_applied_migrations", return_value=[] 357 | ) 358 | 359 | # Run migration 360 | houseplant.migrate_up() 361 | 362 | # Verify correct SQL was executed with table_definition and table_settings 363 | expected_sql = """CREATE TABLE settings_table ON CLUSTER '{cluster}' ( 364 | id UInt32, 365 | name String, 366 | created_at DateTime 367 | ) ENGINE = MergeTree() 368 | ORDER BY (id, created_at) 369 | PARTITION BY toYYYYMM(created_at)""" 370 | 371 | mock_execute.assert_called_once_with(expected_sql, None) 372 | mock_mark_applied.assert_called_once_with("20240101000000") 373 | mock_get_applied.assert_called_once() 374 | 375 | 376 | def test_migrate_up_with_development_view(houseplant, test_migration_with_view, mocker): 377 | # Mock database calls 378 | houseplant.env = "development" 379 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 380 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 381 | mock_get_applied = mocker.patch.object( 382 | houseplant.db, "get_applied_migrations", return_value=[] 383 | ) 384 | 385 | # Run migration 386 | houseplant.migrate_up() 387 | 388 | # Verify correct SQL was executed with view_definition and view_query 389 | expected_sql = """CREATE MATERIALIZED VIEW settings_table 390 | TO sink_table ( 391 | id UInt32, 392 | name String, 393 | created_at DateTime 394 | ) 395 | AS SELECT * FROM events""" 396 | 397 | mock_execute.assert_called_once_with(expected_sql, None) 398 | mock_mark_applied.assert_called_once_with("20240101000000") 399 | mock_get_applied.assert_called_once() 400 | 401 | 402 | def test_migrate_up_with_production_view(houseplant, test_migration_with_view, mocker): 403 | # Mock database calls 404 | houseplant.env = "production" 405 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 406 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 407 | mock_get_applied = mocker.patch.object( 408 | houseplant.db, "get_applied_migrations", return_value=[] 409 | ) 410 | 411 | # Run migration 412 | houseplant.migrate_up() 413 | 414 | # Verify correct SQL was executed with view_definition and view_query 415 | expected_sql = """CREATE MATERIALIZED VIEW settings_table 416 | ON CLUSTER '{cluster}' 417 | TO sink_table ( 418 | id UInt32, 419 | name String, 420 | created_at DateTime 421 | ) 422 | AS SELECT * FROM events""" 423 | 424 | mock_execute.assert_called_once_with(expected_sql, None) 425 | mock_mark_applied.assert_called_once_with("20240101000000") 426 | mock_get_applied.assert_called_once() 427 | 428 | 429 | def test_update_schema_no_duplicates(houseplant, duplicate_migrations, mocker): 430 | versions = duplicate_migrations 431 | 432 | # Mock database calls 433 | mocker.patch.object( 434 | houseplant.db, 435 | "get_applied_migrations", 436 | return_value=[(versions[0],), (versions[1],)], 437 | ) 438 | mocker.patch.object( 439 | houseplant.db, "get_database_tables", return_value=[("events",)] 440 | ) 441 | mocker.patch.object( 442 | houseplant.db, "get_database_materialized_views", return_value=[] 443 | ) 444 | mocker.patch.object(houseplant.db, "get_database_dictionaries", return_value=[]) 445 | 446 | # Mock the SHOW CREATE TABLE call 447 | mocker.patch.object( 448 | houseplant.db.client, 449 | "execute", 450 | return_value=[ 451 | [ 452 | "CREATE TABLE events (id UInt32, name String) ENGINE = MergeTree() ORDER BY id" 453 | ] 454 | ], 455 | ) 456 | 457 | # Update schema 458 | houseplant.update_schema() 459 | 460 | # Read the generated schema file 461 | with open("ch/schema.sql", "r") as f: 462 | schema_content = f.read() 463 | 464 | # Verify the table appears only once in the schema 465 | table_count = schema_content.count("CREATE TABLE events") 466 | assert ( 467 | table_count == 1 468 | ), f"Table 'events' appears {table_count} times in schema, expected 1" 469 | 470 | 471 | def test_db_schema_load(houseplant, test_migration, mocker): 472 | # Mock database calls 473 | mock_mark_applied = mocker.patch.object(houseplant.db, "mark_migration_applied") 474 | 475 | # Run schema load 476 | houseplant.db_schema_load() 477 | 478 | # Verify migration was marked as applied without executing SQL 479 | mock_mark_applied.assert_called_once_with("20240101000000") 480 | 481 | 482 | @pytest.mark.skip 483 | def test_migrate_down(houseplant, test_migration, mocker): 484 | # Mock database calls 485 | mock_execute = mocker.patch.object(houseplant.db, "execute_migration") 486 | mock_mark_rolled_back = mocker.patch.object( 487 | houseplant.db, "mark_migration_rolled_back" 488 | ) 489 | mock_get_applied = mocker.patch.object( 490 | houseplant.db, "get_applied_migrations", return_value=[("20240101000000",)] 491 | ) 492 | 493 | # Roll back migration 494 | houseplant.migrate_down() 495 | 496 | # Verify correct SQL was executed 497 | mock_execute.assert_called_once_with("DROP TABLE events") 498 | mock_mark_rolled_back.assert_called_once_with("20240101000000") 499 | mock_get_applied.assert_called_once() 500 | 501 | 502 | def test_migrate_up_missing_version(houseplant, test_migration, mocker): 503 | # Mock database calls 504 | mock_get_applied = mocker.patch.object( 505 | houseplant.db, "get_applied_migrations", return_value=[] 506 | ) 507 | 508 | # Run migration with non-existent version 509 | houseplant.migrate_up("99999999999999") 510 | 511 | # Verify error message was printed 512 | mock_get_applied.assert_called_once() 513 | 514 | 515 | def test_migrate_up_missing_table_field(houseplant, tmp_path, mocker): 516 | # Mock database calls 517 | mocker.patch.object(houseplant.db, "execute_migration") 518 | mocker.patch.object(houseplant.db, "mark_migration_applied") 519 | mock_get_applied = mocker.patch.object( 520 | houseplant.db, "get_applied_migrations", return_value=[] 521 | ) 522 | 523 | # Set up test environment with invalid migration 524 | migrations_dir = tmp_path / "ch/migrations" 525 | migrations_dir.mkdir(parents=True) 526 | migration_file = migrations_dir / "20240101000000_invalid_migration.yml" 527 | 528 | migration_content = """version: "20240101000000" 529 | name: invalid_migration 530 | 531 | development: &development 532 | up: | 533 | CREATE TABLE events ( 534 | id UInt32, 535 | name String 536 | ) ENGINE = MergeTree() 537 | ORDER BY id 538 | down: | 539 | DROP TABLE events 540 | 541 | test: 542 | <<: *development 543 | 544 | production: 545 | <<: *development 546 | """ 547 | 548 | migration_file.write_text(migration_content) 549 | os.chdir(tmp_path) 550 | 551 | # Run migration 552 | houseplant.migrate_up() 553 | 554 | # Verify mocks were called appropriately 555 | mock_get_applied.assert_called_once() 556 | 557 | 558 | def test_migrate_down_no_migrations(houseplant, mocker): 559 | # Mock database calls 560 | mock_get_applied = mocker.patch.object( 561 | houseplant.db, "get_applied_migrations", return_value=[] 562 | ) 563 | 564 | # Run migration down 565 | houseplant.migrate_down() 566 | 567 | # Verify error message was printed 568 | mock_get_applied.assert_called_once() 569 | 570 | 571 | def test_migrate_down_missing_migration_file(houseplant, mocker): 572 | # Mock database calls to return a version with no corresponding file 573 | mock_get_applied = mocker.patch.object( 574 | houseplant.db, "get_applied_migrations", return_value=[("99999999999999",)] 575 | ) 576 | 577 | # Run migration down 578 | houseplant.migrate_down() 579 | 580 | # Verify warning was printed 581 | mock_get_applied.assert_called_once() 582 | 583 | 584 | def test_migrate_down_missing_table_field(houseplant, tmp_path, mocker): 585 | # Set up test environment with invalid migration 586 | migrations_dir = tmp_path / "ch/migrations" 587 | migrations_dir.mkdir(parents=True) 588 | migration_file = migrations_dir / "20240101000000_invalid_migration.yml" 589 | 590 | migration_content = """version: "20240101000000" 591 | name: invalid_migration 592 | 593 | development: &development 594 | up: | 595 | CREATE TABLE events ( 596 | id UInt32, 597 | name String 598 | ) ENGINE = MergeTree() 599 | ORDER BY id 600 | down: | 601 | DROP TABLE events 602 | 603 | test: 604 | <<: *development 605 | 606 | production: 607 | <<: *development 608 | """ 609 | 610 | migration_file.write_text(migration_content) 611 | os.chdir(tmp_path) 612 | 613 | # Mock database calls 614 | mock_get_applied = mocker.patch.object( 615 | houseplant.db, "get_applied_migrations", return_value=[("20240101000000",)] 616 | ) 617 | 618 | # Run migration down 619 | houseplant.migrate_down() 620 | 621 | # Verify error was handled 622 | mock_get_applied.assert_called_once() 623 | 624 | 625 | def test_check_migrations_dir_not_found(houseplant, tmp_path): 626 | # Change to empty directory 627 | os.chdir(tmp_path) 628 | 629 | # Verify SystemExit is raised when migrations dir not found 630 | with pytest.raises(SystemExit): 631 | houseplant._check_migrations_dir() 632 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.10" 3 | 4 | [[package]] 5 | name = "click" 6 | version = "8.1.7" 7 | source = { registry = "https://pypi.org/simple" } 8 | dependencies = [ 9 | { name = "colorama", marker = "platform_system == 'Windows'" }, 10 | ] 11 | sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } 12 | wheels = [ 13 | { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, 14 | ] 15 | 16 | [[package]] 17 | name = "clickhouse-driver" 18 | version = "0.2.9" 19 | source = { registry = "https://pypi.org/simple" } 20 | dependencies = [ 21 | { name = "pytz" }, 22 | { name = "tzlocal" }, 23 | ] 24 | sdist = { url = "https://files.pythonhosted.org/packages/bf/0b/3790274f7591fc55b1f91bcc8e576338859cc632b1b17288b5bab79b769d/clickhouse-driver-0.2.9.tar.gz", hash = "sha256:050ea4870ead993910b39e7fae965dc1c347b2e8191dcd977cd4b385f9e19f87", size = 357752 } 25 | wheels = [ 26 | { url = "https://files.pythonhosted.org/packages/bd/0a/e5bb8a527f88acc1ee2efca28354cf5eab40e26e974df2221ab27abf9b9a/clickhouse_driver-0.2.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ce04e9d0d0f39561f312d1ac1a8147bc9206e4267e1a23e20e0423ebac95534", size = 219246 }, 27 | { url = "https://files.pythonhosted.org/packages/e1/fd/8d057e77c8e7f1c739fcb76277edf83a863a259505031a2f8668d5bb2221/clickhouse_driver-0.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ae5c8931bf290b9d85582e7955b9aad7f19ff9954e48caa4f9a180ea4d01078", size = 215395 }, 28 | { url = "https://files.pythonhosted.org/packages/8e/71/fa053b0327a8e7cdd3f69e3f01a5683f901e2b1388f057db9d39de86f016/clickhouse_driver-0.2.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e51792f3bd12c32cb15a907f12de3c9d264843f0bb33dce400e3966c9f09a3f", size = 923279 }, 29 | { url = "https://files.pythonhosted.org/packages/a2/a7/9be2e1a40543959c9c008bf01ae54aef793153eeef9885fca8a16ab677b8/clickhouse_driver-0.2.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42fc546c31e4a04c97b749769335a679c9044dc693fa7a93e38c97fd6727173d", size = 967909 }, 30 | { url = "https://files.pythonhosted.org/packages/7f/ec/16b320623a30de3b62f37f7aec4d33fc10a09bd095160346abd288a71822/clickhouse_driver-0.2.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a383a403d185185c64e49edd6a19b2ec973c5adcb8ebff7ed2fc539a2cc65a5", size = 965883 }, 31 | { url = "https://files.pythonhosted.org/packages/df/8b/1997d8c3f88127441229c8080b5701e3d8b6ffd591a0890d6c518f8b1355/clickhouse_driver-0.2.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f05321a97e816afc75b3e4f9eda989848fecf14ecf1a91d0f22c04258123d1f7", size = 935935 }, 32 | { url = "https://files.pythonhosted.org/packages/ca/60/1751a1ef2b70026c6c0a5d9b7e31a19bcd54dcc6b245de5d3db2034108cc/clickhouse_driver-0.2.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be47e793846aac28442b6b1c6554e0731b848a5a7759a54aa2489997354efe4a", size = 911710 }, 33 | { url = "https://files.pythonhosted.org/packages/a8/e0/05697a02f3e388c64c57ae5bddc5deb45f5802d0d62a10c633965a3dad8b/clickhouse_driver-0.2.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:780e42a215d1ae2f6d695d74dd6f087781fb2fa51c508b58f79e68c24c5364e0", size = 933821 }, 34 | { url = "https://files.pythonhosted.org/packages/7e/44/650ea0e3bfaa587b062a3da60233d061e568b412c21e429a116ce7134023/clickhouse_driver-0.2.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9e28f1fe850675e173db586e9f1ac790e8f7edd507a4227cd54cd7445f8e75b6", size = 920276 }, 35 | { url = "https://files.pythonhosted.org/packages/fb/66/0991e9f6a14838c900446b258ba55ef47a79bc224bf74c23c92dd157ad7d/clickhouse_driver-0.2.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:125aae7f1308d3083dadbb3c78f828ae492e060f13e4007a0cf53a8169ed7b39", size = 973511 }, 36 | { url = "https://files.pythonhosted.org/packages/8b/01/733d6f55344093a2b87d05db864df9044e756c13894c19000be3535b062b/clickhouse_driver-0.2.9-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:2f3c4fbb61e75c62a1ab93a1070d362de4cb5682f82833b2c12deccb3bae888d", size = 980838 }, 37 | { url = "https://files.pythonhosted.org/packages/63/0c/5dba2a82fe1701c7f0db88a1d7d08134a8c80192f2548871b87c54b066c3/clickhouse_driver-0.2.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dc03196a84e32d23b88b665be69afae98f57426f5fdf203e16715b756757961", size = 950460 }, 38 | { url = "https://files.pythonhosted.org/packages/5b/24/08f1d0afceaf5e2f325d851add6b9f80bd71c6c540dc29a1197d8d37eadb/clickhouse_driver-0.2.9-cp310-cp310-win32.whl", hash = "sha256:25695d78a1d7ad6e221e800612eac08559f6182bf6dee0a220d08de7b612d993", size = 198432 }, 39 | { url = "https://files.pythonhosted.org/packages/73/9b/6c110f95530c26b9297bf204e487e645c90eb28dd3ca2cf40ac8ae61e4b4/clickhouse_driver-0.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:367acac95398d721a0a2a6cf87e93638c5588b79498a9848676ce7f182540a6c", size = 213541 }, 40 | { url = "https://files.pythonhosted.org/packages/53/e9/8bf043fbac34e06eebb7d58647c1a28616bf065cdc3334c8d771902c8902/clickhouse_driver-0.2.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a7353a7a08eee3aa0001d8a5d771cb1f37e2acae1b48178002431f23892121a", size = 220413 }, 41 | { url = "https://files.pythonhosted.org/packages/88/6d/e8b534bee11c809f52b1c9d77160da48a3d84c775d7e547a73f2d2ba1f9f/clickhouse_driver-0.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6af1c6cbc3481205503ab72a34aa76d6519249c904aa3f7a84b31e7b435555be", size = 215881 }, 42 | { url = "https://files.pythonhosted.org/packages/f0/5e/894f789b47a4deaff0a84b2660323b4b3e692c3ff5cc0783d59a7945b50d/clickhouse_driver-0.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48033803abd1100bfff6b9a1769d831b672cd3cda5147e0323b956fd1416d38d", size = 1015584 }, 43 | { url = "https://files.pythonhosted.org/packages/ab/91/951e0aa20d110f8d801023fec28c9cbbb0cfa9c596170e732e66bb30bf9d/clickhouse_driver-0.2.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f202a58a540c85e47c31dabc8f84b6fe79dca5315c866450a538d58d6fa0571", size = 1058755 }, 44 | { url = "https://files.pythonhosted.org/packages/7a/25/2ff96df78d078150e5bc01307657a21b8bd29f040c11bffed7841228eabe/clickhouse_driver-0.2.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4df50fd84bfa4aa1eb7b52d48136066bfb64fabb7ceb62d4c318b45a296200b", size = 1058250 }, 45 | { url = "https://files.pythonhosted.org/packages/f7/08/1b08d596dab964bd306a45d4d3fe84c1740888d5a392374621a5fac8f186/clickhouse_driver-0.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:433a650571a0d7766eb6f402e8f5930222997686c2ee01ded22f1d8fd46af9d4", size = 1026785 }, 46 | { url = "https://files.pythonhosted.org/packages/8e/34/8b34c729719617373f645ad4cdff38ff7f7903b417208ad97859407edb3f/clickhouse_driver-0.2.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:232ee260475611cbf7adb554b81db6b5790b36e634fe2164f4ffcd2ca3e63a71", size = 988253 }, 47 | { url = "https://files.pythonhosted.org/packages/f0/b0/d36ec521179b7d2966831ae875e2049800c653d6a6697e96c3143e09b66c/clickhouse_driver-0.2.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:09049f7e71f15c9c9a03f597f77fc1f7b61ababd155c06c0d9e64d1453d945d7", size = 1016946 }, 48 | { url = "https://files.pythonhosted.org/packages/0f/90/74e9cc608a9c0aca8fc58e3fa1dc5f8dda4e0c00bd3d4be8c9abd31a4e54/clickhouse_driver-0.2.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:424153d1d5f5a807f596a48cc88119f9fb3213ca7e38f57b8d15dcc964dd91f7", size = 993030 }, 49 | { url = "https://files.pythonhosted.org/packages/29/b5/7831b1eefb8450e12b263cc8d9e60615e361d698485ffc66fd8704145381/clickhouse_driver-0.2.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4f078fd1cf19c4ca63b8d1e0803df665310c8d5b644c5b02bf2465e8d6ef8f55", size = 1053397 }, 50 | { url = "https://files.pythonhosted.org/packages/fc/d2/4c2206105f7e8d5a06b1aea6ea345fadf42d299f0b642b89b86c3be8b726/clickhouse_driver-0.2.9-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f138d939e26e767537f891170b69a55a88038919f5c10d8865b67b8777fe4848", size = 1061742 }, 51 | { url = "https://files.pythonhosted.org/packages/10/1c/0951208141bb26681c06ea0c84a0fe5e4374b1560c38abe8b5f93da6bbb5/clickhouse_driver-0.2.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9aafabc7e32942f85dcb46f007f447ab69024831575df97cae28c6ed127654d1", size = 1031183 }, 52 | { url = "https://files.pythonhosted.org/packages/23/94/2e533b5638fddb6abfb3f3de41f9938445661aae781209c5d6634d53b40f/clickhouse_driver-0.2.9-cp311-cp311-win32.whl", hash = "sha256:935e16ebf1a1998d8493979d858821a755503c9b8af572d9c450173d4b88868c", size = 198031 }, 53 | { url = "https://files.pythonhosted.org/packages/45/87/163e4469d298d5f74a61328a129494c84404932ae25d7c585b172609def7/clickhouse_driver-0.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:306b3102cba278b5dfec6f5f7dc8b78416c403901510475c74913345b56c9e42", size = 213510 }, 54 | { url = "https://files.pythonhosted.org/packages/60/58/acc74be412330aa4d681df2d13e013e84e27cc767dea73a507cb71c74cff/clickhouse_driver-0.2.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fcb2fd00e58650ae206a6d5dbc83117240e622471aa5124733fbf2805eb8bda0", size = 221487 }, 55 | { url = "https://files.pythonhosted.org/packages/f5/bc/09b69a1be0155e02a0df9ecafb63c9a2f7d9e412c865dd3c711e07967e85/clickhouse_driver-0.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7a3e6b0a1eb218e3d870a94c76daaf65da46dca8f6888ea6542f94905c24d88", size = 217362 }, 56 | { url = "https://files.pythonhosted.org/packages/ed/58/79eadc238d6ee0d7920ae36f0ea3a6479a8310bffb6c97ea6aa060a98434/clickhouse_driver-0.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a8d8e2888a857d8db3d98765a5ad23ab561241feaef68bbffc5a0bd9c142342", size = 1018173 }, 57 | { url = "https://files.pythonhosted.org/packages/44/61/1647a0d8aae2c4a3d8c3093d1799f943ff38a0cb81d5e4050be18993f3fa/clickhouse_driver-0.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85d50c011467f5ff6772c4059345968b854b72e07a0219030b7c3f68419eb7f7", size = 1046542 }, 58 | { url = "https://files.pythonhosted.org/packages/77/23/32bab0efeec64d56313b90c73d067440829630f9a5980de73cb52350a4c9/clickhouse_driver-0.2.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:93b395c1370629ccce8fb3e14cd5be2646d227bd32018c21f753c543e9a7e96b", size = 1057508 }, 59 | { url = "https://files.pythonhosted.org/packages/07/8e/9b79fd85d28a9e83b87a8722a8e736d69ef5edde8cee5d1dde6950aa512f/clickhouse_driver-0.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbcee870c60d9835e5dce1456ab6b9d807e6669246357f4b321ef747b90fa43", size = 1032860 }, 60 | { url = "https://files.pythonhosted.org/packages/bd/ca/208358dd8d80a25633b5f19a9acadb1fb23b55be7f2123e5e70d132de204/clickhouse_driver-0.2.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fffa5a5f317b1ec92e406a30a008929054cf3164d2324a3c465d0a0330273bf8", size = 984133 }, 61 | { url = "https://files.pythonhosted.org/packages/19/e1/9767cea5bfc9451b7a2680d5b0d4bd3261c56db92002f90ce716209f59c1/clickhouse_driver-0.2.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:476702740a279744badbd177ae1c4a2d089ec128bd676861219d1f92078e4530", size = 1020897 }, 62 | { url = "https://files.pythonhosted.org/packages/f5/bc/62511b61fbee97c8ab1c64ab4bf33045bcc132d236e61a65831c0de32b82/clickhouse_driver-0.2.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5cd6d95fab5ff80e9dc9baedc9a926f62f74072d42d5804388d63b63bec0bb63", size = 989911 }, 63 | { url = "https://files.pythonhosted.org/packages/a0/a8/e3ff5cbc24dbc087acf0733c47fe7a6a6a2f3225e9c168af2414fb803f3c/clickhouse_driver-0.2.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:05027d32d7cf3e46cb8d04f8c984745ae01bd1bc7b3579f9dadf9b3cca735697", size = 1045389 }, 64 | { url = "https://files.pythonhosted.org/packages/d6/58/29f56e340094cfec72080773e3d94c7963c2e69f70edff83f2a139965d38/clickhouse_driver-0.2.9-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:3d11831842250b4c1b26503a6e9c511fc03db096608b7c6af743818c421a3032", size = 1063242 }, 65 | { url = "https://files.pythonhosted.org/packages/ff/0f/161626812ad2bd9480ff390a96489983709d94b33da68f028ace9d1367be/clickhouse_driver-0.2.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:81b4b671b785ebb0b8aeabf2432e47072413d81db959eb8cfd8b6ab58c5799c6", size = 1039703 }, 66 | { url = "https://files.pythonhosted.org/packages/4a/29/e353c4e835d722b4f6b259d668c2ac47f35bf6a0053414a80522df649ff5/clickhouse_driver-0.2.9-cp312-cp312-win32.whl", hash = "sha256:e893bd4e014877174a59e032b0e99809c95ec61328a0e6bd9352c74a2f6111a8", size = 198390 }, 67 | { url = "https://files.pythonhosted.org/packages/5b/09/ff81e99e9ecbb85f2ada57a690b1d0cfee6f2e1eff59ee08609a160d5644/clickhouse_driver-0.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:de6624e28eeffd01668803d28ae89e3d4e359b1bff8b60e4933e1cb3c6f86f18", size = 213585 }, 68 | { url = "https://files.pythonhosted.org/packages/76/7a/961029af713ae42e43f2691abbcee248ed057229d24613d693260edbcb32/clickhouse_driver-0.2.9-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:45a3d5b1d06750fd6a18c29b871494a2635670099ec7693e756a5885a4a70dbf", size = 197672 }, 69 | { url = "https://files.pythonhosted.org/packages/5b/d4/1da419cb98e829bbacea4c07e184a747804f11543ff976c6539f4fbd62f2/clickhouse_driver-0.2.9-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8415ffebd6ca9eef3024763abc450f8659f1716d015bd563c537d01c7fbc3569", size = 220898 }, 70 | { url = "https://files.pythonhosted.org/packages/86/d6/f9042e885f4510ab3a09ef2f5d1acfd40f328794dff66ae12e52954cda04/clickhouse_driver-0.2.9-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace48db993aa4bd31c42de0fa8d38c94ad47405916d6b61f7a7168a48fb52ac1", size = 228801 }, 71 | { url = "https://files.pythonhosted.org/packages/82/62/88ece5b7ca32d8a4bb4b1d0250cb54d1cfb1e7abfc759355d1fb51fda23a/clickhouse_driver-0.2.9-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b07123334fe143bfe6fa4e3d4b732d647d5fd2cfb9ec7f2f76104b46fe9d20c6", size = 234107 }, 72 | { url = "https://files.pythonhosted.org/packages/b2/ba/af473c973b853a9de1312d73f929f39e17424bd723966b398b898188a13a/clickhouse_driver-0.2.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2af3efa73d296420ce6362789f5b1febf75d4aa159a479393f01549115509d5", size = 199442 }, 73 | ] 74 | 75 | [[package]] 76 | name = "colorama" 77 | version = "0.4.6" 78 | source = { registry = "https://pypi.org/simple" } 79 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 80 | wheels = [ 81 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 82 | ] 83 | 84 | [[package]] 85 | name = "coverage" 86 | version = "7.6.8" 87 | source = { registry = "https://pypi.org/simple" } 88 | sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } 89 | wheels = [ 90 | { url = "https://files.pythonhosted.org/packages/31/86/6ed22e101badc8eedf181f0c2f65500df5929c44c79991cf45b9bf741424/coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50", size = 206988 }, 91 | { url = "https://files.pythonhosted.org/packages/3b/04/16853c58bacc02b3ff5405193dfc6c66632442d931b23dd7b9452dc55cf3/coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf", size = 207418 }, 92 | { url = "https://files.pythonhosted.org/packages/f8/eb/8a91520d04215eb549d6a7d7d3a79cbb1d78b5dd0814f4b23bf97521d580/coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee", size = 235860 }, 93 | { url = "https://files.pythonhosted.org/packages/00/10/bf1ede5b54ae1bbf39921a5dd4cc84aee79041ed301ec8955064785ddb90/coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6", size = 233766 }, 94 | { url = "https://files.pythonhosted.org/packages/5c/ea/741d9233eb502906e0d18ccf4c15c4fb74ff0e85fd8ee967590194b889a1/coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d", size = 234924 }, 95 | { url = "https://files.pythonhosted.org/packages/18/43/b2cfd4413a5b64ab27c289228b0c45b4527d1b99381cc9d6a00bfd515da4/coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331", size = 234019 }, 96 | { url = "https://files.pythonhosted.org/packages/8e/95/8b2fbb9d1a79277963b6095cd51a90fb7088cd3618faf75550038331f78b/coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638", size = 232481 }, 97 | { url = "https://files.pythonhosted.org/packages/4d/d7/9e939508a39ef67605b715ca89c6522214aceb27c2db9152ae3ae1cf8626/coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed", size = 233609 }, 98 | { url = "https://files.pythonhosted.org/packages/ba/e2/1c5fb52eafcffeebaa9db084bff47e7c3cf4f97db752226c232cee4d530b/coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e", size = 209669 }, 99 | { url = "https://files.pythonhosted.org/packages/31/31/6a56469609a252549dd4b090815428d5521edd4642440d987573a450c069/coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a", size = 210509 }, 100 | { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 }, 101 | { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 }, 102 | { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 }, 103 | { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 }, 104 | { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 }, 105 | { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 }, 106 | { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 }, 107 | { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 }, 108 | { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 }, 109 | { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 }, 110 | { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, 111 | { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, 112 | { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, 113 | { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, 114 | { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, 115 | { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, 116 | { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, 117 | { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, 118 | { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, 119 | { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, 120 | { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, 121 | { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, 122 | { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, 123 | { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, 124 | { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, 125 | { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, 126 | { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, 127 | { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, 128 | { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, 129 | { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, 130 | { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, 131 | { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, 132 | { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, 133 | { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, 134 | { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, 135 | { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, 136 | { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, 137 | { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, 138 | { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, 139 | { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, 140 | { url = "https://files.pythonhosted.org/packages/32/df/0d2476121cd0bfb9ca2413efe02289c474b82c4b134863bef4b89ec7bcfa/coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce", size = 199230 }, 141 | ] 142 | 143 | [package.optional-dependencies] 144 | toml = [ 145 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 146 | ] 147 | 148 | [[package]] 149 | name = "exceptiongroup" 150 | version = "1.2.2" 151 | source = { registry = "https://pypi.org/simple" } 152 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 153 | wheels = [ 154 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 155 | ] 156 | 157 | [[package]] 158 | name = "houseplant" 159 | version = "0.2.2" 160 | source = { editable = "." } 161 | dependencies = [ 162 | { name = "clickhouse-driver" }, 163 | { name = "python-dotenv" }, 164 | { name = "pyyaml" }, 165 | { name = "ruff" }, 166 | { name = "typer" }, 167 | ] 168 | 169 | [package.optional-dependencies] 170 | dev = [ 171 | { name = "coverage" }, 172 | { name = "isort" }, 173 | { name = "mypy" }, 174 | { name = "pytest" }, 175 | { name = "pytest-cov" }, 176 | { name = "pytest-mock" }, 177 | { name = "ruff" }, 178 | ] 179 | 180 | [package.metadata] 181 | requires-dist = [ 182 | { name = "clickhouse-driver", specifier = ">=0.2.9" }, 183 | { name = "coverage", marker = "extra == 'dev'" }, 184 | { name = "isort", marker = "extra == 'dev'" }, 185 | { name = "mypy", marker = "extra == 'dev'" }, 186 | { name = "pytest", marker = "extra == 'dev'" }, 187 | { name = "pytest-cov", marker = "extra == 'dev'" }, 188 | { name = "pytest-mock", marker = "extra == 'dev'" }, 189 | { name = "python-dotenv", specifier = ">=1.0.1" }, 190 | { name = "pyyaml", specifier = ">=6.0.2" }, 191 | { name = "ruff", specifier = ">=0.7.4" }, 192 | { name = "ruff", marker = "extra == 'dev'" }, 193 | { name = "typer", specifier = ">=0.14.0" }, 194 | ] 195 | 196 | [[package]] 197 | name = "iniconfig" 198 | version = "2.0.0" 199 | source = { registry = "https://pypi.org/simple" } 200 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 201 | wheels = [ 202 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 203 | ] 204 | 205 | [[package]] 206 | name = "isort" 207 | version = "5.13.2" 208 | source = { registry = "https://pypi.org/simple" } 209 | sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 } 210 | wheels = [ 211 | { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 }, 212 | ] 213 | 214 | [[package]] 215 | name = "markdown-it-py" 216 | version = "3.0.0" 217 | source = { registry = "https://pypi.org/simple" } 218 | dependencies = [ 219 | { name = "mdurl" }, 220 | ] 221 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 222 | wheels = [ 223 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 224 | ] 225 | 226 | [[package]] 227 | name = "mdurl" 228 | version = "0.1.2" 229 | source = { registry = "https://pypi.org/simple" } 230 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 231 | wheels = [ 232 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 233 | ] 234 | 235 | [[package]] 236 | name = "mypy" 237 | version = "1.13.0" 238 | source = { registry = "https://pypi.org/simple" } 239 | dependencies = [ 240 | { name = "mypy-extensions" }, 241 | { name = "tomli", marker = "python_full_version < '3.11'" }, 242 | { name = "typing-extensions" }, 243 | ] 244 | sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } 245 | wheels = [ 246 | { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, 247 | { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, 248 | { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, 249 | { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, 250 | { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, 251 | { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, 252 | { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, 253 | { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, 254 | { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, 255 | { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, 256 | { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, 257 | { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, 258 | { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, 259 | { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, 260 | { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, 261 | { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, 262 | { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, 263 | { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, 264 | { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, 265 | { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, 266 | { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, 267 | ] 268 | 269 | [[package]] 270 | name = "mypy-extensions" 271 | version = "1.0.0" 272 | source = { registry = "https://pypi.org/simple" } 273 | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } 274 | wheels = [ 275 | { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, 276 | ] 277 | 278 | [[package]] 279 | name = "packaging" 280 | version = "24.2" 281 | source = { registry = "https://pypi.org/simple" } 282 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 283 | wheels = [ 284 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 285 | ] 286 | 287 | [[package]] 288 | name = "pluggy" 289 | version = "1.5.0" 290 | source = { registry = "https://pypi.org/simple" } 291 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 292 | wheels = [ 293 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 294 | ] 295 | 296 | [[package]] 297 | name = "pygments" 298 | version = "2.18.0" 299 | source = { registry = "https://pypi.org/simple" } 300 | sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } 301 | wheels = [ 302 | { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, 303 | ] 304 | 305 | [[package]] 306 | name = "pytest" 307 | version = "8.3.4" 308 | source = { registry = "https://pypi.org/simple" } 309 | dependencies = [ 310 | { name = "colorama", marker = "sys_platform == 'win32'" }, 311 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 312 | { name = "iniconfig" }, 313 | { name = "packaging" }, 314 | { name = "pluggy" }, 315 | { name = "tomli", marker = "python_full_version < '3.11'" }, 316 | ] 317 | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } 318 | wheels = [ 319 | { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, 320 | ] 321 | 322 | [[package]] 323 | name = "pytest-cov" 324 | version = "6.0.0" 325 | source = { registry = "https://pypi.org/simple" } 326 | dependencies = [ 327 | { name = "coverage", extra = ["toml"] }, 328 | { name = "pytest" }, 329 | ] 330 | sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } 331 | wheels = [ 332 | { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, 333 | ] 334 | 335 | [[package]] 336 | name = "pytest-mock" 337 | version = "3.14.0" 338 | source = { registry = "https://pypi.org/simple" } 339 | dependencies = [ 340 | { name = "pytest" }, 341 | ] 342 | sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } 343 | wheels = [ 344 | { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, 345 | ] 346 | 347 | [[package]] 348 | name = "python-dotenv" 349 | version = "1.0.1" 350 | source = { registry = "https://pypi.org/simple" } 351 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 352 | wheels = [ 353 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 354 | ] 355 | 356 | [[package]] 357 | name = "pytz" 358 | version = "2024.2" 359 | source = { registry = "https://pypi.org/simple" } 360 | sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } 361 | wheels = [ 362 | { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, 363 | ] 364 | 365 | [[package]] 366 | name = "pyyaml" 367 | version = "6.0.2" 368 | source = { registry = "https://pypi.org/simple" } 369 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 370 | wheels = [ 371 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, 372 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, 373 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, 374 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, 375 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, 376 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, 377 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, 378 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, 379 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, 380 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 381 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 382 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 383 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 384 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 385 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 386 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 387 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 388 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 389 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 390 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 391 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 392 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 393 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 394 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 395 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 396 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 397 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 398 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 399 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 400 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 401 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 402 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 403 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 404 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 405 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 406 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 407 | ] 408 | 409 | [[package]] 410 | name = "rich" 411 | version = "13.9.4" 412 | source = { registry = "https://pypi.org/simple" } 413 | dependencies = [ 414 | { name = "markdown-it-py" }, 415 | { name = "pygments" }, 416 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 417 | ] 418 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 419 | wheels = [ 420 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 421 | ] 422 | 423 | [[package]] 424 | name = "ruff" 425 | version = "0.8.1" 426 | source = { registry = "https://pypi.org/simple" } 427 | sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 } 428 | wheels = [ 429 | { url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 }, 430 | { url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 }, 431 | { url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 }, 432 | { url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 }, 433 | { url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 }, 434 | { url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 }, 435 | { url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 }, 436 | { url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 }, 437 | { url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 }, 438 | { url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 }, 439 | { url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 }, 440 | { url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 }, 441 | { url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 }, 442 | { url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 }, 443 | { url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 }, 444 | { url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 }, 445 | { url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 }, 446 | ] 447 | 448 | [[package]] 449 | name = "shellingham" 450 | version = "1.5.4" 451 | source = { registry = "https://pypi.org/simple" } 452 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 453 | wheels = [ 454 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 455 | ] 456 | 457 | [[package]] 458 | name = "tomli" 459 | version = "2.2.1" 460 | source = { registry = "https://pypi.org/simple" } 461 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 462 | wheels = [ 463 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, 464 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, 465 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, 466 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, 467 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, 468 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, 469 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, 470 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, 471 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 472 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 473 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, 474 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, 475 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, 476 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, 477 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, 478 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, 479 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, 480 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, 481 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 482 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 483 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, 484 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, 485 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, 486 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, 487 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, 488 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, 489 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, 490 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, 491 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 492 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 493 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 494 | ] 495 | 496 | [[package]] 497 | name = "typer" 498 | version = "0.14.0" 499 | source = { registry = "https://pypi.org/simple" } 500 | dependencies = [ 501 | { name = "click" }, 502 | { name = "rich" }, 503 | { name = "shellingham" }, 504 | { name = "typing-extensions" }, 505 | ] 506 | sdist = { url = "https://files.pythonhosted.org/packages/0d/7e/24af5b9aaa0872f9f6dc5dcf789dc3e57ceb23b4c570b852cd4db0d98f14/typer-0.14.0.tar.gz", hash = "sha256:af58f737f8d0c0c37b9f955a6d39000b9ff97813afcbeef56af5e37cf743b45a", size = 98836 } 507 | wheels = [ 508 | { url = "https://files.pythonhosted.org/packages/bb/d8/a3ab71d5587b42b832a7ef2e65b3e51a18f8da32b6ce169637d4d21995ed/typer-0.14.0-py3-none-any.whl", hash = "sha256:f476233a25770ab3e7b2eebf7c68f3bc702031681a008b20167573a4b7018f09", size = 44707 }, 509 | ] 510 | 511 | [[package]] 512 | name = "typing-extensions" 513 | version = "4.12.2" 514 | source = { registry = "https://pypi.org/simple" } 515 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 516 | wheels = [ 517 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 518 | ] 519 | 520 | [[package]] 521 | name = "tzdata" 522 | version = "2024.2" 523 | source = { registry = "https://pypi.org/simple" } 524 | sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } 525 | wheels = [ 526 | { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, 527 | ] 528 | 529 | [[package]] 530 | name = "tzlocal" 531 | version = "5.2" 532 | source = { registry = "https://pypi.org/simple" } 533 | dependencies = [ 534 | { name = "tzdata", marker = "platform_system == 'Windows'" }, 535 | ] 536 | sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 } 537 | wheels = [ 538 | { url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 }, 539 | ] 540 | --------------------------------------------------------------------------------