diff --git a/.dockerignore b/.dockerignore index 92f9f6820..eb2d768a1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,24 @@ +services/ +!/services/GlobalSign.crt + +build/ +dist/ +.cache/ + data* data/ *.sock -*.egg-info/, +*.egg-info/ */__pycache__/ .git/ */node_modules/ +*/.npm/ */build/ .dockerignore Dockerfile +*.yml + __pycache__ *.pyc *.pyo @@ -16,3 +26,6 @@ __pycache__ .cache *.log .git + +*/.venv +*/.cache \ No newline at end of file diff --git a/.envrc b/.envrc deleted file mode 100755 index 84d5e7f16..000000000 --- a/.envrc +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -export LANG=en_US.utf8 -export LC_ALL=en_US.utf8 - -# Load secrets tokens (gitlab, jenkins) used to start the backend -if [[ -r $HOME/.tokens ]]; then - source $HOME/.tokens -fi - -# Enable building with SSH agent forwarding -export DOCKER_BUILDKIT=1 - -# Makes it easier to run the unit tests -export PYTHONPATH=$(pwd) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..78d3d077d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. Linux] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..bbcbbe7d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..9f41eccdf --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,91 @@ +--- +# https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions +# https://help.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions +# https://help.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token +# https://hynek.me/articles/python-github-actions/ +# https://medium.com/swlh/fast-docker-build-in-kubernetes-f52088854f45 + +name: CI +on: [push, pull_request] + +env: + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 + +jobs: + code-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 backend qaboard --count --select=E9,F63,F7,F82 --show-source --statistics + # --exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 backend qaboard --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Type Checks with MyPy + run: mypy -p qaboard --ignore-missing-imports + - name: Unit Tests + run: | + # git is needed in the tests and setup to fail if not setup + git config --global user.name "Arthur Flam" + git config --global user.email "arthur.flam@samsung.com" + green -vvv --quiet-stdout + + build-webapp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + - uses: actions/cache@v4 + with: + path: ~/.npm + key: node-${{ hashFiles('webapp/npm-shrinkwrap.json') }} + restore-keys: ${{ runner.os }}-node- + - run: cd webapp && npm ci + - run: cd webapp && npm run build --if-present + # - run: cd webapp && npm test + + test-docker-images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build the docker compose stack + run: docker compose build --build-arg BUILDKIT_INLINE_CACHE=1 + - name: Up the docker compose stack + run: docker compose up -d + - name: Check running containers + run: docker ps -a + - name: Check logs + run: sleep 10 && docker compose logs + - name: Smoke Test @app + run: curl -s --retry 10 --retry-connrefused http://localhost:5151/ + - name: sleep 10 && Smoke Test @api + run: sleep 10 && curl -s --retry 10 --retry-connrefused http://localhost:5151/api/v1/projects + + publish-docker-images: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + needs: + - code-checks + - build-webapp + - test-docker-images + steps: + - uses: actions/checkout@v4 + - name: Build the docker compose stack + run: docker compose build --build-arg BUILDKIT_INLINE_CACHE=1 + - name: Publish images + run: | + echo ${{ secrets.GITHUB_TOKEN }} | docker login https://docker.pkg.github.com -u arthur-flam --password-stdin + docker compose push diff --git a/qaboard-backend/slamvizapp/api/__init__.py b/.github/workflows/publish.yaml similarity index 100% rename from qaboard-backend/slamvizapp/api/__init__.py rename to .github/workflows/publish.yaml diff --git a/.github/workflows/pypy.yml b/.github/workflows/pypy.yml new file mode 100644 index 000000000..b97b9e6c6 --- /dev/null +++ b/.github/workflows/pypy.yml @@ -0,0 +1,27 @@ +# https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 000000000..35af5cf34 --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,38 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [master] + paths: [website/**] + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18.x + cache: yarn + cache-dependency-path: website/yarn.lock + - name: Build website + working-directory: website + run: | + yarn install --frozen-lockfile + yarn build + + # Popular action to deploy to GitHub Pages: + # Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Build output to publish to the `gh-pages` branch: + publish_dir: ./website/build + # Assign commit authorship to the official GH-Actions bot for deploys to `gh-pages` branch: + # https://github.com/actions/checkout/issues/13#issuecomment-724415212 + # The GH actions bot is used by default if you didn't specify the two fields. + # You can swap them out with your own user credentials. + user_name: github-actions[bot] + user_email: 41898282+github-actions[bot]@users.noreply.github.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 193122f29..fb30e4e0e 100755 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,28 @@ +certs/ +saml/*.json +passwd* +*.bkp +.config/configstore/ +*.patch +.npm/ +migration/ +.npm/ +cache.*.json +user-quotas.json build/ build_qadocs/ log.lsf.txt log.txt ssl/ +commits/ +user/ +drafts/ + +backend/data/ + +image.batches.yaml +iter.batches.yaml +sub.batches.yaml *.png *.jpg @@ -10,6 +30,8 @@ ssl/ *.svg *.gif +# ssh-agent files to build container images +ssh-agent.pid # recommended by docusaurus # generated files @@ -24,6 +46,12 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.vscode + +# generated by tests. TODO: use /tmp +qaboard/sample_project/image.batches.yaml + +.DS_Store # Created by https://www.gitignore.io/api/linux,python,windows,sublimetext,visualstudio diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c258a4f35..f83681a95 100755 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,102 +1,123 @@ -# TODO: Replace with a different CI for github.com... +default: + tags: [lsf] + +variables: + SIRC_LSF_QUEUE: alg_isp_q + SIRC_USER: ispq + # https://docs.gitlab.com/ee/ci/yaml/#git-clean-flags + GIT_CLEAN_FLAGS: -ffdx --exclude=webapp/node_modules/ + GIT_SUBMODULE_STRATEGY: recursive + stages: - test - deploy # Internally - publish # To Pypi before_script: - - source .envrc + - source ./.envrc + # Use ssh-agent to build container images that access private repositories + - eval $(ssh-agent -s) + - ssh-add $HOME/.ssh/id_rsa + - echo "${SSH_AGENT_PID}" > ssh-agent.pid +after_script: + # don't leave ssh agents running + - if [ -f ssh-agent.pid ] ; then kill $(cat ssh-agent.pid) ; fi -unit:tests: + +unit-tests: stage: test + retry: 2 # solves frequent filesystem sync issues script: - - python -munittest + - green -vvv --quiet-stdout --run-coverage -cli:tests: +type-check: stage: test - retry: 2 # solves frequent filesystem sync issues script: - # TODO: turn into a standalone script / tests. - - cd qatools/sample_project - - python -mqatools --help - - mkdir -p cli_tests/dir; touch cli_tests/a.jpg; touch cli_tests/b.jpg; touch cli_tests/dir/c.jpg - - export QA_DATABASE=$(pwd) - - export QA_OFFLINE=true - - python -mqatools run -i cli_tests/a.jpg 'echo "{absolute_input_path} => {output_directory}"' - # expect 1 run - - python -mqatools --dryrun batch cli_tests - # expect 3 runs - - sed -i 's/# globs:/globs:/' qatools.yaml - - python -mqatools --dryrun batch cli_tests --list | jq '.' - - python -mqatools batch cli_tests 'echo "{absolute_input_path} => {output_directory}"' - - python -mqatools batch --runner=local cli_tests 'echo "{absolute_input_path} => {output_directory}"' - # other CLI tests - # - python -mqatools save-artifacts # Gitlab: 404: Project not found - - python -mqatools get commit_id + - mypy -p qaboard --ignore-missing-imports + +lint: + stage: test + script: + - flake8 backend qaboard --count --select=E9,F63,F7,F82 --show-source --statistics + deploy:qa: stage: deploy only: - master script: - - pip install . + - export PROXY_STUFF='--trusted-host pypi.org --trusted-host files.pythonhosted.org --trusted-host pypi.python.org' + - umask 022 + - pip install $PROXY_STUFF . +deploy:qa:windows: + stage: deploy + only: + - master + before_script: [] # Overrides the global before_script since it changes LD_LIBRARY_PATH which causes curl to fail + script: + - - 'curl --header "Content-Type: application/json" --request POST --data ''{"build_url": "http://jenmaster1:8080/job/ALGO/job/Install_qaboard_on_windows_nodes", "cause": "cde-python updated", "params": {}}'' http://qa/api/v1/jenkins/build/trigger/' publish:PyPi: stage: publish when: manual script: - - cd qatools - - python setup.py sdist bdist_wheel - - twine upload --verbose -u __token__ dist/* + - ssh arthurf@planet31 'cd qaboard; twine upload --verbose -u __token__ dist/*' -variables: - DOCKER_IMAGE: qaboard - # https://docs.gitlab.com/ee/ci/yaml/#git-clean-flags - GIT_CLEAN_FLAGS: -ffdx --exclude=qaboard-webapp/node_modules/ +#variables: +# # https://docs.gitlab.com/ee/ci/yaml/#git-clean-flags +# GIT_CLEAN_FLAGS: -ffdx --exclude=webapp/node_modules/ +# GIT_SUBMODULE_STRATEGY: recursive # CI for the web application and the backend # TODO: enable it, make it work... +# image: gitlab-srv.transchip.com:4567/common-infrastructure/qaboard # backend:tests: # stage: test # script: -# - cd qaboard-backend +# - cd backend # # we only check that the syntax is correct -# - pip install . +# - pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org . # webapp:tests: # stage: test # script: -# - cd qaboard-webapp +# - cd webapp # - npm ci # # - npm test - -# we deploy to the "qa" host. -# Internally we need to deal with proxy issues, tcsh as default shell, etc... .deploy: &deploy stage: deploy - script: - - cd qaboard-webapp && ssh qa "bash -c 'cd $(pwd) && source ../.envrc && npm install --no-audit && GENERATE_SOURCEMAP=true npm run build'" && cd .. - - cd qaboard-webapp && rsync -r build/* /home/ispq/qaboard/webapp_builds && cd .. - - ssh qa "bash -c \"cd $(pwd) && source .envrc && docker build --ssh default -m 12g --tag $DOCKER_IMAGE:$CI_ENVIRONMENT_SLUG .\"" - - ssh qa "docker stop qaboard-$CI_ENVIRONMENT_SLUG; docker rm qaboard-$CI_ENVIRONMENT_SLUG" || true - - ssh qa "bash -c \"cd $(pwd) && source .envrc && CI_ENVIRONMENT_SLUG=$CI_ENVIRONMENT_SLUG $(pwd)/qaboard-backend/deployment/start-docker.sh\"" + variables: + DOCKER_HOST: alginfra1 + COMPOSE_PROJECT_NAME: $CI_ENVIRONMENT_SLUG + # script: + # - ssh $DOCKER_HOST "bash -c \"cd $(pwd) && source .envrc && ./at-sirc-before-up.py && docker compose -f docker-compose.yml -f production.yml -f sirc.yml up -d --build\"" + +# deploy:staging: +# <<: *deploy +# environment: +# name: staging +# url: http://qa:9000 +# only: +# - master +# Assuming you +# - cloned at /home/ispq/qabaord +# - filled .env with the jenkins/gitlab tokens +# - put the SSL keys in services/nginx/ssl/qa deploy:production: - <<: *deploy + # <<: *deploy + stage: deploy + variables: + DOCKER_HOST: qa + CI_ENVIRONMENT_SLUG: "" + script: + - ssh $DOCKER_HOST "bash -c \"cd /home/ispq/qaboard_prod && source .envrc && git reset --hard && git pull && ./at-sirc-before-up.py > /dev/null && docker compose -f docker-compose.yml -f production.yml -f sirc.yml up -d --build\"" environment: name: production url: https://qa only: - master when: manual - -deploy:staging: - <<: *deploy - environment: - name: staging - url: http://qa:9000 - only: - - master diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..0bae60561 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/cde-python"] + path = third_party/cde-python + url = git@gitlab-srv:cde/cde-python.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7b03ef9e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + + +## QA-Board Architecture + +QA-Board is an experiment tracking framework with advanced viewers for algorithm, ML, and performance engineers. It consists of: + +- **`qaboard/`** - Python CLI package that wraps user code and manages experiments +- **`backend/`** - Flask API with PostgreSQL database for managing runs and data +- **`webapp/`** - React.js frontend for visualizing and comparing results +- **`services/`** - Infrastructure services (nginx, databases, message queues) +- **`website/`** - Docusaurus documentation site + +## Development Commands + +### Environment Setup +```bash +# Start full development environment +docker compose -f docker-compose.yml -f development.yml up -d + +# At SIRC, run this first to mount important folders +./at-sirc-before-up.py + +# With SIRC-specific config +docker compose -f docker-compose.yml -f development.yml -f sirc.yml up -d +``` + +### Frontend Development (React) +```bash +cd webapp +npm install +npm start # Start dev server +npm run build # Production build +npm test # Run tests +``` + +### Backend Development (Python Flask) +```bash +cd backend +# Backend runs in Docker, see docker-compose.yml +``` + +### Documentation Site +```bash +cd website +yarn install +yarn start # Start Docusaurus dev server +yarn build # Build static site +``` + +### Python CLI Package +```bash +# Main qaboard package uses uv for dependencies +# Development dependencies include testing tools +uv sync --extra dev # Install with dev dependencies +``` + +## Testing + +### Python Tests +- **Framework**: `green` test runner (configured in pyproject.toml) +- **Backend tests**: `pytest` (in backend/pyproject.toml dev dependencies) +- **Type checking**: `mypy` +- **Linting**: `flake8` +- **Test files**: Located in `tests/` directory + +```bash +# Run Python tests (use green test runner) +green + +# Type checking +mypy qaboard/ + +# Linting +flake8 qaboard/ +``` + +### Frontend Tests +```bash +cd webapp +npm test # React test suite +``` + +## Key Technologies + +- **Backend**: Python 3.11+, Flask, PostgreSQL, SQLAlchemy, Celery, Redis +- **Frontend**: React 18, Redux, TypeScript, Blueprint UI, D3.js, Plotly.js +- **Infrastructure**: Docker Compose, nginx, RabbitMQ +- **CLI**: Python with Click framework +- **Package Management**: `uv` for Python, `npm` for JavaScript + +## Development Workflow + +1. **Environment**: Use Docker Compose for full stack development +2. **Database**: PostgreSQL with Alembic migrations +3. **Task Queue**: LSF or Celery with RabbitMQ for background jobs +4. **Image Serving**: Cantaloupe IIIF server for advanced image viewing +5. **Authentication**: Supports local, LDAP, and SAML authentication + +## Special Features + +- **LSF Integration**: High-performance computing cluster support for batch jobs +- **Advanced Visualizations**: Images, videos, plots, 3D point clouds, flame graphs +- **Git Integration**: Version control awareness and commit tracking +- **Parameter Tuning**: Built-in optimization workflows with scikit-optimize +- **Bit Accuracy Testing**: Automated regression testing for algorithm validation + +## Architecture Notes + +The system follows a microservices pattern with Docker containers. The CLI submits jobs and uploads results, the backend processes data and stores in PostgreSQL, and the frontend fetches data via REST API to render visualizations. Services provide infrastructure like reverse proxy, image serving, and task processing. + +Main entry points: +- CLI: `qa` command (from qaboard package) +- Backend API: Flask application served via uWSGI +- Frontend: React SPA served by nginx +- Documentation: Static Docusaurus site \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..02ec40665 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing to QA-Board +👍🎉 First off, thanks for taking the time to contribute! 🎉👍 + +The following is a set of guidelines for contributing to QA-Board. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +## Code of Conduct +This project and everyone participating in it is governed by the QA-Board [Code of Conduct](https://github.com/Samsung/qaboard/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to arthur.flam@samsung.com. + +## Questions? +If you've got questions about anything (setup, contributing...) or just want to chat with the developers, please feel free to [start a thread in our Spectrum community](https://spectrum.chat/qaboard)! You can also contact us [by mail](arthur.flam@samsung.com). + +## Code organization +Each section has its own README: +- [qaboard](qaboard/): python package (`qaboard`) for the `qa` CLI wrapper than runs your code +- [backend](backend/) exposes an HTTP API to manage runs. Built with python's `flask` and `postgreSQL` as database. +- [webapp](webapp/) is the web frontend that displays results, based on `reactjs`. +- [services](services/): the web application composes a reverse proxy (`nginx`), an image server ([`cantaloupe`](https://medusa-project.github.io/cantaloupe/)), etc. This folder stores all the relevant `Dockerfile`s and settings +- [website](website/) is the [QA-Board website](https://samsung.github.io/qaboard) and [docs](https://samsung.github.io/qaboard/docs) + +## Where to start +If you want to contribute to the project but do not know where to start, or what to work on, don't hesitate to chat with the maintainers. QA-Board has many parts and much can be improved. We'll do our best to find something that matches your experience and has a meaningful impact on the project. Before you work on a big feature, don't hesitate to open an issue and discuss it. + +**To start a dev server:** + +```bash +# check requirements in webapp/README.md +cd webapp +npm install + +# At SIRC we need to make sure important folders are mounted before starting containers... +./at-sirc-before-up.py + +# see more into in docker-compose.yml and backend/README.md +docker compose -f docker-compose.yml -f development.yml -f sirc.yml up -d +``` + +## Openness +Currently, we use internally at Samsung a private fork of QA-Board. The differences are very small, mainly having to do with hardcoded configuration and CI. Our goal to move to a process where we first contribute to the public repository, then merge back the changes. + +We want to develop QA-Board in the open, and started asking our users to submit issues on GitHub.com. We plan on using the public issue tracker to discuss the roadmap. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100755 index 917f3a120..000000000 --- a/Dockerfile +++ /dev/null @@ -1,168 +0,0 @@ -# TODO: Use a lighter base image like alpine-linux -# Possibly let's do it when we split the application -# with docker-compose into database+backend+frontend+image-servers -FROM ubuntu:bionic -LABEL maintainer="arthurf.flam@samsung.com" - - - -ENV DEBIAN_FRONTEND noninteractive -RUN apt-get update && \ - echo exit 0 > /usr/sbin/policy-rc.d && \ - # Essential utilities - # Using --no-install-recommends doesn't work out-of-the-box, e.g. apt-key misses dirmngr later - apt-get install -y \ - sudo \ - wget curl sudo \ - software-properties-common build-essential \ - libc6-dev \ - python-dev && \ - # Useful utilities when debugging the container - apt-get install -y zsh htop tree less nano && \ - # Remove the cache - rm -rf /var/lib/apt/lists/* - - -# Trust various SSL certificates used by Samsung's IT -COPY qaboard-backend/deployment/DLP-TRITON.crt /usr/local/share/ca-certificates/samsung/DLP-TRITON.crt -COPY qaboard-backend/deployment/sirc-ca.cer /usr/local/share/ca-certificates/samsung/sirc-ca.cer -COPY qaboard-backend/deployment/sirc-ca.crt /usr/local/share/ca-certificates/samsung/sirc-ca.crt -RUN update-ca-certificates && \ - yes | dpkg-reconfigure ca-certificates -- - - -# Install git, up-to-date. -# Since the application manages a cache of all projects' repos, it is preferable. -# If we ran into scale issues, we could look into a service like Gitlab's gitaly. -# Note: if we didn't have proxy issues we would just -# add-apt-repository -y ppa:git-core/ppa -RUN echo "deb http://ppa.launchpad.net/git-core/ppa/ubuntu trusty main" >> /etc/apt/sources.list && \ - echo "deb-src http://ppa.launchpad.net/git-core/ppa/ubuntu trusty main" >> /etc/apt/sources.list && \ - apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys A1715D88E1DF1F24 && \ - apt-get update -qq && apt-get install -y git - - -# Install a complete Python environment -RUN wget --no-check-certificate https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh && \ - bash Miniconda3-latest-Linux-x86_64.sh -f -b -p /opt/anaconda3 -ENV PATH /opt/anaconda3/bin:${PATH} -# TODO: -# Ideally we should freeze dependencies, use requirement.txt/requirement.lock.txt, etc, but we there was no time to spend on this... -# To save build time dependencies are installed early in the dockerfile - now. -# We ran into issues with uwsgi segfaulting at runtime, issues with the pandas from pip... -# Some day we should clean this! -RUN conda install -k -c conda-forge libiconv -RUN conda install -k -c conda-forge uwsgi -RUN conda install -k pandas -RUN pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org \ - pip pipenv \ - gitpython click flask flask_cors flask-admin sqlalchemy alembic sqlalchemy_utils ujson sklearn scikit-image scikit-learn scikit-optimize - - -# TODO: -# Projects can define their own iter_inputs() function to find inputs -# The use case it to connect to databases. However, at this stage the function is executed -# directly by the server. Not only is it unsecure (on our network let's say it's allright...), -# but it introduces a strong coupling between dependencies needed by projects and the server. -# Solutions could be: -# - [x] Short term, make those users call in a subprocess *their* python with dependencies, and read from STDOUT. -# we could read their projects's .envrc -# - [ ] Middle term, execute those functions in a docker container used by users to define their environment -# - [ ] The above makes things *slow* (?). What do we do? A sort of iter_input server? -# Limit the logic/connections available to users? -# For now, some projects need this to connect to MySQL: -RUN apt-get update -qq && apt-get install --no-install-recommends -y libssl-dev default-libmysqlclient-dev && \ - pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org \ - # https://github.com/PyMySQL/mysqlclient-python - # https://github.com/ContinuumIO/anaconda-issues/issues/10646 - mysqlclient \ - # We still ran into issues with missing libs.. this is python only - PyMySQL[rsa] - - -# nginx as reverse proxy -RUN echo 'deb http://nginx.org/packages/ubuntu/ bionic nginx' > /etc/apt/sources.list.d/nginx.list && \ - echo 'deb-src http://nginx.org/packages/ubuntu/ bionic nginx' >> /etc/apt/sources.list.d/nginx.list && \ - apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys ABF5BD827BD9BF62 && \ - # nginx-extra instead of just -full or smaller for WebDav and DAV Ext - apt-get update -qq && apt-get install -y --no-install-recommends nginx-extras && \ - rm /etc/nginx/sites-enabled/default -EXPOSE 5000 80 443 - - -# PostgreSQL Database -# TODO: compare to the official dockerfile, even replace with it... -# https://github.com/docker-library/postgres/blob/f19a74ec301fe755b70a822f905c8f537f67bc9a/11/Dockerfile -RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ bionic-pgdg main' > /etc/apt/sources.list.d/pgdg.list && \ - wget --quiet --no-check-certificate -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ - apt-get update -qq && apt-get install -y --no-install-recommends postgresql-10 postgresql-contrib-10 - # Allow connections from the outside world - with passwords -RUN echo "listen_addresses = '*'" >> /etc/postgresql/10/main/postgresql.conf && \ - echo "shared_preload_libraries = 'pg_stat_statements'" >> /etc/postgresql/10/main/postgresql.conf && \ - echo 'host all all ::/0 md5' >> /etc/postgresql/10/main/pg_hba.conf && \ - echo 'host all all 0.0.0.0/0 md5' >> /etc/postgresql/10/main/pg_hba.conf -USER postgres -RUN /etc/init.d/postgresql start && sleep 10 && psql --command "CREATE USER ci WITH SUPERUSER PASSWORD 'dvsdvs';" -USER root -VOLUME ["/etc/postgresql", "/var/log/postgresql", "/var/lib/postgresql"] -EXPOSE 5432 -# Solves issues when using old backups with an undefined uid. The postgres dockerfile fixes the uid in advance... -RUN groupadd -g 107 postgresold -# Python PostgreSQL driver -RUN apt-get install -y --no-install-recommends libpq-dev && \ - pg_config --version && \ - conda install -k -c conda-forge psycopg2 - - -# nodejs -RUN curl -ksL https://deb.nodesource.com/setup_10.x | \ - sed 's/wget -/wget --no-check-certificate -/g' | \ - sed 's/curl -/curl -k -/g' | \ - bash - && \ - apt-get install -y nodejs && \ - npm config set strict-ssl false && \ - npm config set cafile /usr/local/share/ca-certificates/samsung/DLP-TRITON.crt - - -# Frontend's dependencies -WORKDIR /qaboard/qaboard-webapp -COPY qaboard-webapp/package.json qaboard-webapp/npm-shrinkwrap.json ./ -## FIXME #################################### -# ENV NODE_ENV production -# # At the moment we don't build the app from the container because of frequent issues: -# # - ulimit would kick in (solvable via ENV AFAIK) -# # - network issues would cause always one of the 1000 dependencies to fail fetching -# # solvable via an internal pip proxy (e.g. artifactory) -# # As a user, you are expected to build it yourself with: -# # $ cd qaboard-webapp; npm ci; npm build -# # Then mount the build/ folder to /qaboard/qaboard-webapp/build -# We used to have things like -# RUN ulimit -n 2000 && npm ci -ddd # install exactly as in the lock-file (prefered...) -# RUN ulimit -n 2000 && npm install -ddd # install compatible dependencies -# RUN npm run build -COPY . /qaboard/ - - -# Backend API -ENV LANG 'C.UTF-8' -ENV LC_ALL 'C.UTF-8' -WORKDIR /qaboard -RUN pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org --editable . ./qaboard-backend - -WORKDIR /qaboard -VOLUME /var/qaboard - - -# Some of our NFS mounts seem to use squash_root, eg /stage/algo_data -# It forces us to acces them with a regular SIRC user and dance around with sudo -# FIXME: use a different user, possibly use ARG/.env to parametrize -RUN useradd -u 11611 -g 10 arthurf --shell /bin/bash --no-create-home; \ - echo 'arthurf ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers -USER arthurf - - -# reverse proxy settings -COPY qaboard-backend/deployment/nginx/mime.types qaboard-backend/deployment/nginx/nginx.conf /etc/nginx/ -COPY qaboard-backend/deployment/nginx/conf.d/qaboard.conf /etc/nginx/conf.d/ - -CMD ["/qaboard/qaboard-backend/deployment/init.sh"] diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100755 index 1d3b31228..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -recursive-include qatools/sample_project/qa * -include qatools/sample_project/qatools.yaml - diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..3b489dc71 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,61 @@ +# Migration Guide: Branch Unification + +This document covers breaking changes from the branch unification +(merging master-sirc, master-korea into a single master). + +## For SIRC Users + +### CLI +- After the regular install with: `pip install git+ssh://git@gitlab-srv/common-infrastructure/qaboard` +- Also install the site config: +```bash + pip install --upgrade "qaboard-site-sirc @ git+ssh://git@gitlab-srv/common-infrastructure/qaboard#subdirectory=deployments/sirc/cli" +``` +- This auto-configures API URL (https://qa), port (5000), secrets path (! we dont support it anymore as part of the project config) +- All existing ENV var overrides continue to work + +### Server +- Deploy with: `docker compose -f docker-compose.yml -f production.yml -f deployments/sirc/sirc.yml -f deployments/sirc/prod.yml up up` + +### Docker Builds +Proxy/cert configuration is no longer hardcoded in Dockerfiles. Instead, `sirc.yml` +passes build args automatically. **No action needed** — just rebuild as usual with +the SIRC overlay: +```bash +docker compose -f docker-compose.yml -f deployments/sirc/sirc.yml build +``` +The overlay provides `PROXY_URL`, `CA_CERT_URL`, `NO_PROXY`, `GIT_SSL_VERIFY`, +`NODE_TLS_REJECT_UNAUTHORIZED`, and `QABOARD_EXTRA=sirc` as build args. + +If you have custom certs in `services/cantaloupe/cert/`, those files are now +gitignored (only `.gitkeep` is tracked). Copy your certs back after cloning. + +### Breaking Changes +- `--lsf-threads` renamed to `--lsf-max-threads` (already done on master-sirc) + +## For DSK Users + +### CLI +- Install with: `pip install qaboard[dsk]` (was: `pip install qaboard`) +- This auto-configures API URL (https://qaboard.samsungds.net) + +### Server +- Deploy with: `docker compose -f docker-compose.yml -f production.yml -f deployments/dsk/dsk.yml up` + +### Docker Builds +Proxy/cert values are no longer hardcoded. Set `PROXY_URL`, `CA_CERT_URL`, and +`NO_PROXY` in your `.env` file or override them in `deployments/dsk/dsk.yml`. + +### Breaking Changes +- DB migration required: `is_ldap`/`is_sso` booleans -> `login_type` string field + Run: `alembic upgrade head` +- `--lsf-threads` renamed to `--lsf-max-threads` + +## For Open-Source Users + +No breaking changes. The default behavior is unchanged. +New features available: LDAP/SAML auth, LSF/celery runners, multiple +image servers -- all opt-in via ENV vars. + +All Dockerfiles now build cleanly with no build args (proxy/cert blocks +are skipped when args are empty). diff --git a/README.md b/README.md index b91b4779c..b7e9eb48e 100755 --- a/README.md +++ b/README.md @@ -1,54 +1,68 @@ -# QA-Board -**QA-Board** helps Algorithms/QA engineers build great products with powerful *quality evaluation* and *collaboration* tools. +

+ QA-Board logo -> QA-Board is not released yet, and likely won't work *yet* for you because it expects running on our infra... -> -> We're working on it! **Status: https://github.com/Samsung/qaboard/issues/1** +

Experiment tracking framework with advanced viewers.
+ +
Helps algo/ml/perf engineers share results, collaborate, and build better products.
+

+ +

+ qaboard-chat + PyPI + Docs + CI +

+ +> It's an open-source project: https://github.com/samsung/qaboard ## Features - **Organize, View and Compare Results**, **Tuning/Optimization** - **Web-based:** sharable URLs, no install needed. -- **Visualizations:** support for quantitative metrics, and many file formats: advanced image viewer, support for videos, plotly graphs, text, pointclouds, embedded HTML... -- **Integrations:** direct access from Git/CI, easily exportable results, API, links to the code, trigger jobs... +- **Visualizations:** support for quantitative metrics, and many file formats: advanced image viewer, support for videos, plotly graphs, text, pointclouds, HTML... +- **Integrations:** direct access from Git and CI tools, easily exportable results, API, links to the code, trigger gitlabCI/jenkins/webhooks... +- **Agnostic** to your language/framework: run your existing code, write files, view them. + +> For screenshots check [our website](https://samsung.github.io/qaboard). ## Benefits +QA-Board across many projects enables us to: - **Scale R&D:** enable engineers to achieve more and be more productive. - **Faster Time-to-Market:** collaboration across teams, workflow integration.. - **Quality:** uncover issues earlier, KPIs, tuning, reporting... +## Deployment -## Get in touch -We are looking for feedback and insights from outside Samsung. This will help us set the direction for `qaboard`. - -We think you could be interested if have projects where: -- unit tests are not enough (ML, operational research...), and a loss function doesn't tell the whole story. Maybe because... -- there are performance / quality trade-offs, or different configurations of your code you need to compare (hardware design, mobile/embedded...). -- you need advanced visualizations to make sense of results (statistics, image processing, 3d sensors, sensing and decision tasks...) and need tools to dive down into outputs. -- lifecycles are complex, with many stakeholders (algo, hardware, software, QA, production...) +```bash +# Open-source +docker compose -f docker-compose.yml -f production.yml up -> Contact arthur.flam@samsung.com +# With site-specific config (e.g. SIRC, DSK) +docker compose -f docker-compose.yml -f production.yml -f deployments/sirc.yml up +``` ## Getting Started -[Read the docs!](http://qa-docs/docs/installation) You will learn how to: -- install QA-Board's CLI wrapper -- run a QA-Board server -- wrap your code with QA-Board -- view output files and KPIs -- ...and improve your integration with many guides: bit-accuracy, tuning, etc. +[Read the docs!](https://samsung.github.io/qaboard/docs/introduction) You will learn how to: +- Start a QA-Board server +- Wrap your code with QA-Board +- View output files and KPIs +- ...and setup parameter tuning, integrations with 3rd party tools, etc. + +If you want to learn about the code's organization, or how to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md) + + +## Feedback? Questions? Need Help? Found a bug? +> Don't hesitate to get in touch! Contact arthur.flam@samsung.com, we'll be delighted to hear your insights. + +If you've got questions about setup, deploying, want to develop new features, or just want to chat with the developers, please feel free to [start a thread in our Spectrum community](https://spectrum.chat/qaboard)! -## Code organization -Each section has its own README: -- [qatools](qatools): provides the `qa` CLI wrapper than runs your code, and the `import qatools` package. -- [qaboard-backend](qaboard-backend/) exposes an HTTP API used to read/write all the metadata on runs. -- [qaboard-webapp](qaboard-webapp/) is the frontend that displays results. -- [thirdparty](thirdparty/): - * [Cantaloupe](https://medusa-project.github.io/cantaloupe/) IIIF server, used to "stream" large images to the users. +Found a bug with QA-Board? Go ahead and [submit an issue](https://github.com/Samsung/qaboard/issues). And, of course, feel free to submit pull requests with bug fixes or changes to the `master` branch. -> **WIP:** we're merging multiple repos into one, expect those path to not be 100% accurate! +## Contributors +QA-Board was started at [Samsung SIRC](https://www.linkedin.com/company/samsung-israel-r-d-center-sirc/) by [Arthur Flam](https://shapescience.xyz). -## Contributing -> Merge requests are welcomed, and don't hesitate to create issues! For a quick chat do contact [Arthur Flam](mailto:arthur.flam@samsung.com) +Thanks to the following people for their contributions, testing, feedback or bug reports: Amir Fruchtman, Avi Schori, Yochay Doutsh, Itamar Persi, Amichay Amitay, Lena Grechikhin, Gal Hai, Rivka Emmanuel, Nadav Ofer. Thanks also to Sebastien Derhy, Elad Rozin, Nathan Levy, Shahaf Duenyas, Asaf Jazcilevich and Yoel Yaffe for supporting the project. +> You don't see your name? Get in touch to be added to the list! -## Licensing -- The logo is a the Poodle [twemoji](https://twemoji.twitter.com/) 🐩, recolored in Samsung Blue 🔵. *Copyright 2019 Twitter, Inc and other contributors. Code licensed under the [MIT License](http://opensource.org/licenses/MIT). Graphics licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)* \ No newline at end of file +## Credits +- The logo is a the Poodle [twemoji](https://twemoji.twitter.com/) 🐩, recolored in Samsung Blue 🔵. *Copyright 2019 Twitter, Inc and other contributors. Code licensed under the [MIT License](http://opensource.org/licenses/MIT). Graphics licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)* diff --git a/SIDEBAR.md b/SIDEBAR.md new file mode 100644 index 000000000..6e4529b41 --- /dev/null +++ b/SIDEBAR.md @@ -0,0 +1,94 @@ +# Sidebar Design Refresh - QA-Board + +## Design Direction +Modern, polished sidebar inspired by Linear, Notion, and Samsung design language. Focus on clarity, utility, and personality while maintaining Blueprint.js consistency. + +## Samsung-Inspired Color Palette +- **Primary Blue**: `#1f7ce8` (Samsung Blue) +- **Dark Blue**: `#0d47a1` (Deep Samsung Blue) +- **Accent**: `#00d4ff` (Samsung Cyan accent) +- **Background**: `#1a1d23` (Dark background) +- **Surface**: `#252a31` (Elevated surface) +- **Text Primary**: `#ffffff` +- **Text Secondary**: `#a0a6b0` +- **Border**: `#3a3f47` +- **Hover**: `rgba(31, 124, 232, 0.1)` +- **Active**: `rgba(31, 124, 232, 0.2)` + +## Design Principles +1. **Clarity**: Clear visual hierarchy with proper spacing +2. **Utility**: Context-aware content and smart grouping +3. **Personality**: Subtle Samsung branding and refined interactions +4. **Responsive**: Adapt from laptops (1024px) to ultrawide displays (2560px+) + +## Implementation Progress + +### ✅ Phase 1: Foundation (Completed) +- [x] Create design tokens and constants +- [x] Update styled-components with new design system +- [x] Implement responsive breakpoint system +- [x] Create animation utilities +- [x] Fix CSS specificity issues with Blueprint overrides +- [x] Enhance active states and Samsung blue accents +- [x] Improve section separators visibility +- [x] Add hover state interactions + +### ✅ Phase 1.5: Styling Fixes (Completed) +- [x] Override Blueprint CSS with higher specificity +- [x] Add Samsung blue active indicators (left border) +- [x] Improve section borders and spacing +- [x] Fix hover states with smooth transitions + +### ✅ Phase 2: Advanced Polish (Completed) +- [x] Add logical section headers ("Project Navigation", "Analysis Tools") +- [x] Implement status badges with error indicators and pulse animations +- [x] Add subtle icon animations (scale on hover) +- [x] Create loading skeleton components for better UX +- [x] Enhance accessibility with focus states and keyboard navigation +- [x] Add contextual content based on current page + +### ✅ Phase 3: Final Touches (Completed) +- [x] Smooth icon hover animations with 1.1x scale +- [x] Pulsing error badges for failed outputs +- [x] Enhanced focus indicators for accessibility +- [x] Shimmer loading states for dynamic content +- [x] Improved visual hierarchy with section organization +- [x] Samsung blue accents throughout for brand consistency + +### ✅ Phase 2.5: Layout Cleanup (Completed) +- [x] Removed ugly bullet points from menu items +- [x] Reduced excessive left/right padding for cleaner look +- [x] Made sidebar more compact (240px → 200px default width) +- [x] Updated sider_width export for proper app rendering +- [x] Cleaned up overall spacing and section organization + +### ✅ Phase 3: Content Organization (Completed) +- [x] Organized menu items under logical section headers +- [x] **Metrics**: Summary, Metrics Table (was KPIs), Metrics Diff (was KPI diff) +- [x] **Outputs**: Visualizations, Output Files, Logs (with error badge) +- [x] **Source**: Artifacts & Configs, Code +- [x] **Tuning**: Available Tests, Run Tests/Tuning, Analysis +- [x] **Actions & Links**: No header (integrations at top) +- [x] Moved error badge from main header to Outputs section +- [x] Improved information architecture and user flow + +### 🎯 Implementation Complete! +All phases successfully implemented with Samsung-inspired design language. +Sidebar is now more compact, clean, professional, and logically organized. + +## Technical Decisions +- **Styling**: Styled-components with CSS-in-JS for simple styles +- **Animations**: CSS transitions for smooth interactions +- **Responsive**: CSS Grid/Flexbox with media queries +- **Icons**: Maintain Blueprint.js icon system +- **Typography**: Enhance existing typography scale + +## Key Improvements +1. **Visual Hierarchy**: Better spacing, typography, and color contrast +2. **Interactive States**: Smooth hover, active, and focus states +3. **Section Organization**: Logical grouping with subtle separators +4. **Samsung Branding**: Subtle blue accents and refined aesthetics +5. **Responsive Design**: Optimized for desktop screens 1024px - 2560px+ + +## Next Steps +Starting with Phase 1 - creating design tokens and updating the foundation styling system. \ No newline at end of file diff --git a/at-sirc-before-up.py b/at-sirc-before-up.py new file mode 100755 index 000000000..507b9512e --- /dev/null +++ b/at-sirc-before-up.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +""" +Run this before calling `docker compose up` at SIRC. + +At SIRC we rely on auto-mounted volumes. Attempting to mount them before they are bound leads to "too many levels of symbolic links errors". +""" +import sys +from pathlib import Path +import yaml + + +sirc_config_path = Path(__file__).parent / 'sirc.yml' +with sirc_config_path.open() as f: + sirc_config = yaml.safe_load(f) +volumes = sirc_config['services']['proxy']['volumes'] +volumes.append("/home:/home") +volumes = [Path(v.split(':')[0]) for v in volumes] + +for v in volumes: + if "dockermounts" in str(v): + continue + print(v) + if v.is_file(): + continue + for d in v.iterdir(): + print(f". {d}") + if d.is_file(): + continue + try: + if '--shallow' in sys.argv: + continue + for dd in d.iterdir(): + print(f". . {dd}") + # print(dd) + ... + except Exception as e: + print(e) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..c3e5ac98c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +.venv +.git +Dockerfile +.dockerignore \ No newline at end of file diff --git a/qaboard-backend/.gitignore b/backend/.gitignore similarity index 99% rename from qaboard-backend/.gitignore rename to backend/.gitignore index eaaf887d4..55f62cc55 100755 --- a/qaboard-backend/.gitignore +++ b/backend/.gitignore @@ -216,9 +216,6 @@ target/ # Jupyter Notebook .ipynb_checkpoints -# pyenv -.python-version - # celery beat schedule file celerybeat-schedule diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 000000000..3a4f41ef3 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.13 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..c883bc747 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,185 @@ +# https://pythonspeed.com/articles/base-image-python-docker-images/ +# TODO: 3.14 when pillow ships in binary https://pillow.readthedocs.io/en/latest/installation/platform-support.html +# -slim-trixie when python-ldap binary links with the updated libldap.. +FROM python:3.13-slim-bookworm + +LABEL maintainer="arthurf.flam@samsung.com" + +# Proxy/cert configuration — no-ops when args are empty (open-source builds) +# SIRC/DSK pass values via compose overlay build args +ARG PROXY_URL="" +ARG CA_CERT_URL="" +ARG NO_PROXY="" + +RUN if [ -n "$PROXY_URL" ]; then \ + echo "Acquire::http::Proxy \"$PROXY_URL\";" >> /etc/apt/apt.conf && \ + echo 'Acquire::https::Verify-Peer "false";' >> /etc/apt/apt.conf; \ + fi +ENV HTTP_PROXY=${PROXY_URL} http_proxy=${PROXY_URL} \ + HTTPS_PROXY=${PROXY_URL} https_proxy=${PROXY_URL} \ + NO_PROXY=${NO_PROXY} + +RUN apt-get update -qq && \ + apt-get install -y ca-certificates wget && \ + if [ -n "$CA_CERT_URL" ]; then \ + wget "$CA_CERT_URL" -P /usr/local/share/ca-certificates/ && \ + update-ca-certificates; \ + fi + + + # Keeps Python from generating .pyc files for 1-time use in the container's unionFS +ENV PYTHONDONTWRITEBYTECODE=1 \ + # Turns off buffering for easier container logging + PYTHONUNBUFFERED=1 \ + # Tracebacks on segfaults + PYTHONFAULTHANDLER=1 \ + # Avoid issues with SSL misconfigured at SIRC + PYTHONWARNINGS="ignore:Unverified HTTPS request" + + +# Git: debian ships a recent enough version without requiring an alternative PPA +ARG GIT_SSL_VERIFY="true" +RUN apt-get install -y git && \ + if [ -n "$PROXY_URL" ]; then \ + git config --global http.proxy "$PROXY_URL"; \ + fi + +RUN if [ "$GIT_SSL_VERIFY" = "false" ]; then \ + git config --global http.sslVerify false; \ + fi && \ + # gc tends to take time in our big repos and the locks slow everything down + # causing uwsgi to spawn more workers and eat RAM + git config --global gc.auto 0 && \ + # We manage git repos in various directories... + git config --system --add safe.directory '*' && \ + git config --global --add safe.directory '*' + +ARG GIT_SERVER="" +RUN --mount=type=ssh \ + if [ -n "$GIT_SERVER" ]; then \ + ssh -T -o StrictHostKeyChecking=no git@$GIT_SERVER; \ + fi + +RUN apt-get update -qq && \ + apt-get install -y \ + # https://www.psycopg.org/docs/install.html#install-from-source + # https://www.psycopg.org/docs/faq.html#faq-compile + libpq-dev build-essential gcc \ + # for Ldap Authentication + libsasl2-dev libldap2-dev \ + # At SIRC we need to be able to turn into any user to delete their output files + sudo \ + # procps is a set of command line and full-screen utilities that + # provide information out of the pseudo-filesystem. + # https://github.com/warmchang/procps + procps + # If we want uwsgi's routing support we need libpcre + # https://uwsgi-docs.readthedocs.io/en/latest/InternalRouting.html + # we used to have libpcre3 but it is not shipped as part of recent debian + # https://launchpad.net/ubuntu/+source/uwsgi/+changelog + # and this breaks at link-time + # libpcre2-dev + +# Required to build some projects from source +RUN apt-get update -qq && \ + apt-get install -y \ + # for scipy if python is very recent + gfortran pkg-config libopenblas-dev liblapack-dev \ + # required for python3-saml for python-xmlsec https://xmlsec.readthedocs.io/en/stable/install.html + libxml2-dev libxmlsec1-dev libxmlsec1-openssl libz-dev \ + libxslt1-dev + + +# References: +# https://docs.astral.sh/uv/guides/integration/docker/ +# https://hynek.me/articles/docker-uv/ +COPY --from=ghcr.io/astral-sh/uv:0.10.7 /uv /uvx /bin/ + # Already isolated environment +ENV UV_SYSTEM_PYTHON=1 \ + UV_PROJECT_ENVIRONMENT="/usr/local/" \ + # Still useful for dev + PATH="/qaboard/backend/.venv/bin:$PATH" \ + # Silence uv complaining about not being able to use hard links + UV_LINK_MODE=copy \ + # Faster application startups + UV_COMPILE_BYTECODE=1 \ + # Prevent uv from accidentally downloading isolated Python builds + UV_PYTHON_DOWNLOADS=never + +# Make it possible to resolve dependencies of the CLI package +WORKDIR /qaboard +COPY pyproject.toml README.md ./ +WORKDIR /qaboard +COPY deployments/ ./deployments + +WORKDIR /qaboard/backend +# Install all dependencies +# https://github.com/xmlsec/python-xmlsec/issues/320 +ENV UWSGI_PROFILE_OVERRIDE="xml=no" \ + CC=gcc \ + CXX=g++ +RUN --mount=type=ssh \ + --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=backend/uv.lock,target=uv.lock,readwrite \ + --mount=type=bind,source=backend/pyproject.toml,target=pyproject.toml \ + uv sync \ + # we ran into issues making sure we really build uwsgi + # From source to use the UWSGI_PROFILE_OVERRIDE set above + #FIXME: when we fix the xml bugs and issues then we can uncomment --locked + # --locked \ + --no-install-project \ + --no-install-package qaboard + +# Backend +WORKDIR /qaboard/backend +ADD backend . +# CLI package +WORKDIR /qaboard/qaboard +COPY qaboard/ ./ +WORKDIR /qaboard/tests +COPY tests/ ./ + + +WORKDIR /qaboard/backend +# Sync the project, asserting the lockfile is up to date +# with a shared cache across builds +RUN --mount=type=ssh \ + --mount=type=cache,target=/root/.cache/uv \ + uv sync --refresh-package qaboard + #FIXME: when we fix the xml bugs and issues then we can uncomment --locked + # --locked + +# Install site-specific extras (e.g. QABOARD_EXTRA=sirc) +ENV PYTHONPATH="/opt/site-packages:${PYTHONPATH}" +ARG QABOARD_EXTRA="" +ARG CDE_PACKAGE="" +# Useful if we need to dev... +ENV CDE_PACKAGE=${CDE_PACKAGE} +ENV QABOARD_EXTRA=${QABOARD_EXTRA} +RUN --mount=type=ssh \ + --mount=type=cache,target=/root/.cache/uv \ + if [ -n "$QABOARD_EXTRA" ]; then \ + ## Cannot work as uv requires to resolve even extras... + # uv sync --extra "$QABOARD_EXTRA" --refresh-package qaboard; \ + ## Annoying for dev and when managing lock-files + # uv pip install "qaboard-site-$QABOARD_EXTRA @ file:../deployments/$QABOARD_EXTRA/cli" "$CDE_PACKAGE"; \ + uv pip install --no-deps --target /opt/site-packages \ + "qaboard-site-$QABOARD_EXTRA @ file:../deployments/$QABOARD_EXTRA/cli" "$CDE_PACKAGE"; \ + fi + +# TODO: Ideally clear proxy env so it doesn't leak into runtime containers +# but for dev it is not a good experience +# ENV HTTP_PROXY="" http_proxy="" \ +# HTTPS_PROXY="" https_proxy="" +# # we keep NO_PROXY however! + +ENV QA_NO_CHECK_FOR_UPDATES=1 + +# Where we keep a cache of application data (e.g. git clones) +VOLUME /var/qaboard +# It's useful to make it world-writable so that +# we can run as a non-root user during dev and clone without issues +RUN mkdir -p /var/qaboard/git && chmod -R 777 /var/qaboard/git + +WORKDIR /qaboard/backend +CMD ["/qaboard/backend/init.sh"] diff --git a/backend/README.md b/backend/README.md new file mode 100755 index 000000000..c390edfab --- /dev/null +++ b/backend/README.md @@ -0,0 +1,131 @@ +# QA-Board Backend +QA-Board's backend built as a [flask](https://flask.pocoo.org) application, with packages managed by `uv`. It exposes an HTTP API used to read/write all the metadata on QA-Board's runs. + +## How to start a development backend +1. First get the code: +```bash +git clone git@gitlab-srv:common-infrastructure/qaboard.git +cd qaboard +``` + +2. If you want to run a frontend, [go to the README](../webapp/README.md) and without docker run `cd webapp; npm install`. + +3. Edit at the top-level of the repository _development.yml_, and replace `arthurf` with your user. Edit _services/backend/passwd_ and add a line with your user, looking like `arthurf:*:11611:10:Arthur Flam:/home/arthurf:/bin/tcsh`. You can get it with `getent passwd | grep arthurf`. + +4. Start the server: + +```bash +# At SIRC we need to make sure important folders are mounted before starting containers... +./at-sirc-before-up.py + +docker compose -f docker-compose.yml -f development.yml -f sirc.yml up -d + +# for more build logs +export BUILDKIT_PROGRESS=plain +``` + +> **Tip:** If you called `npm install` in the *webapp/*, (see the [README](../webapp)), a frontend connected to the dev backend will also be up on port 3000. + +Get logs and a shell with: +``` +docker compose -f docker-compose.yml -f development.yml -f sirc.yml logs -f backend +docker compose -f docker-compose.yml -f development.yml -f sirc.yml exec backend bash +``` + +Edit _development.yml_ as suits your needs to e.g. change connect to another database using `QABOARD_DB_HOST`. + +Consult also: +- [Starting QA-Board Guide](https://samsung.github.io/qaboard/docs/deploy). +- [Troubleshooting Guide](https://samsung.github.io/qaboard/docs/backend-admin/troubleshooting). +- To learn how to restore from a backup, read the [upgrade guide](https://samsung.github.io/qaboard/docs/backend-admin/host-upgrades). + +At SIRC: +```bash +sudo sysctl -w net.core.somaxconn=65536 +``` + +## Overview +[sqlalchemy](http://docs.sqlalchemy.org/en/latest/orm/tutorial.html) maps our classes (defined in [/models](models/)) to database tables: + * **Projects** + * Versions of the code, called **CiCommits** + * Each commit has **Batches** of related **Outputs** + * Each output was run on a specific **TestInputs** + +Flask helps us create an HTTP server. It exposes API endpoints defined in the [api/](api/) folder. +- `api.py`: read/list data about projects/commits/outputs +- `webhooks.py`: listens for (i) push notification from gitlab (ii) new results sent by `qa`. +- `tuning.py`: ask for new tuning runs, + +`database.py` manages how we access our database, and connects to the git repository via `gitpython`. + +## Changing the database schemas +- when you add/rename/delete tables or fields to the database, you should define a migration + * we use [`alembic`](http://alembic.zzzcomputing.com/en/latest/tutorial.html) to manage migrations + * you'll find [many examples here](alembic/versions) + +It's useful to connect to `pgadmin` on the URL `/pgadmin4` (user/pass in the `docker-compose.yml`) + + +## Adding or upgrading packages, migrations... +Start a shell in the container to access `uv` or `alembic`: +```bash +# you'd run something like this +docker compose -f docker-compose.yml -f development.yml -f sirc.yml build backend +su $USER +uv sync +# .. +cd backend +alembic --help +``` + + +## SQL performance +### Custom database config +Get a sample config: + +```bash +docker run -i --rm postgres:12-alpine cat /usr/local/share/postgresql/postgresql.conf.sample > services/db/postgres.conf +``` + +And add it to your `db` container: +```yaml + db: + volumes: + - ./services/db/postgres.conf:/var/lib/postgresql/data/postgresql.conf +``` + +### Tuning +Queries: +- In the backend, set `QABOARD_DB_ECHO=true` to see all SQL queries +- Get an SQL prompt with `docker compose exec db psql -U qaboard` and play with `EXPLAIN ANALYZE my-query`. +- `pgadmin` is available by default on port 5050. + +Tuning: +- [Read here](https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server) about how to investigate the database's performance. + +```bash +# Check performance issues with +# https://github.com/jfcoz/postgresqltuner +apt-get install -y libdbd-pg-perl +postgresqltuner.pl --host=localhost --database=qaboard --user=ci --password=password + +# or we can also use pgbadger: +# https://github.com/dalibo/pgbadger +``` + + +## Monitoring & Application performance (WIP) +To get information about how much time is spend where in the python code: +```python +from ..utils import profiled +with profiled(): + ... # code to be profiled +``` + +### SENTRY (Application Monitoring and Error Tracking Software) +To integrate with SENTRY server, add an environment variable __SENTRY_DSN__ to _\.yml_, for example: +```yml + backend: + environment: + - SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 +``` \ No newline at end of file diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py new file mode 100755 index 000000000..1862a9cef --- /dev/null +++ b/backend/backend/__init__.py @@ -0,0 +1,90 @@ +import os +from .database import db_session, Session + +# Configure the flask application +from flask import Flask +from flask_cors import CORS +app = Flask(__name__) + +# This key will be used to sign session cookies +# To generate a key: python -c 'import os; print(os.urandom(16))' +app.secret_key = os.environ.get('SECRET_KEY', 'please-generate-your-own-secret-key') + +if os.environ.get('FLASK_ENV') == 'production' and os.environ.get('SENTRY_DSN'): + # send errors to sentry server + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + + # TODO: add IT's certificate and remove this... + import urllib3 + urllib3.disable_warnings() + class InsecureHttpTransport(sentry_sdk.transport.HttpTransport): + def _get_pool_options(self): + options = super()._get_pool_options() + options["cert_reqs"] = "CERT_NONE" # Ignore SSL Errors + return options + + sentry_sdk.init( + dsn=os.environ.get('SENTRY_DSN'), + integrations=[ + FlaskIntegration(), + ], + traces_sample_rate=float(os.environ.get('SENTRY_SAMPLE_RATE', 0.2)), + transport=InsecureHttpTransport, # TODO: remove this... + # ca_certs="some/place/sirc-certificate-authority.pem" + ) + +# Provide easy access to our git repositories +from .git_utils import Repos +from .config import git_server, qaboard_data_git_dir +repos = Repos(git_server, qaboard_data_git_dir) + + +# Some magic to use sqlalchemy safely with Flask +# http://flask.pocoo.org/docs/0.12/patterns/sqlalchemy/ +from backend.database import db_session, engine, Base +@app.teardown_appcontext +def shutdown_session(exception=None): + db_session.remove() + +import backend.api.api +import backend.api.commit +import backend.api.batch +import backend.api.outputs +import backend.api.webhooks +import backend.api.integrations +import backend.api.tuning +import backend.api.export_to_folder +import backend.api.image +import backend.api.milestones +import backend.api.auth +import backend.api.tasks + +# Enable cross-origin requests to avoid development headcaches +# cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) +CORS(app) + +Base.metadata.create_all(engine) + + +def warm_cache(): + """Warm up cache when a worker starts.""" + print("Warming cache in worker") + from backend.utils import get_users_per_name + users = get_users_per_name("") + print(f"Loaded info about {len(users)} users") + # https://chatgpt.com/share/67c6e90f-f8b8-8000-953b-b164371166c9 + # Avoids errors + # > sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) lost synchronization with server: got message type " " + # https://docs.sqlalchemy.org/en/13/core/pooling.html#pooling-multiprocessing + # https://stackoverflow.com/questions/43648075/uwsgi-flask-sqlalchemy-intermittent-postgresql-errors-with-warning-there-is-al + # https://uwsgi-docs.readthedocs.io/en/latest/articles/TheArtOfGracefulReloading.html#preforking-vs-lazy-apps-vs-lazy + # https://stackoverflow.com/questions/41279157/connection-problems-with-sqlalchemy-and-multiple-processes + engine.dispose() + +try: + import uwsgi + uwsgi.post_fork_hook = warm_cache +except: + pass + diff --git a/qaboard-backend/slamvizapp/alembic.ini b/backend/backend/alembic.ini old mode 100644 new mode 100755 similarity index 94% rename from qaboard-backend/slamvizapp/alembic.ini rename to backend/backend/alembic.ini index d59419aa4..dca1d88b3 --- a/qaboard-backend/slamvizapp/alembic.ini +++ b/backend/backend/alembic.ini @@ -35,8 +35,8 @@ script_location = alembic # are written from script.py.mako # output_encoding = utf-8 -# is set in slamvizapp/env.py -# sqlalchemy.url = postgresql://ci:dvsdvs@localhost:5432/slamvizapp +# is set in backend/env.py +# sqlalchemy.url = postgresql://ci:dvsdvs@localhost:5432/qaboard # Logging configuration diff --git a/qaboard-backend/slamvizapp/alembic/README b/backend/backend/alembic/README similarity index 100% rename from qaboard-backend/slamvizapp/alembic/README rename to backend/backend/alembic/README diff --git a/qaboard-backend/slamvizapp/alembic/env.py b/backend/backend/alembic/env.py similarity index 95% rename from qaboard-backend/slamvizapp/alembic/env.py rename to backend/backend/alembic/env.py index 35a06fdca..6ce7115f7 100644 --- a/qaboard-backend/slamvizapp/alembic/env.py +++ b/backend/backend/alembic/env.py @@ -3,7 +3,7 @@ from sqlalchemy import create_engine from logging.config import fileConfig -from slamvizapp.database import engine_url, engine +from backend.database import engine_url, engine # this could be used for automatic migrations # context.configure(compare_type = True) @@ -20,7 +20,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -from slamvizapp.models import Base +from backend.models import Base target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, diff --git a/qaboard-backend/slamvizapp/alembic/script.py.mako b/backend/backend/alembic/script.py.mako similarity index 100% rename from qaboard-backend/slamvizapp/alembic/script.py.mako rename to backend/backend/alembic/script.py.mako diff --git a/qaboard-backend/slamvizapp/alembic/versions/10dea94d2dc1_add_an_output_dir_override_folder.py b/backend/backend/alembic/versions/10dea94d2dc1_add_an_output_dir_override_folder.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/10dea94d2dc1_add_an_output_dir_override_folder.py rename to backend/backend/alembic/versions/10dea94d2dc1_add_an_output_dir_override_folder.py diff --git a/qaboard-backend/slamvizapp/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py b/backend/backend/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py similarity index 97% rename from qaboard-backend/slamvizapp/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py rename to backend/backend/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py index 28dfe1011..e876f7d66 100644 --- a/qaboard-backend/slamvizapp/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py +++ b/backend/backend/alembic/versions/156752e2d05e_added_ids_for_git_commit_parents.py @@ -14,7 +14,7 @@ Base = declarative_base() Session = sessionmaker() -from slamvizapp import repos +from backend import repos # revision identifiers, used by Alembic. revision = '156752e2d05e' diff --git a/backend/backend/alembic/versions/44c55bb36f57_add_batch_batch_dir_override.py b/backend/backend/alembic/versions/44c55bb36f57_add_batch_batch_dir_override.py new file mode 100644 index 000000000..6e768d60b --- /dev/null +++ b/backend/backend/alembic/versions/44c55bb36f57_add_batch_batch_dir_override.py @@ -0,0 +1,26 @@ +"""Add Batch.batch_dir_override + +Revision ID: 44c55bb36f57 +Revises: c44a0b869765 +Create Date: 2020-08-02 05:39:48.915317 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '44c55bb36f57' +down_revision = 'c44a0b869765' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('batches', sa.Column('batch_dir_override', sa.String)) + + +def downgrade(): + op.drop_column('batches', 'batch_dir_override') +# 13:27.40 +# 13: \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/alembic/versions/5720713911df_add_an_output_type_field.py b/backend/backend/alembic/versions/5720713911df_add_an_output_type_field.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/5720713911df_add_an_output_type_field.py rename to backend/backend/alembic/versions/5720713911df_add_an_output_type_field.py diff --git a/backend/backend/alembic/versions/5f4fae68863e_add_sso_to_users.py b/backend/backend/alembic/versions/5f4fae68863e_add_sso_to_users.py new file mode 100644 index 000000000..74dde9535 --- /dev/null +++ b/backend/backend/alembic/versions/5f4fae68863e_add_sso_to_users.py @@ -0,0 +1,25 @@ +"""add_sso_to_users + +Revision ID: 5f4fae68863e +Revises: 44c55bb36f57 +Create Date: 2023-07-20 09:14:59.224465 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5f4fae68863e' +down_revision = '44c55bb36f57' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('users', sa.Column('is_sso', sa.Boolean)) + op.drop_constraint('users_full_name_key', 'users') + +def downgrade(): + op.drop_column("users", "is_sso") + op.create_unique_constraint('users_full_name_key', 'users', ['full_name']) \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/alembic/versions/6f8f309611c5_rename_slam_output_to_output.py b/backend/backend/alembic/versions/6f8f309611c5_rename_slam_output_to_output.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/6f8f309611c5_rename_slam_output_to_output.py rename to backend/backend/alembic/versions/6f8f309611c5_rename_slam_output_to_output.py diff --git a/qaboard-backend/slamvizapp/alembic/versions/7bb944065bfd_added_data_json_field_for_batch.py b/backend/backend/alembic/versions/7bb944065bfd_added_data_json_field_for_batch.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/7bb944065bfd_added_data_json_field_for_batch.py rename to backend/backend/alembic/versions/7bb944065bfd_added_data_json_field_for_batch.py diff --git a/qaboard-backend/slamvizapp/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py b/backend/backend/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py similarity index 97% rename from qaboard-backend/slamvizapp/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py rename to backend/backend/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py index f3b51e732..449681265 100644 --- a/qaboard-backend/slamvizapp/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py +++ b/backend/backend/alembic/versions/80a0b3ed9dd1_allow_sub_projects.py @@ -104,6 +104,9 @@ def downgrade(): # main downgrade op.alter_column('ci_commits', 'hexsha', type_=sa.String, new_column_name='id') op.create_primary_key('ci_commits_pkey', 'ci_commits', ['id']) + bind = op.get_bind() + session = Session(bind=bind) + ci_commits = session.query(CiCommitOld) for ci_commit in ci_commits: ci_commits.id = ci_commit.hexsha diff --git a/qaboard-backend/slamvizapp/alembic/versions/847475604161_added_data_json_field_for_ci_commit.py b/backend/backend/alembic/versions/847475604161_added_data_json_field_for_ci_commit.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/847475604161_added_data_json_field_for_ci_commit.py rename to backend/backend/alembic/versions/847475604161_added_data_json_field_for_ci_commit.py diff --git a/qaboard-backend/slamvizapp/alembic/versions/8d684ac2793b_remove_hardcoded_test_input_data_columns.py b/backend/backend/alembic/versions/8d684ac2793b_remove_hardcoded_test_input_data_columns.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/8d684ac2793b_remove_hardcoded_test_input_data_columns.py rename to backend/backend/alembic/versions/8d684ac2793b_remove_hardcoded_test_input_data_columns.py diff --git a/backend/backend/alembic/versions/8d84dfaf350d_convert_configuration_to_jsonb_as_.py b/backend/backend/alembic/versions/8d84dfaf350d_convert_configuration_to_jsonb_as_.py new file mode 100644 index 000000000..5c7148ee3 --- /dev/null +++ b/backend/backend/alembic/versions/8d84dfaf350d_convert_configuration_to_jsonb_as_.py @@ -0,0 +1,98 @@ +"""Convert configuration to JSONB as configurations + +Revision ID: 8d84dfaf350d +Revises: c10df60dd41c +Create Date: 2020-03-11 08:31:41.905096 + +""" +import time + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +import sqlalchemy.dialects.postgresql as postgresql + +from sqlalchemy.orm import sessionmaker, Session as BaseSession +from sqlalchemy.ext.declarative import declarative_base + +from qaboard.conventions import deserialize_config, serialize_config + +Base = declarative_base() +Session = sessionmaker() + +class Output(Base): + __tablename__ = 'outputs' + id = sa.Column(sa.Integer, primary_key=True) + configuration = sa.Column(sa.String()) + configurations = sa.Column(JSONB(), default=[]) + +# revision identifiers, used by Alembic. +revision = '8d84dfaf350d' +down_revision = 'c10df60dd41c' +branch_labels = None +depends_on = None + + +def upgrade(): + start = time.time() + print('Upgrading extra_parameters') + op.alter_column('outputs', 'extra_parameters', type_=postgresql.JSONB, postgresql_using='extra_parameters::text::jsonb') + + print(f'Upgrading configurations [{time.time() - start:.1f}s]') + op.execute('ALTER TABLE "outputs" ADD COLUMN configurations jsonb;') + # the column needs to really exist before the calls to bulk_update_mapping() + # op.add_column('outputs', sa.Column('configurations', JSONB)) + + bind = op.get_bind() + session = Session(bind=bind, autoflush=False) + + batch = [] + batch_size = 50_000 + output_total = 1_608_000 + start_batch = time.time() + for idx, o in enumerate(session.query(Output)): + if idx % batch_size == 0: + print(o, deserialize_config(o.configuration)) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + session.bulk_update_mappings(Output, batch) + session.flush() + batch = [] + batch.append({"id": o.id, "configurations": deserialize_config(o.configuration)}) + + print(f"DONE, now committing configurations [elapsed time: {now - start:.1f}s]") + session.bulk_update_mappings(Output, batch) + session.flush() + session.commit() + print(f"DONE, now dropping configuration [elapsed time: {now - start:.1f}s]") + op.drop_column("outputs", "configuration") + + print('Creating idx_outputs_filter') + op.create_index('idx_outputs_filter', 'outputs', ["batch_id", "test_input_id", "platform"]) + + + + +def downgrade(): + start = time.time() + print('Downgrading extra_parameters') + op.alter_column('outputs', 'extra_parameters', type_=sa.dialects.postgresql.JSON, postgresql_using='extra_parameters::jsonb::text') + + print(f'Removing idx_outputs_filter [{time.time() - start:.1f}s]') + op.drop_index('idx_outputs_filter') + + print(f'Downgrading configuration [{time.time() - start:.1f}s]') + op.add_column('outputs', sa.Column('configuration', sa.STRING)) + bind = op.get_bind() + session = Session(bind=bind) + for idx, o in enumerate(session.query(Output)): + if idx % 100 == 0: + print(f"#{idx} [{time.time() - start}s]") + session.commit() + o.configuration = serialize_config(o.configurations) + session.add(o) + + session.flush() + session.commit() + op.drop_column("batches", "configurations") diff --git a/qaboard-backend/slamvizapp/alembic/versions/93a369d8ac64_changing_recording_to_input_and_adding_database.py b/backend/backend/alembic/versions/93a369d8ac64_changing_recording_to_input_and_adding_database.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/93a369d8ac64_changing_recording_to_input_and_adding_database.py rename to backend/backend/alembic/versions/93a369d8ac64_changing_recording_to_input_and_adding_database.py diff --git a/backend/backend/alembic/versions/9d423882514e_add_login_type_and_data_to_users_table.py b/backend/backend/alembic/versions/9d423882514e_add_login_type_and_data_to_users_table.py new file mode 100755 index 000000000..29b04f37c --- /dev/null +++ b/backend/backend/alembic/versions/9d423882514e_add_login_type_and_data_to_users_table.py @@ -0,0 +1,59 @@ +"""add login-type and data to users table + +Revision ID: 9d423882514e +Revises: bdacefe1d00c +Create Date: 2023-11-14 14:01:35.046389 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision = '9d423882514e' +down_revision = 'bdacefe1d00c' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('users', sa.Column('login_type', sa.String)) + op.add_column('users', sa.Column('data', JSONB)) + # for each entry, set to login_type with values LOCAL/LDAP/SAML according to is_ldap and is_sso + op.execute(""" + UPDATE users SET login_type = + CASE + WHEN is_sso THEN 'SAML' + WHEN is_ldap THEN 'LDAP' + ELSE 'LOCAL' + END; + """) + + op.drop_column('users', 'is_sso') + op.drop_column('users', 'is_ldap') + + + +def downgrade(): + op.add_column('users', sa.Column('is_sso', sa.Boolean)) + op.add_column('users', sa.Column('is_ldap', sa.Boolean)) + # for each entry, set is_sso,is_ldap according to login_type + op.execute(""" + UPDATE users SET is_sso = + CASE + WHEN login_type='SAML' THEN true + ELSE false + END; + """) + + op.execute(""" + UPDATE users SET is_ldap = + CASE + WHEN login_type='LDAP' THEN true + ELSE false + END; + """) + + op.drop_column('users', 'login_type') + op.drop_column('users', 'data') diff --git a/qaboard-backend/slamvizapp/alembic/versions/b0785fa8ab5a_baseline.py b/backend/backend/alembic/versions/b0785fa8ab5a_baseline.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/b0785fa8ab5a_baseline.py rename to backend/backend/alembic/versions/b0785fa8ab5a_baseline.py diff --git a/backend/backend/alembic/versions/bdacefe1d00c_moving_to_jsonb.py b/backend/backend/alembic/versions/bdacefe1d00c_moving_to_jsonb.py new file mode 100644 index 000000000..713dcb180 --- /dev/null +++ b/backend/backend/alembic/versions/bdacefe1d00c_moving_to_jsonb.py @@ -0,0 +1,41 @@ +"""moving to jsonb + +Revision ID: bdacefe1d00c +Revises: 5f4fae68863e +Create Date: 2023-10-02 14:33:19.765907 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision = 'bdacefe1d00c' +down_revision = '5f4fae68863e' +branch_labels = None +depends_on = None + + + +migrated_tables = ("ci_commits", "batches", "outputs", "projects") +# to test for dev, +# CREATE TABLE outputs_dev AS +# SELECT * FROM outputs +# WHERE outputs.id>21301391; +# and ran the migration, and reverted the alembic version manually in the db +# migrated_tables = ("outputs_dev",) +column = "data" + + +def upgrade(): + for table in migrated_tables: + op.alter_column( + table, column, existing_type=sa.JSON, type_=JSONB, postgresql_using=f"{column}::jsonb" + ) + +def downgrade(): + for table in migrated_tables: + op.alter_column( + table, column, existing_type=JSONB, type_=sa.JSON, postgresql_using=f"{column}::json" + ) diff --git a/qaboard-backend/slamvizapp/alembic/versions/c10df60dd41c_add_deleted_fields.py b/backend/backend/alembic/versions/c10df60dd41c_add_deleted_fields.py similarity index 97% rename from qaboard-backend/slamvizapp/alembic/versions/c10df60dd41c_add_deleted_fields.py rename to backend/backend/alembic/versions/c10df60dd41c_add_deleted_fields.py index 6e5164299..6125714ba 100644 --- a/qaboard-backend/slamvizapp/alembic/versions/c10df60dd41c_add_deleted_fields.py +++ b/backend/backend/alembic/versions/c10df60dd41c_add_deleted_fields.py @@ -73,4 +73,4 @@ def downgrade(): session.add(project) session.commit() - op.alter_column('ci_commits', 'latest_output_datetime', new_column_name='time_of_last_batch', type_=DateTime(timezone=True)) + op.alter_column('ci_commits', 'latest_output_datetime', new_column_name='time_of_last_batch', type_=sa.DateTime(timezone=True)) diff --git a/backend/backend/alembic/versions/c44a0b869765_convert_output_data_to_jsonb.py b/backend/backend/alembic/versions/c44a0b869765_convert_output_data_to_jsonb.py new file mode 100644 index 000000000..1f834515e --- /dev/null +++ b/backend/backend/alembic/versions/c44a0b869765_convert_output_data_to_jsonb.py @@ -0,0 +1,25 @@ +"""Convert output.data to JSONB + +Revision ID: c44a0b869765 +Revises: 8d84dfaf350d +Create Date: 2020-03-25 17:09:15.810682 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy.dialects.postgresql as postgresql + + +# revision identifiers, used by Alembic. +revision = 'c44a0b869765' +down_revision = '8d84dfaf350d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column('outputs', 'data', type_=postgresql.JSONB, postgresql_using='data::text::jsonb') + + +def downgrade(): + op.alter_column('outputs', 'data', type_=sa.dialects.postgresql.JSON, postgresql_using='data::jsonb::text') diff --git a/qaboard-backend/slamvizapp/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py b/backend/backend/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py similarity index 98% rename from qaboard-backend/slamvizapp/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py rename to backend/backend/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py index ee1ef8dc4..b15541dd6 100644 --- a/qaboard-backend/slamvizapp/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py +++ b/backend/backend/alembic/versions/c872ccb5ecf2_added_fields_useful_for_the_cis_.py @@ -21,7 +21,7 @@ Base = declarative_base() Session = sessionmaker() -from slamvizapp import repos +from backend import repos class CiCommit(Base): __tablename__ = 'ci_commits' diff --git a/backend/backend/alembic/versions/cda62d01ead8_add_token_table.py b/backend/backend/alembic/versions/cda62d01ead8_add_token_table.py new file mode 100644 index 000000000..bacb1f0c9 --- /dev/null +++ b/backend/backend/alembic/versions/cda62d01ead8_add_token_table.py @@ -0,0 +1,34 @@ +"""Add Token table + +Revision ID: cda62d01ead8 +Revises: 9d423882514e +Create Date: 2024-10-31 08:50:21.226634 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'cda62d01ead8' +down_revision = '9d423882514e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'tokens', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False, unique=True, index=True), + sa.Column('expires_at', sa.DateTime, nullable=False), + sa.Column('revoked', sa.Boolean, default=False), + sa.Column('created_at', sa.DateTime, default=sa.func.now()) + ) + op.create_index(op.f('ix_tokens_token'), 'tokens', ['token'], unique=True) + + +def downgrade(): + op.drop_index(op.f('ix_tokens_token'), table_name='tokens') + op.drop_table('tokens') \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/alembic/versions/e37bfe94d6e0_added_misc_json_fields_for_outputs_and_.py b/backend/backend/alembic/versions/e37bfe94d6e0_added_misc_json_fields_for_outputs_and_.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/e37bfe94d6e0_added_misc_json_fields_for_outputs_and_.py rename to backend/backend/alembic/versions/e37bfe94d6e0_added_misc_json_fields_for_outputs_and_.py diff --git a/qaboard-backend/slamvizapp/alembic/versions/f6a4bc0b55f8_turning_the_many_metrics_columns_into_a_.py b/backend/backend/alembic/versions/f6a4bc0b55f8_turning_the_many_metrics_columns_into_a_.py similarity index 100% rename from qaboard-backend/slamvizapp/alembic/versions/f6a4bc0b55f8_turning_the_many_metrics_columns_into_a_.py rename to backend/backend/alembic/versions/f6a4bc0b55f8_turning_the_many_metrics_columns_into_a_.py diff --git a/qaboard-backend/slamvizapp/scripts/__init__.py b/backend/backend/api/__init__.py similarity index 100% rename from qaboard-backend/slamvizapp/scripts/__init__.py rename to backend/backend/api/__init__.py diff --git a/backend/backend/api/api.py b/backend/backend/api/api.py new file mode 100755 index 000000000..3dddc37db --- /dev/null +++ b/backend/backend/api/api.py @@ -0,0 +1,213 @@ +""" +Access data related to Projects. +""" +import os +import pytz +import json +import datetime + +import ujson +from flask import request, jsonify, make_response + +from sqlalchemy import func, and_, asc, or_ +from sqlalchemy.orm import selectinload + +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy.sql import label + +from backend import app, db_session +from ..models import Project, CiCommit, Batch, Output +from ..utils import profiled +from .auth import is_authorized_user + +to_datetime = lambda s: timezone.localize(datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ')) +timezone = pytz.timezone("utc") + + +def _parse_json_env(key, default): + raw = os.environ.get(key, default) + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + return json.loads(default) + +_image_servers = _parse_json_env('QABOARD_IMAGE_SERVERS', '{"default": "/iiif"}') +_path_mappings = _parse_json_env('QABOARD_PATH_MAPPINGS', '[]') + + +@app.route("/api/v1/config") +def get_site_config(): + """Return runtime site configuration for the frontend.""" + sample_rate = os.environ.get('SENTRY_TRACES_SAMPLE_RATE', '1.0') + try: + sample_rate = float(sample_rate) + except (ValueError, TypeError): + sample_rate = 1.0 + + return jsonify({ + "image_servers": _image_servers, + "login_type": os.environ.get('QABOARD_LOGIN_TYPE', 'LOCAL'), + "login_required": bool(os.environ.get('QABOARD_LOGIN_REQUIRED', '')), + "sentry_dsn": os.environ.get('SENTRY_DSN'), + "posthog_api_key": os.environ.get('POSTHOG_API_KEY'), + "posthog_host": os.environ.get('POSTHOG_HOST'), + "path_mappings": _path_mappings, + "docs_root": os.environ.get('QABOARD_DOCS_ROOT', 'https://samsung.github.io/qaboard/'), + "avatar_url_template": os.environ.get('QABOARD_AVATAR_URL'), + "sentry_traces_sample_rate": sample_rate, + }) + + +@app.route("/api/v1/commits") +@app.route("/api/v1/commits/") +@app.route("/api/v1/commits/") +def get_commits(branch=None): + project_id = request.args['project'] + + if not is_authorized_user(None, project_id): + return f"Forbidden: You don't have permission to access this project", 403 + + to_date_s = request.args.get('to', None) + now_localized = timezone.localize(datetime.datetime.now()) + + to_date = to_datetime(to_date_s) if to_date_s else now_localized + # We only care about days, and this makes sure we smooth out TZ issues + # It's likely unnecessary... + to_date = to_date + datetime.timedelta(hours=3) + + from_date_s = request.args.get('from', None) + from_date = to_datetime(from_date_s) if from_date_s else (now_localized - datetime.timedelta(days=4)) + ci_commits = (db_session + .query(func.max(CiCommit.authored_datetime)) + .filter(CiCommit.project_id == project_id) + ) + if branch: + branch = branch.replace('origin/', '') + ci_commits = ci_commits.filter(or_(CiCommit.branch == branch, CiCommit.branch == f'origin/{branch}')) + committer_name = request.args.get('committer', None) + if committer_name: + ci_commits = ci_commits.filter_by(committer_name=committer_name) + + latest_authored_datetime = ci_commits.scalar() + if not latest_authored_datetime: + return jsonify([]) + from_date = min(latest_authored_datetime - (to_date - from_date), from_date) + from_date = from_date - datetime.timedelta(hours=3) # timezones as above + + with_outputs = False if request.args.get('with_outputs', 'false')=='false' else True + ci_commits = db_session.query(CiCommit) + if True: # with_outputs: do this when we pre-aggregated counts of Outputs per status... + ci_commits = ci_commits.options(selectinload(CiCommit.batches).selectinload(Batch.outputs)) + else: + ci_commits = ci_commits.options(selectinload(CiCommit.batches)) + ci_commits = (ci_commits + .filter( + CiCommit.authored_datetime >= from_date, + CiCommit.authored_datetime <= to_date, + CiCommit.project_id == project_id, + ) + .order_by(CiCommit.authored_datetime.desc()) + ) + + if committer_name: + ci_commits = ci_commits.filter_by(committer_name=committer_name) + if branch: + ci_commits = ci_commits.filter(or_(CiCommit.branch == branch, CiCommit.branch == f'origin/{branch}')) + + + metrics_to_aggregate = json.loads(request.args.get('metrics', '{}')) + if project_id.startswith("CDE-Users/HW_ALG") or project_id.startswith("aqua/"): # too many results to be fast... + metrics_to_aggregate = {} + + with_batches = None + batch = request.args.get('batch', None) + if batch: + with_batches = [batch] + else: + only_ci_batches = False if request.args.get('only_ci_batches', 'false')=='false' else True + if only_ci_batches: + with_batches = ['default'] + serializable_commits = [] + # with profiled(): + for c in ci_commits.yield_per(1000).limit(1000): + if not c.batches: + continue + serializable_commits.append(c.to_dict( + db_session, + with_aggregation=metrics_to_aggregate, + with_batches=with_batches, + with_outputs=with_outputs + )) + response = make_response(ujson.dumps(serializable_commits)) + response.headers['Content-Type'] = 'application/json' + return response + + +@app.route("/api/v1/project/branches") +def get_branches(): + """Returns a list of that project's branches""" + project_id = request.args.get('project') + branches = (db_session + .query(CiCommit.branch) + .filter(CiCommit.project_id==project_id) + .distinct() + .order_by(CiCommit.branch) + ) + + if not is_authorized_user(None, project_id): + return f"Forbidden: You don't have permission to access this project", 403 + return jsonify([b[0] for b in branches.yield_per(1000)]) + + + +@app.route("/api/v1/projects") +def get_projects(): + projects = (db_session + .query( + Project.id, + Project.data, + Project.latest_output_datetime, + label('latest_commit_datetime', func.max(CiCommit.authored_datetime)), + label('total_commits', func.count(CiCommit.id)), + ) + .join(CiCommit) + .group_by(Project.id) + .order_by(asc(func.lower(Project.id))) + ) + response = {} + for project_id, data, latest_output_datetime, latest_commit_datetime, total_commits in projects.yield_per(1000): + if not is_authorized_user(None, project_id): + continue + + if "qatools_metrics" in data: + del data['qatools_metrics'] + if "qatools_config" in data: + data['qatools_config'] = {"project": data['qatools_config'].get("project", {})} + response[project_id] = { + 'data': data, + 'latest_output_datetime': latest_output_datetime.isoformat() if latest_output_datetime else None, # isoformat not necessary? + 'latest_commit_datetime': latest_commit_datetime.isoformat(), + 'total_commits': total_commits, + } + response = make_response(ujson.dumps(response)) + response.headers['Content-Type'] = 'application/json' + return response + + +@app.route("/api/v1/project") +def get_project(): + project_id = request.args['project'] + project = (Project + .query.filter( + Project.id==project_id, + ) + .one() + ) + + if not is_authorized_user(None, project_id): + return f"Forbidden: You don't have permission to access this project", 403 + + return jsonify(project.data) + + + diff --git a/backend/backend/api/auth.py b/backend/backend/api/auth.py new file mode 100644 index 000000000..88823ff53 --- /dev/null +++ b/backend/backend/api/auth.py @@ -0,0 +1,528 @@ +""" +Authentication for qaboard - LOCAL, LDAP and SAML. +""" +import os +from datetime import timedelta + +import yaml +import ldap +import simplejson +from flask import request, jsonify, redirect, session +from flask_login import LoginManager, login_user, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash +try: + # FIXME: Unfortunately we run often into libxml mismatch version issues + from onelogin.saml2.auth import OneLogin_Saml2_Auth + from onelogin.saml2.utils import OneLogin_Saml2_Utils +except Exception as e: + print(f"WARNING: Could not import onelogin.saml2: {e}") +from backend import app, db_session +from ..models import User, Token + + +login_manager = LoginManager(app) +is_login_restricted = bool(os.getenv("QABOARD_LOGIN_RESTRICTED", False)) # True/False +if is_login_restricted: + users_restrict_yaml = os.getenv("QABOARD_LOGIN_RESTRICTED_YAML") + try: + with open(users_restrict_yaml, 'r') as f: + users_restrict_config = yaml.load(f, Loader=yaml.SafeLoader) + except: + users_restrict_config = {} +else: + users_restrict_config = {} + +login_type = os.getenv("QABOARD_LOGIN_TYPE") # LOCAL/LDAP/SAML +if login_type == "LDAP": + # Server hostname (including port) + ldap_host = os.environ['QABOARD_LDAP_HOST'] + # Server port, usually 389 or 636 if SSL is used. + ldap_port = os.environ.get('QABOARD_LDAP_PORT', 389) + # Search base for users. (Will not be searched recursively) + ldap_user_base = os.environ['QABOARD_LDAP_USER_BASE'] + # The Distinguished Name to bind as, this user will be used to lookup information about other users. + ldap_bind_dn = os.environ['QABOARD_LDAP_BIND_DN'] + # The password to bind with for the lookup user. + ldap_password = os.environ['QABOARD_LDAP_PASSWORD'] + # "User lookup filter, the placeholder {login} will be replaced by the user supplied login. (e.g. `(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))`, or `(&(objectClass=user)(|(sAMAccountName={login})))`) + ldap_user_filter = os.environ['QABOARD_LDAP_USER_FILTER'] + # User attributes + ldap_attr_email = os.environ.get('QABOARD_LDAP_ATTRIBUTE_EMAIL', "mail") + ldap_attr_common_name = os.environ.get('QABOARD_LDAP_ATTRIBUTE_COMMON_NAME', "cn") + +elif login_type == "SAML": + # the directory that contains the settings files and certs + app.config['SAML_PATH'] = os.path.abspath(os.getenv('QABOARD_SAML_DIR')) + # User attributes + saml_attr_email = os.environ.get('QABOARD_SAML_ATTRIBUTE_EMAIL') + saml_attr_user_name = os.environ.get('QABOARD_SAML_ATTRIBUTE_USER_NAME') + saml_attr_common_name = os.environ.get('QABOARD_SAML_ATTRIBUTE_COMMON_NAME') + # saml_attr_id = os.environ.get('QABOARD_SAML_ATTRIBUTE_ID') + + +# FIXME: Today User.data is a bunch of LDAP info stuffed directly. +# Needs to be namespaced under "ldap". + + +@app.route('/api/v1/user/token/', methods=['POST']) +def create_token(): + if not current_user.is_authenticated: + return f"Not logged-in", 403 + # user = User.query.filter_by(user_name='sircdevops').one() + # login_user(user, remember=False, duration=timedelta(days=180)) + token = Token(current_user) + db_session.add(token) + db_session.commit() + return jsonify({ + "token": token.token, + "created_at": token.created_at.isoformat(), + "expires_at": token.expires_at.isoformat() if token.expires_at else None, + }) + + +@app.route('/api/v1/user/signup/', methods=['POST']) +def signup(): + if os.environ.get("QABOARD_DISABLE_SIGNUP") == "True": + return f"Signup disabled", 403 + try: + user = create_user({ + "email": request.form.get('email'), + "user_name": request.form.get('user_name'), + "full_name": request.form.get('full_name'), + "password": request.form.get('password'), + "login_type": "LOCAL", + "data": {}, + }) + except Exception as e: + print(f"[signup] Error when creating new user with {request.form}: {e}") + return f"{e}", 403 + return jsonify({ + "id": user.id, + "email": user.email, + "user_name": user.user_name, + "full_name": user.full_name, + "login_type": user.login_type, + }) + + +@app.route('/api/v1/user/auth/', methods=['POST']) +def auth_post(): + if current_user.is_authenticated: + logout_user() + username = request.form.get('username') + password = request.form.get('password') + user_info = auth(username, password) + if not user_info["login_success"]: + print(f"[auth] Failed Login @{username}") + return jsonify({"error": user_info["error"]}), 403 + + user = User.query.filter_by(user_name=username).one() + login_user(user, remember=True, duration=timedelta(days=180)) + print(f"[auth] Login @{username}") + return jsonify(user_info) + + +@app.route('/api/v1/user/me/', methods=['GET']) +def get_current_user(to_jsonify=True): + if login_type == "SAML": + info = { + "is_authenticated": False, + } + if 'samlUserdata' in session: + if len(session['samlUserdata']) > 0: + samlUserdata = session['samlUserdata'] + user_name = samlUserdata.get(saml_attr_user_name, [])[0] + # email = samlUserdata.get(saml_attr_email, [])[0] + # full_name = samlUserdata.get(saml_attr_common_name, [])[0] + # user_id = samlUserdata.get(saml_attr_id, [])[0] + user = User.query.filter_by(user_name=user_name).one_or_none() + info.update({ + "is_authenticated": True, + "login_type": login_type, + "user_id": user.id, + "user_name": user.user_name, + "full_name": user.full_name, + "email": user.email, + "data": user.data, + }) + else: # login_type != "SAML" + # https://flask-login.readthedocs.io/en/latest/#your-user-class + is_authenticated = current_user.is_authenticated + info = { + "is_authenticated": is_authenticated, + "is_anonymous": current_user.is_anonymous, + "is_active": current_user.is_active, + } + if is_authenticated: + info.update({ + "user_id": current_user.id, + "user_name": current_user.user_name, + "full_name": current_user.full_name, + "email": current_user.email, + "login_type": current_user.login_type, + }) + + if to_jsonify: + return jsonify(info) + else: + return info + + +@app.route('/api/v1/user/logout/', methods=['POST']) +def logout(): + if current_user.is_authenticated: + logout_user() + return jsonify({"status": "OK"}) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(user_id) + + +@login_manager.request_loader +def load_user_from_request(request): + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith("Bearer "): + token_str = auth_header.replace("Bearer ", "") + else: + token_str = request.args.get('token') + if not token_str: + return None + + token = Token.query.filter_by(token=token_str).first() + if token and token.is_valid(): + user = User.query.get(token.user_id) + if user: + login_user(user) + return user + return None + + +def create_user(info): + if not info["user_name"]: + raise Exception("ERROR: cannot create a new user, missing user_name\n") + + user = User( + user_name=info["user_name"], + full_name=info["full_name"], + email=info["email"], + login_type=info["login_type"], + # is_ldap=info["is_ldap"], + # is_sso=info["is_sso"], + data=info["data"], + # TODO: use a slower hash, currently the default is pbkdf2:sha256 + # https://werkzeug.palletsprojects.com/en/1.0.x/utils/#werkzeug.security.generate_password_hash + password=generate_password_hash(info["password"]) if "password" in info else None, + ) + db_session.add(user) + db_session.commit() + print(f"Created {user}") + return user + + +def update_user(user, info): + if not info["user_name"]: + raise Exception("ERROR: cannot create a new user, missing user_name\n") + + user_info = { + "user_name":info["user_name"], + "full_name":info["full_name"], + "email":info["email"], + "login_type":info["login_type"], + "data":info["data"], + "password": generate_password_hash(info["password"]) if "password" in info else None, + } + user.update(**user_info) + db_session.add(user) + db_session.commit() + return user + + +def is_authorized_user(user_info: dict, project=None): + """ + Check if the given user is authorized to access the server or to a specified project. + + This function determines if a user is authorized by verifying their information + and optionally checking their access rights to a particular project. If no + `user_info` is provided, the current user's information is retrieved and used. + + Args: + user_info (dict): A dictionary containing information about the user. + project (optional): The project to check authorization for. + If `None`, the function only checks general user authorization to the server. + + Returns: + bool: True if the user is authorized to access the project (or authorized in general + if no project is provided), False otherwise. + """ + if not user_info: + user_info = get_current_user(to_jsonify=False) + + if project: + if not users_restrict_config.get('projects'): + return True + # check if project is projects + if not users_restrict_config['projects'].get(project): + # check if a father project exists + + + # Find all strings in list_of_strs that start with the same prefix as my_str + matching_strs = [s for s in users_restrict_config['projects'].keys() if project.startswith(s)] + # Get the longest string from the matching strings + if matching_strs: + project = max(matching_strs, key=len) + else: + # Project is public + return True + + is_authorized = False + perms_data = users_restrict_config['projects'][project] if project else users_restrict_config.get('login', {}) + if not perms_data: + return True + for key, value in user_info.items(): + if is_authorized: break + if key in perms_data.keys(): + if isinstance(value, str): + is_authorized = value in perms_data[key] + elif isinstance(value, list): + is_authorized = any([v for v in value if v in perms_data[key]]) + elif isinstance(value, dict): + for inner_key, inner_value in value.items(): + if is_authorized: break + if inner_key in perms_data[key].keys(): + print([v for v in inner_value if v in perms_data[key][inner_key]]) + is_authorized = any([v for v in inner_value if v in perms_data[key][inner_key]]) + return is_authorized + + +def auth(username, password): + user = User.query.filter_by(user_name=username).first() # if this returns a user, then the user_name already exists in database + # FIXME: check we render the error field in JS, not invalid_password=True.. + if login_type == "LDAP" and (not user or (user.login_type == 'LDAP')): + return auth_ldap(username, password) + # elif login_type == "SAML" and (not user or (user.login_type == 'SAML')): + # return auth_sso(username, password) + else: + return auth_local(username, password) + + +def auth_local(username, password): + info = { + "user_name": username, + "login_type": "LOCAL", + "login_success": False, + } + + if is_login_restricted and not is_authorized_user(info): + info["error"] = f"The user is not authorized, please contact qaboard Admins.\n user_info{info}" + session.clear() + return info + user = User.query.filter_by(user_name=username).one_or_none() + if not user: + info["error"] = "invalid-username" + elif not check_password_hash(user.password, password): + info["error"] = "invalid-password" + else: + info["login_success"] = True + info["id"] = user.id + info["full_name"] = user.full_name + info["user_name"] = user.user_name + info["email"] = user.email + info["data"] = user.data + return info + +def auth_ldap(user_name, password): + if login_type != "LDAP": + raise Exception("LDAP authentication is disabled") + user_info = { + "user_name": user_name, + "login_type": login_type, + "login_success": False, + } + # TODO: support for secure LDAP + ldap_uri = f"ldap://{ldap_host}" if not ldap_port else f"ldap://{ldap_host}:{ldap_port}" + ldap_connect = ldap.initialize(ldap_uri) + ldap_connect.set_option(ldap.OPT_REFERRALS, 0) + ldap_connect.simple_bind_s(ldap_bind_dn, ldap_password) + + # check if the user exists + ldap_search = ldap_user_filter.replace("{login}", user_name) + certificate = ldap_connect.search_s( + ldap_user_base, + ldap.SCOPE_SUBTREE, + ldap_search, + ['distinguishedName'], + )[0][0] + + if certificate: + # check the password and get the full user info + try: + ldap_connect.set_option(ldap.OPT_REFERRALS, 0) + ldap_connect.simple_bind_s(certificate, password) + details = ldap_connect.search_s( + ldap_user_base, + ldap.SCOPE_SUBTREE, + ldap_search, + [ldap_attr_common_name, 'mail'], + ) + user_ldap = details[0][1] + user_info["login_success"] = True + user_info["full_name"] = str(user_ldap[ldap_attr_common_name][0], 'utf-8') + if ldap_attr_email not in user_ldap: + user_info["email"] = None + user_info["error"] = f"missing-email-({ldap_attr_email} LDAP attribute)" + else: + user_info["email"] = str(user_ldap[ldap_attr_email][0], 'utf-8') + # serialize and deserialize to str-json, to avoid dealing with bytes-type errors. # FIXME: any better solution? + user_info["data"] = simplejson.loads(simplejson.dumps(details)) + except (ldap.INVALID_CREDENTIALS, ldap.OPERATIONS_ERROR): + user_info["error"] = "invalid-password" + else: + user_info["error"] = "invalid-username" + ldap_connect.unbind_s() + + if user_info["login_success"]: + if is_login_restricted and not is_authorized_user(user_info): + user_info["login_success"] = False + user_info["error"] = f"The user is not authorized, please contact qaboard Admins.\n user_info{user_info}" + session.clear() + # return user_info + else: + user = User.query.filter_by(user_name=user_name).one_or_none() + if user: + user = update_user(user, user_info) + else: + user = create_user(user_info) + user_info["id"] = user.id + return user_info + +@app.route('/api/auth/saml20/login/', methods=['GET', 'POST']) +def saml_auth(): + """ SAML Authentication """ + req = prepare_flask_request(request) + auth = init_saml_auth(req) + errors = [] + + + if 'sso' in request.args: + return redirect(auth.login(return_to=request.referrer)) + # If AuthNRequest ID need to be stored in order to later validate it, do instead + # sso_built_url = auth.login() + # session['AuthNRequestID'] = auth.get_last_request_id() + # return redirect(sso_built_url) + elif 'slo' in request.args: + name_id = session_index = name_id_format = name_id_nq = name_id_spnq = None + if 'samlNameId' in session: + name_id = session['samlNameId'] + if 'samlSessionIndex' in session: + session_index = session['samlSessionIndex'] + if 'samlNameIdFormat' in session: + name_id_format = session['samlNameIdFormat'] + if 'samlNameIdNameQualifier' in session: + name_id_nq = session['samlNameIdNameQualifier'] + if 'samlNameIdSPNameQualifier' in session: + name_id_spnq = session['samlNameIdSPNameQualifier'] + session.clear() + return redirect(auth.logout(name_id=name_id, session_index=session_index, nq=name_id_nq, name_id_format=name_id_format, spnq=name_id_spnq)) + elif 'acs' in request.args: + request_id = None + if 'AuthNRequestID' in session: + request_id = session['AuthNRequestID'] + + auth.process_response(request_id=request_id) + errors = auth.get_errors() + not_auth_warn = not auth.is_authenticated() + if len(errors) == 0: + if 'AuthNRequestID' in session: + del session['AuthNRequestID'] + + session['samlUserdata'] = auth.get_attributes() + session['samlNameId'] = auth.get_nameid() + session['samlNameIdFormat'] = auth.get_nameid_format() + session['samlNameIdNameQualifier'] = auth.get_nameid_nq() + session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq() + session['samlSessionIndex'] = auth.get_session_index() + + if not session['samlUserdata']: + errors.append(f"samlUserdata is empty.") + return " ".join(errors), 403 + else: + # if len(session['samlUserdata']) > 0: + samlUserdata = session['samlUserdata'] + user_info = { + "login_type": login_type, + # "is_ldap": False, + # "is_sso": True, + "user_name": samlUserdata.get(saml_attr_user_name, [])[0], + "full_name": samlUserdata.get(saml_attr_common_name, [])[0], + "email": samlUserdata.get(saml_attr_email, [])[0], + "data": dict(samlUserdata), + } + if is_login_restricted and not is_authorized_user(user_info): + errors.append(f"The user is not authorized, please contact qaboard Admins.\n user_info{user_info}") + session.clear() + return " ".join(errors), 403 + + # user_info = get_current_user(to_jsonify=False) + user = User.query.filter_by(user_name=user_info.get("user_name")).one_or_none() + if user: + user = update_user(user, user_info) + else: + user = create_user(user_info) + + self_url = OneLogin_Saml2_Utils.get_self_url(req) + if 'RelayState' in request.form and self_url != request.form['RelayState']: + # TODO: To avoid 'Open Redirect' attacks, before execute the redirection confirm + # the value of the request.form['RelayState'] is a trusted URL. + return redirect(auth.redirect_to(request.form['RelayState'])) + elif 'sls' in request.args: + request_id = None + if 'LogoutRequestID' in session: + request_id = session['LogoutRequestID'] + dscb = lambda: session.clear() + url = auth.process_slo(request_id=request_id, delete_session_cb=dscb) + errors = auth.get_errors() + if len(errors) == 0: + if url is not None: + # TODO: To avoid 'Open Redirect' attacks, before execute the redirection confirm + # the value of the request.form['RelayState'] is a trusted URL. + return redirect(url) + + # Handle bad requests: + # raise Exception(" ".join(errors)) + print(" ".join(errors)) + return " ".join(errors), 403 + + # self_url = OneLogin_Saml2_Utils.get_self_url(req) + # if 'RelayState' in request.form and self_url != request.form['RelayState']: + # return redirect(auth.redirect_to(request.form['RelayState'])) + + +def init_saml_auth(req): + auth = OneLogin_Saml2_Auth(req, custom_base_path=app.config['SAML_PATH']) + return auth + +def prepare_flask_request(request): + # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields + return { + 'https': 'on' if request.environ.get('HTTP_X_FORWARDED_PROTO') == 'https' else 'off', + 'http_host': request.environ.get('HTTP_X_FORWARDED_HOST'), + 'server_port': request.environ.get('HTTP_X_FORWARDED_PORT'), + 'script_name': request.path, + 'get_data': request.args.copy(), + # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + # 'lowercase_urlencoding': True, + 'post_data': request.form.copy() + } + # url_data = urlparse(request.url) + # return { + # 'https': 'on' if request.scheme == 'https' else 'off', + # 'http_host': request.host, + # 'server_port': url_data.port, + # 'script_name': request.path, + # 'get_data': request.args.copy(), + # # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + # # 'lowercase_urlencoding': True, + # 'post_data': request.form.copy() + # } diff --git a/backend/backend/api/batch.py b/backend/backend/api/batch.py new file mode 100644 index 000000000..a51a03e50 --- /dev/null +++ b/backend/backend/api/batch.py @@ -0,0 +1,189 @@ +import json + +from flask import request, jsonify +from sqlalchemy.orm.attributes import flag_modified + +from backend import app, db_session +from ..models import CiCommit, Batch +from .export_to_folder import filter_outputs + + + + +@app.route('/api/v1/batch', methods=['POST']) +@app.route('/api/v1/batch/', methods=['POST']) +def update_batch(): + data = request.get_json() + try: + ci_commit = CiCommit.get_or_create( + session=db_session, + hexsha=request.json['git_commit_sha'], + project_id=request.json['project'], + data=data, + ) + except: + return f"404 ERROR:\n ({request.json['project']}): There is an issue with your commit id ({request.json['git_commit_sha']})", 404 + + batch = ci_commit.get_or_create_batch(data['batch_label']) + # prefix_output_dir for backward-compatibility + batch.batch_dir_override = data.get("batch_dir", data.get("prefix_output_dir")) + + # Clients can store any metadata in each batch. + # Currently it's used by `qa optimize` to store info on iterations + if not batch.data: + batch.data = {} + + for attr in ("qaboard_config", "qaboard_metrics"): + if attr in data: + attr_backward_compat = attr.replace("qaboard", "qatools") + # the first time we see a commit's data, we'll save it + # we used to do it when receiving hooks from source control, + # but it led to huge growth of the ci_commits table in a monorepo + # with 100s of subprojects each full of config data + if attr not in ci_commit.data: + ci_commit.data[attr_backward_compat] = data[attr] + flag_modified(ci_commit, "data") + db_session.add(ci_commit) + db_session.commit() + # And each batch can have changes vs its commit's config and metrics. + # The use case is usually working locally with `qa --share` and + # seeing updated visualizations and metrics. + if ci_commit.data[attr_backward_compat] != data[attr]: + batch.data[attr_backward_compat] = data[attr] + flag_modified(batch, "data") + + batch_data = request.json.get('data', {}) + batch.data = {**batch.data, **batch_data} + + # Save info on each "qa batch" command in the batch, mainly to list them in logs + command = request.json.get('command') + if command: + batch.data["commands"] = {**batch.data.get('commands', {}), **command} + flag_modified(batch, "data") + + # It's a `qa optimzize` experiment + if batch_data.get('optimization'): + if batch_data.get('is_best_iter'): + batch.data['best_iter'] = batch_data['iteration'] + flag_modified(batch, "data") + # we will save the outputs from the best iteration in the batch, + # so first we need to remove any previous best results + if not batch_data.get("keep_all_best_iters"): + for o in batch.outputs: + if o.output_type != 'optim_iteration': + print(f" DELETE {o}") + o.delete(soft=False) + db_session.delete(o) + db_session.add(batch) + db_session.commit() + + # Move results from the best iteration in this batch + batch_batch_label = batch_data['iteration_label'] + best_batch = ci_commit.get_or_create_batch(batch_batch_label) + for o in best_batch.outputs: + o.batch = batch + db_session.add(o) + db_session.commit() + + # delete past iterations (we can have "future iters when running in parallel") + # results from the best iter are already in the "main" batch + optim_prefix = f"{data['batch_label']}|iter" + for b in ci_commit.batches: + if not b.label.startswith(optim_prefix): + continue + iteration = int(b.label.replace(optim_prefix, "")) + if iteration <= batch_data['iteration']: + print(f'Deleting iteration {iteration} in {b.label}') + b.delete(db_session) + db_session.delete(b) + + db_session.add(batch) + db_session.commit() + return jsonify({"status": "OK", "id": batch.id}) + + + +@app.route('/api/v1/batch/stop', methods=['POST']) +@app.route('/api/v1/batch/stop/', methods=['POST']) +def stop_batch(): + data = request.get_json() + try: + batch = Batch.query.filter(Batch.id == data['id']).one() + except: + return f"404 ERROR:\n Not found", 404 + status = batch.stop(db_session) + return jsonify(status), 200 if not "error" in status else 500 + +@app.route('/api/v1/batch/redo', methods=['POST']) +@app.route('/api/v1/batch/redo/', methods=['POST']) +def redo_batch(): + data = request.get_json() + try: + batch = Batch.query.filter(Batch.id == data['id']).one() + except: + return f"404 ERROR:\n Not found", 404 + try: + success = batch.redo( + only_failed=data.get('only_failed', False), + only_deleted=data.get('only_deleted', False), + ) + except Exception as e: + return jsonify({"error": f"{e}"}), 500 + if success: + return '{"status": "OK"}' + else: + return jsonify({"error": "Some runs failed to start. Check the 'redo.log' files in the output directories to know more."}), 500 + +@app.route('/api/v1/batch/rename', methods=['POST']) +@app.route('/api/v1/batch/rename/', methods=['POST']) +def rename_batch(): + data = request.get_json() + try: + batch = Batch.query.filter(Batch.id == data['id']).one() + except: + return '{"error":"not found"}', 404 + try: + assert all([b.label != data['label'] for b in batch.ci_commit.batches]) + except: + return '{"error":"already exists {e}"}', 403 + status = batch.rename(label=data['label'], db_session=db_session) + return '{"status": "OK"}' + +# Check move: existing, delete if empty, filter + +@app.route('/api/v1/batch/move', methods=['POST']) +@app.route('/api/v1/batch/move/', methods=['POST']) +def move_batch(): + data = request.get_json() + try: + batch = Batch.query.filter(Batch.id == data['id']).one() + except: + return f"404 ERROR:\n Not found", 404 + dst_batch = batch.ci_commit.get_or_create_batch(data['label']) + for o in filter_outputs(data.get('filter'), batch.outputs): + o.batch = dst_batch + db_session.add(o) + if not batch.outputs: + batch.delete(session=db_session) + db_session.commit() + return '{"status": "OK"}' + + +@app.route('/api/v1/batch/', methods=['DELETE']) +@app.route('/api/v1/batch//', methods=['DELETE']) +def delete_batch(batch_id): + try: + batch = Batch.query.filter(Batch.id == batch_id).one() + except: + return f"404 ERROR:\nNot found", 404 + stop_status = batch.stop(db_session) + if "error" in stop_status: + print(stop_status) + return jsonify(stop_status), 500 + soft = request.args.get('soft') == 'true' + only_failed = request.args.get('only_failed') == 'true' + filter = request.args.get('filter') + batch.delete(session=db_session, soft=soft, only_failed=only_failed, filter=filter) + return {"status": "OK"} + + diff --git a/backend/backend/api/commit.py b/backend/backend/api/commit.py new file mode 100644 index 000000000..a205ecb63 --- /dev/null +++ b/backend/backend/api/commit.py @@ -0,0 +1,166 @@ +import re +import json + +from gitdb.exc import BadName +import ujson +from flask import request, jsonify, make_response + +from sqlalchemy.orm import selectinload +from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm.attributes import flag_modified + +from backend import app, db_session +from .auth import is_authorized_user +from ..models import Project, CiCommit, latest_successful_commit, Batch + + + +@app.route("/api/v1/commit", methods=['GET', 'POST']) +@app.route("/api/v1/commit/", methods=['GET', 'POST']) +@app.route("/api/v1/commit/", methods=['GET', 'POST']) +def api_ci_commit(commit_id=None): + if request.method == 'POST': + hexsha = request.json.get('commit_sha', request.json['git_commit_sha']) if not commit_id else commit_id + try: + commit = CiCommit.get_or_create( + session=db_session, + hexsha=hexsha, + project_id=request.json['project'], + data=request.json, + ) + except Exception as e: + return f"404 ERROR: {e}\n ({request.json['project']}): There is an issue with your commit id ({request.json['git_commit_sha']})", 404 + if not commit.data: + commit.data = {} + # Clients can store any metadata with each commit. + # We've been using it to store code quality metrics per subproject in our monorepo, + # Then we use other tools (e.g. metabase) to create dashboards. + commit_data = request.json.get('data', {}) + commit.data = {**commit.data, **commit_data} + flag_modified(commit, "data") + if commit.deleted: + commit.deleted = False + db_session.add(commit) + db_session.commit() + return jsonify({"status": "OK"}) + + + project_id = request.args['project'] + + if not is_authorized_user(None, project_id): + return f"Forbidden: You don't have permission to access this project", 403 + + if not commit_id: + commit_id = request.args.get('commit', None) + try: + project = Project.query.filter(Project.id==project_id).one() + default_branch = project.data['qatools_config']['project']['reference_branch'] + except: + default_branch = None + branch = request.args.get('branch', default_branch) + ci_commit = latest_successful_commit(db_session, project_id=project_id, branch=branch, batch_label=request.args.get('batch')) + if not ci_commit: + return jsonify({'error': f'Sorry, we cant find any commit with results for the {project_id} on {branch}.'}), 404 + else: + try: + ci_commit = (db_session + .query(CiCommit) + .options( + selectinload(CiCommit.batches). + selectinload(Batch.outputs) + ) + .filter( + CiCommit.project_id==project_id, + CiCommit.hexsha.startswith(commit_id), + ) + .first() + ) + assert ci_commit + # fixme: some commits appear twice, one with a short hash... + # http://alginfra1:6001/CDE-Users/HW_ALG/CIS/tests/products/HM3/commit/2861963a2216816252660bfdd2d9f459ae80b547?reference=ae720d287&batch=default&filter=01_S5KRM1_Nona_12BIT_OUTD02_6576x4992_EIT1.40ms_AGx1_DGx1.ra&selected_views=bit_accuracy + # for commit in ci_commit: + # print(commit, commit.hexsha) + # ci_commit = ci_commit[0] + except (NoResultFound, AssertionError) as e: + try: + # Check if the user provided a branch name instead of a commit ID + ci_commit = (db_session + .query(CiCommit) + .options( + selectinload(CiCommit.batches). + selectinload(Batch.outputs) + ) + .filter( + CiCommit.project_id==project_id, + CiCommit.branch==commit_id, + ) + .order_by(CiCommit.authored_datetime.desc()) + .first() + ) + assert ci_commit + except Exception as e: + # if the user provided what looks like a commit ID, we want to fail fast + # otherwise the code below is super slow + if re.match(r'^[0-9a-fA-F]{40}$', commit_id): + return jsonify({'error': f'Sorry, we could not find any data on commit ID {commit_id} in project {project_id}.'}), 404 + + try: + # TODO: This is a valid use case for having read-rights to the repo, + # we can identify a commit by the tag/branch + # To replace this without read rights, we should listen for push events and build a database + project = Project.query.filter(Project.id==project_id).one() + try: + commit = project.repo.commit(commit_id) + except: + try: + commit = project.repo.refs[commit_id].commit + except: + commit = project.repo.tags[commit_id].commit + if not commit: + return jsonify({'error': f'Sorry, we could not find any data on commit {commit_id} in project {project_id}.'}), 404 + ci_commit = CiCommit(commit, project=project) + db_session.add(ci_commit) + db_session.commit() + except: + return jsonify({'error': f'Sorry, we could not find any data on commit {commit_id} in project {project_id}.'}), 404 + except BadName: + return jsonify({f'error': f'Sorry, we could not understand the commit ID {commit_id} for project {project_id}.'}), 404 + except Exception as e: + raise(e) + return jsonify({'error': 'Sorry, the request failed.'}), 500 + + batch = request.args.get('batch', None) + with_batches = [batch] if batch else None # by default we show all batches + with_aggregation = json.loads(request.args.get('metrics', '{}')) + response = make_response(ujson.dumps(ci_commit.to_dict(db_session, with_aggregation, with_batches=with_batches, with_outputs=True))) + response.headers['Content-Type'] = 'application/json' + return response + + +@app.route("/api/v1/commit/save-artifacts/", methods=['POST']) +@app.route("/api/v1/commit/save-artifacts", methods=['POST']) +def commit_save_artifacts(): + hexsha = request.json.get('hexsha') + try: + ci_commits = (db_session + .query(CiCommit) + .filter( + CiCommit.hexsha == hexsha, + ) + ) + except: + return f"404 ERROR:\n ({request.json['project']}): There is an issue with your commit id ({hexsha})", 404 + for ci_commit in ci_commits.yield_per(1000): + if not request.json['project'].startswith(ci_commit.project_id): + print(f'skip {ci_commit.project_id}') + continue + print(f"[save-artifacts] {ci_commit}") + # FIXME: in the clean crontab we remove commits without runs + # if we rely on artifacts from a subproject without runs, it will cause issues... + # we should use the git info to find the qatools.yaml + ci_commit.save_artifacts() + if ci_commit.deleted: + ci_commit.deleted = False + db_session.add(ci_commit) + db_session.commit() + return 'OK' diff --git a/backend/backend/api/export_to_folder.py b/backend/backend/api/export_to_folder.py new file mode 100755 index 000000000..d2665eaf4 --- /dev/null +++ b/backend/backend/api/export_to_folder.py @@ -0,0 +1,466 @@ +""" +Implement the API used by the "Export to a shared directory" plugin. +""" +import os +import re +import json +import shutil +import hashlib +from pathlib import Path +from functools import lru_cache + +from requests.utils import quote +from flask import request, jsonify +from flask_login import current_user +from sqlalchemy.orm import joinedload +from polyleven import levenshtein as polyleven_levenshtein + + +from qaboard.compat import windows_to_linux_path +from qaboard.conventions import serialize_config +from backend import app, db_session +from backend.fs_utils import as_user, rmtree +from ..models import Project, CiCommit, Batch, slugify_hash +from ..config import qaboard_url + + +@lru_cache(maxsize=1024) +def levenshtein_opt(s1, s2): + s1, s2 = strip_common(s1, s2) + return levenshtein(s1, s2) + +def strip_common(str1, str2): + prefix = os.path.commonprefix([str1, str2]) + str1_no_prefix = str1[len(prefix):] + str2_no_prefix = str2[len(prefix):] + # Find the common suffix by reversing the strings + common_suffix_length = len(os.path.commonprefix([str1_no_prefix[::-1], str2_no_prefix[::-1]])) + if common_suffix_length > 0: + str1_no_prefix = str1_no_prefix[:-common_suffix_length] if common_suffix_length < len(str1_no_prefix) else '' + str2_no_prefix = str2_no_prefix[:-common_suffix_length] if common_suffix_length < len(str2_no_prefix) else '' + return str1_no_prefix, str2_no_prefix + + +# https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Python +@lru_cache(maxsize=4096) +def levenshtein(s1, s2): + if s1 == s2: + return 0 + if s1 == "{}" and s2 == "{}": + return 0 + if s1 == "[]" and s2 == "[]": + return 0 + return polyleven_levenshtein(s1, s2) + + if len(s1) < len(s2): + return levenshtein(s2, s1) + # len(s1) >= len(s2) + if len(s2) == 0: + return len(s1) + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 # j+1 instead of j since previous_row and current_row are one character longer + deletions = current_row[j] + 1 # than s2 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + return previous_row[-1] + + +def load_commit(project_id, commit_id): + if not commit_id: return None + try: + return (db_session + .query(CiCommit) + .options( # avoid n+1 queries + joinedload(CiCommit.batches). + joinedload(Batch.outputs) + ) + .filter( + CiCommit.project_id==project_id, + CiCommit.hexsha==commit_id, + ) + .one() + ) + except: + return None + + + +# no need to make a copy - we don't reuse the outputs +def filter_outputs(query, outputs): + if not query: + return outputs + + query = query.strip().lower().replace('"', '') + query = re.sub(r'(=+|: +)', ':', query) + + tokens = query = query.split() + negative_tokens = [t[1:] for t in tokens if t.startswith('-')] + positive_tokens = [t for t in tokens if not t.startswith('-')] + # print('negative_tokens', negative_tokens) + # print('positive_tokens', positive_tokens) + + def match(output): + # in the web application, json is serialized tight without extra whitespace + extra_parameters = json.dumps(output.extra_parameters, separators=(',', ':')) + configurations = json.dumps(output.configurations, separators=(',', ':')) + input_metadata = output.test_input.data['metadata'] if (output.test_input.data and 'metadata' in output.test_input.data) else {} + input_metadata = json.dumps(input_metadata, separators=(',', ':')) + failed = 'fail crash' if output.is_failed else '' + pending = 'pending running' if output.is_pending else '' + batch = output.data.get("batch") or '' + searched = f"{output.test_input.path} {output.platform} {configurations} {extra_parameters} {batch} {input_metadata} {failed} {pending}".replace('"', '').lower() + # print(searched) + # not using output.test_input_tags.join() like in the JS + if any([re.search(t, searched) for t in negative_tokens]): + return False + found = all([re.search(t, searched) for t in positive_tokens]) + # print(found) + return (not positive_tokens or found) + outputs = [o for o in outputs if match(o)] + return outputs + + +def compatible(o1, o2): + if o1.test_input.abs_path == o2.test_input.abs_path: + return True + if o1.test_input.path == o2.test_input.path: + return True + if o1.test_input.data and o2.test_input.data and o1.test_input.data.get('id') and o1.test_input.data.get('id') == o2.test_input.data.get('id'): + return False + +# Note: already defined in qaboard.tuning.py, but raises instead of returning None +def matching_output(output_reference, outputs): + """ + Return the output from a given batch that looks most similar to a given output. + This helps us compare an output to historical results. + """ + def to_json(a): + json_str = json.dumps(a, sort_keys=True) + # the string edit distance scales quadratically + # we tried to mitigate it + # print(json_str) + json_str = re.sub("(workspace|[{}\", '/.\-_]|global|sim|partial_config|image_writer|config|raw|bmp)", "", json_str) + # print(json_str) + return json_str + possible_matching_outputs = [o for o in outputs if compatible(o, output_reference)] + valid_outputs = [o for o in possible_matching_outputs if not o.is_pending] + if not valid_outputs: return None + + def match_key(output): + return ( + 1 - int(output.test_input.abs_path == output_reference.test_input.abs_path or output.test_input.path == output_reference.test_input.path), + levenshtein_opt(to_json(output.configurations), to_json(output_reference.configurations)), + levenshtein_opt(to_json(output.extra_parameters), to_json(output_reference.extra_parameters)), + output.platform == output_reference.platform, + ) + valid_outputs.sort(key=match_key) + return valid_outputs[0] + + + +def commonprefix(m): + # https://github.com/python/cpython/blob/3.7/Lib/genericpath.py#L69 + if not m: return [] + def key(x): + return (len(x), str(x)) + s1 = min(m, key=key) + s2 = max(m, key=key) + for i, c in enumerate(s1): + # print(i, c, file=sys.stderr) + if c != s2[i]: + return s1[:i] + return s1 + + +@app.route("/api/v1/export") +@app.route("/api/v1/export/") +def export_to_folder(): + project_id = request.args['project'] + ref_project_id = request.args.get('ref_project', project_id) + + new_commit_id = request.args['new_commit_id'] + new_commit = load_commit(project_id, new_commit_id) + if not new_commit: + return f"ERROR: Commit {request.args['new_commit_id']} not found", 404 + new_batch_label = request.args.get('batch_new', 'default') + new_batch = new_commit.get_or_create_batch(new_batch_label) + new_outputs = new_batch.outputs + + filter_new = request.args.get('filter_new') + filter_ref = request.args.get('filter_ref') + + if request.args.get('ref_commit_id'): + ref_commit_id = request.args['ref_commit_id'] + ref_commit = load_commit(ref_project_id, ref_commit_id) + if not ref_commit: + return f"ERROR: Commit {ref_commit_id} not found", 404 + ref_batch_label = request.args.get('batch_ref', 'default') + ref_batch = ref_commit.get_or_create_batch(ref_batch_label) + ref_outputs = ref_batch.outputs + same_ref_new = ref_project_id == project_id and ref_commit_id == new_commit_id and ref_batch_label == new_batch_label and filter_new == filter_ref + if same_ref_new: + ref_commit = None + ref_batch = None + ref_outputs = [] + else: + ref_commit = None + ref_batch = None + ref_outputs = [] + + new_outputs = filter_outputs(filter_new, new_outputs) + ref_outputs = filter_outputs(filter_ref, ref_outputs) + # print("new_outputs", len(new_outputs)) + # print("ref_outputs", len(ref_outputs)) + + # We save the links in a unique folder + query_string = f"{project_id} {new_commit.hexsha} {ref_commit.hexsha if ref_commit else ''} {new_batch.id} {ref_batch.id if ref_batch else ''} {filter_new} {filter_ref}" + m = hashlib.md5(query_string.encode('utf-8')).hexdigest() + + if "export_dir" in request.args: + export_dir = Path(request.args['export_dir']) + export_dir = windows_to_linux_path(export_dir).resolve() + forbidden_dirs = ["/etc", "/bin", "/sbin", "/bin", "/lib", "/arch", "/proc", "/lib64", "/run", "/sys", "/usr/"] + for forbidden_dir in forbidden_dirs: + assert not export_dir.is_relative_to(forbidden_dir) + else: + export_dir = new_commit.repo_outputs_dir / 'share' / m[:8] + + if not export_dir.exists(): + prev_mask = os.umask(000) + try: + export_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + os.umask(prev_mask) + return json.dumps({"error": f"ERROR: When creating '{export_dir}': {e}"}), 403 + os.umask(prev_mask) + + is_export_dir_writable = os.access(export_dir, os.W_OK) + if not is_export_dir_writable: + return json.dumps({"error": f"The export folder need to be writable by all users. Call 'chmod a+w {export_dir}'"}), 403 + + output_refs = {} + for output in new_outputs: + output_refs[output.id] = matching_output(output, ref_outputs) + # if output_refs[output.id]: + # print(" config:", output.configurations) + # print(" match:", output_refs[output.id].configurations) + + # find common characteristics + common_data = {} + if not ref_commit or ref_commit.id == new_commit.id: + common_data['commit'] = new_commit.hexsha + all_outputs = [*new_outputs, *list(output_refs.values())] + all_outputs = [o for o in all_outputs if o] # remove None outputs + all_platforms = {o.platform for o in all_outputs} + if len(all_platforms) == 1: + common_data['platform'] = all_outputs[0].platform + all_configurations = {o.configuration for o in all_outputs} + if len(all_configurations) == 1: + common_data['configurations'] = all_outputs[0].configurations + elif len(all_configurations) > 1: + all_configurations = [o.configurations for o in all_outputs] + common_data['configurations_prefix'] = commonprefix(all_configurations) + all_reversed_configurations = [list(reversed(o.configurations)) for o in all_outputs] + common_data['configurations_suffix'] = list(reversed(commonprefix(all_reversed_configurations))) + all_databases = {o.test_input.database for o in all_outputs} + if len(all_databases) == 1: + common_data['database'] = all_outputs[0].test_input.database + # To be honest, we really should find what is common in each batch + # and use @new-* @ref-*. It gives more flexibility for comparing N batches, and can shorten things even more + + all_extra_parameters = set() + common_extra_parameters = {} + for o in all_outputs: + all_extra_parameters.update(set(o.extra_parameters.keys())) + all_extra_parameters_prefix = commonprefix([p for p in all_extra_parameters]) + for key in all_extra_parameters: + values = set() + for o in all_outputs: + if not o.extra_parameters: o.extra_parameters = {} + o_value = [str(o.extra_parameters.get(key))] + # print(o.id, o_value) + values.update(set(o_value)) + # print(key, values) + if len(values) == 1: + if not all_outputs[0].extra_parameters: all_outputs[0].extra_parameters = {} + common_extra_parameters[key] = all_outputs[0].extra_parameters.get(key) + if common_extra_parameters: + common_data['extra_parameters'] = common_extra_parameters + with (export_dir / '0.common.json').open('w') as f: + json.dump(common_data, f, sort_keys=True, indent=2, separators=(',', ': ')) + + + # we save a mapping label => full into + label_mappings = { + 'extra_parameters': dict(), + 'configurations': dict(), + 'databases': dict(), + } + + glob = request.args.get('path', '*') + nb_files_exported = 0 + errors = [] + for output in new_outputs: + output_ref = output_refs[output.id] + if not output_ref: + output_ref = output + + + def get_labels(output, label_mappings): + labels = [] + if output.batch.ci_commit.hexsha != common_data.get("commit"): + labels.append(output.batch.ci_commit.hexsha[:4]) + if output.platform != common_data.get("platform"): + labels.append(output.platform) + if not common_data.get("configurations"): + def strip_config(c): + c_prefix = serialize_config(common_data.get("configurations_prefix", 'placeholder-placeholder')) + c_suffix = serialize_config(common_data.get("configurations_suffix", 'placeholder-placeholder')) + return c.replace(c_prefix, '').replace(c_suffix, '').replace("crop", "").replace('workspace-configurations-', '') + # print("output.configuration", output.configuration) + c_formatted = strip_config(output.configuration) + # print("c_formatted", c_formatted) + stripped_config = slugify_hash(c_formatted) + # print("stripped_config", stripped_config) + # list of common SIRC-specific names + if stripped_config: + labels.append(stripped_config) + label_mappings['configurations'][stripped_config] = output.configurations + # print('label', stripped_config, output.configurations) + if not common_data.get("database"): + slugify_database = slugify_hash(output.test_input.database) + if slugify_database: + labels.append(slugify_database) + label_mappings['databases'][slugify_database] = output.test_input.database + if str(output.extra_parameters) != str(common_data.get("extra_parameters")): + tame = lambda o: set(((k.replace(all_extra_parameters_prefix, ''), str(v)) for k, v in o.items())) + p = tame(output.extra_parameters) - tame(common_extra_parameters) + # print('common_extra_parameters', common_extra_parameters) + # print('tame(common_extra_parameters)', tame(common_extra_parameters)) + # print('output.extra_parameters', output.extra_parameters) + # print('tame(output.extra_parameters)', tame(output.extra_parameters)) + # print('p_new', p_new) + extra_parameters_label = slugify_hash(str(p)) + label_mappings['extra_parameters'][extra_parameters_label] = output.extra_parameters + if p: labels.append(extra_parameters_label) + stitch = lambda l: f"@{'@'.join(l)}" if l else '' + label = stitch(labels) + # TODO: do we want to raise exception if label is empty? + # if not label: + # raise Exception("no label") + return label + + + label_new = get_labels(output, label_mappings) + label_ref = get_labels(output_ref, label_mappings) + + export_type = request.args.get('export_type', "link") + for output_path in output.output_dir.glob(glob): + try: + output_path_rel = output_path.relative_to(output.output_dir) + copied_to_rel = copy_path_rel(output, output_path, label=label_new) + export_to(export_dir / copied_to_rel, output_path, type=export_type, user=current_user) + # copy(output_path, export_dir / copied_to_rel) + if output_ref and output_ref.id != output.id: + output_path_ref = output_ref.output_dir / output_path_rel + if output_path_ref.exists(): + copied_to_rel = copy_path_rel(output_ref, output_path_ref, label=label_ref) + export_to(export_dir / copied_to_rel, output_path_ref, type=export_type, user=current_user) + nb_files_exported += 1 + # copy(output_path, export_dir / copied_to_rel) + nb_files_exported += 1 + except Exception as e: + error = f"WARNING: Error when trying to export {output_path.name}: {e}" + print(error) + errors.append(error) + + if label_mappings['configurations'] or label_mappings['extra_parameters']: + mappings_path = export_dir / '0.mappings.json' + if not mappings_path.exists(): + with mappings_path.open('w') as f: + json.dump(label_mappings, f, indent=4, sort_keys=True) + + params = { + "batch": new_batch.label, + "reference": ref_commit.hexsha if ref_commit else None, + "batch_ref": ref_batch.label if ref_batch else None, + "filter": filter_new if filter_new else None, + "filter_ref": filter_ref if filter_ref else None, + } + params = {k: quote(v) for k, v in params.items() if v} + url = f"{qaboard_url}/{project_id}/commit/{new_commit.hexsha}?{'&'.join(f'{k}={v}' for k, v in params.items())}" + redirect = f""" + + + + + + Page Redirection + + + + If you are not redirected automatically, follow this link to the QA results. + + """ + redirect_file = export_dir / '0.qa.html' + if not redirect_file.exists(): + with redirect_file.open('w') as f: + f.write(redirect) + + link_content = f"[InternetShortcut]\nURL={url}\n" + link_file = export_dir / '0.qa.url' + if not link_file.exists(): + with link_file.open('w') as f: + f.write(link_content) + return jsonify({ + "export_dir": str(export_dir), + "nb_outputs": len(new_outputs), + "nb_outputs_ref": len(ref_outputs), + "nb_files_exported": nb_files_exported, + "errors": errors, + }) + + + + + +def export_to(path_from, path_to, type, user=None): + if path_from.exists(): + try: + path_from.unlink() + except: + rmtree(path_from) + # print(f"LINK {path_from} -> {path_to} [{type}]") + # print(" ", path_from.owner()) + if type == "link": + try: + os.link(str(path_to), str(path_from)) + except: + os.symlink(str(path_to), str(path_from)) + elif type == "copy": + if not current_user.is_authenticated: + raise Exception("Need Login") + as_user(user.user_name, shutil.copyfile, str(path_to), str(path_from)) + # shutil.copyfile(str(path_to), str(path_from)) + else: # "copy" + raise ValueError("Invalid type. Use 'link' or 'copy'.") + + +def copy_path_rel(output, output_path, label): + try: + output_path_rel = output_path.relative_to(output.batch.output_dir) + # we remove the platform, configuration, and tuning hashes + levels_to_ignore = 2 if output.batch.label == 'default' else 4 + copied_rel = Path(*output_path_rel.parts[levels_to_ignore:]) + except: + output_path_rel = output.test_input.path / output_path.relative_to(output.output_dir) + copied_rel = output_path_rel + copied_rel = copied_rel.parent / f"{output_path_rel.stem}{label}{output_path_rel.suffix}" + copied_rel = str(copied_rel).replace('/', '_') # or \ ? or just name .... ?? + return copied_rel diff --git a/backend/backend/api/image.py b/backend/backend/api/image.py new file mode 100755 index 000000000..175b9b0ad --- /dev/null +++ b/backend/backend/api/image.py @@ -0,0 +1,244 @@ +""" +Returns a list of rois. +Create a pdf report of rois comparison. +""" +import time +from pathlib import Path +from functools import lru_cache + +import numpy as np +from requests.utils import unquote +from flask import request, jsonify + +from cde.image import read_image, ImageType +from qaboard.api import url_to_dir + +from backend import app +from ..models import Output +from ..config import qaboard_url +from .image_diff import find_rois + +@lru_cache(maxsize=2) +def cached_read_image(image_path): + """ + Simple LRU cache - the downside is that our images are huge so with 8 workers each saving 2 image, each 300MB, it's bad... + """ + image, meta = read_image(image_path) + return image, meta + + +# TODO: - add locking when working with flask +# import threading # Lock, Semaphore +# - check locking works ok with uwsgi +try: + import uwsgi + under_uwsgi = True +except: + under_uwsgi = False + +import json +import time +import hashlib + +from backend.config import qaboard_data_dir +image_cache_dir = qaboard_data_dir / 'cache' / 'images' +image_cache_dir = Path('/algo/qa_db/image_cache') # TODO: remove for the open-source version +image_cache_dir.mkdir(exist_ok=True, parents=True) + +def clear_memmapped_cache_dir(): + cache_size = 20 + # it will be called concurrently so maybe the files are already deleted! + file_data = list(image_cache_dir.glob('*.dat')) + + def maybe_mtime(path): + try: # avoid TOCTOU + return path.stat().st_mtime + except: + return None + file_data_ts = [(p, maybe_mtime(p)) for p in file_data] + file_data_ts.sort(key=lambda p_ts: -p_ts[1] if p_ts[1] else 0) # oldest last + for file, _ in file_data_ts[cache_size:]: + print(f"RM {file}") + try: + file.unlink(missing_ok=True) + file_info = file.with_suffix('.json') + file_info.unlink(missing_ok=True) + except: + # other processes might have already deleted the files + pass + + +def memmapped_read_image(data_path, info_path): + with info_path.open() as f: + try: + info = json.load(f) + except: + # It's possible writes from the previous call didn't sync + # on the shared storage... So we retry after waiting just a little bit + time.sleep(1) + try: + info = json.load(f) + except Exception as e: + return None, None, e + fp = np.memmap(data_path, dtype=info['dtype'], mode='r', shape=tuple(info['shape'])) + return fp, info['meta'], None + + +def maybe_memmapped_read_image(image_path): + key = f"{image_path}-{image_path.stat().st_mtime}" + hash = hashlib.sha1(key.encode()).hexdigest() + image_cache_data = image_cache_dir / f"{hash}.dat" + image_cache_info = image_cache_dir / f"{hash}.json" + # FIXME: Avoid partial writes by doing a final rename + is_cached = lambda: image_cache_data.exists() and image_cache_info.exists() + is_cached = lambda: False + if not is_cached(): + print(f'MISS {image_path}') + # if multiple worker processes try to create the cache, we'll run into issues + # https://uwsgi-docs.readthedocs.io/en/latest/Locks.html + if under_uwsgi: + uwsgi.lock() + # it's possible we were waiting for another request that wrote the missing file + if is_cached(): + return memmapped_read_image(image_cache_data, image_cache_info) + + try: + clear_memmapped_cache_dir() + image, meta = read_image(image_path) + print(f'READ', meta) + with image_cache_info.open('w') as fmeta: + json.dump({"meta": meta, "shape": image.shape, "dtype": str(image.dtype)}, fmeta) + fp = np.memmap(image_cache_data, dtype=image.dtype, mode='w+', shape=image.shape) + fp[:] = image[:] + fp.flush() # write to disk + if under_uwsgi: + uwsgi.unlock() + return fp, meta, None + except Exception as e: + if under_uwsgi: + uwsgi.unlock() + return None, None, e + else: + print(f'HIT {image_path}') + return memmapped_read_image(image_cache_data, image_cache_info) + + +@app.route("/api/v1/output/image/pixel", methods=['GET', 'POST']) +def get_pixel(): + x = int(request.args['x'])-1 + y = int(request.args['y'])-1 + image_path = Path(url_to_dir(request.args['image_url'])) + if not image_path.exists(): + return f"ERROR: Cannot find {image_path}", 404 + # We work with huge images (100-200MP). Loading them each request can be very slow (~seconds). + # Since the frontend may request 5-10 pixel values per second, we need some form of caching. + image, meta, error = maybe_memmapped_read_image(image_path) + if error: + return jsonify({"error": str(error)}), 400 + # image, meta = cached_read_image(image_path) + # print('meta', meta) + try: + meta = ImageType(*meta) + except: + pass + if isinstance(meta, ImageType): + meta = {"mode": meta.id} + return jsonify({ + "value": image[y,x].tolist(), + "meta": meta, + }) + + +@app.route("/api/v1/output/image/diff", methods=['GET', 'POST']) +def get_rois(): + data = request.json + print(data) + start = time.time() + image_path_new = url_to_dir(data['output_dir_url_new']) / data["path"] + image_path_ref = url_to_dir(data['output_dir_url_ref']) / data["path"] + blobs = find_rois( + image_path_new, + image_path_ref, + data["diff_type"], + data['threshold'], + data['diameter'], + data['count'] + ) + return jsonify(blobs) + + + +@app.route("/api/v1/output/diff/report", methods=['GET', 'POST']) +def get_report(): + import matplotlib.pyplot as plt + from matplotlib.backends.backend_pdf import PdfPages + + data = request.get_json() + # Directory URLs begin with /s/ + report_folder = Path(data['output_dir_url_new'][2:]) / "reports" + new_url = Path(data['output_dir_url_new'][2:]) / data["path"] + ref_url = Path(data['output_dir_url_ref'][2:]) / data["path"] + rois = data['rois'] + # print(data) # DEBUG + + time_tuple = time.localtime() # get struct_time + time_string = time.strftime("%d%m%Y_%H%M%S", time_tuple) + report_path = f"{report_folder}/{time_string}_report.pdf" + report_url = f"{qaboard_url}/s/{report_folder}/{time_string}_report.pdf" + Path(report_folder).mkdir(parents=True, exist_ok=True) + + image_1, meta_1 = read_image(Path(new_url)) + image_2, meta_2 = read_image(Path(ref_url)) + + with PdfPages(report_path) as pdf: + new_ci_output = Output.query.filter(Output.id == data['output_id_new']).one().batch.ci_commit.hexsha + ref_ci_output = Output.query.filter(Output.id == data['output_id_ref']).one().batch.ci_commit.hexsha + + firstPage = plt.figure(figsize=(10,5)) + firstPage.clf() + txt = f"Auto Rois Report\n{time.asctime(time_tuple)}\nnew: {new_ci_output}\nref: {ref_ci_output}" + + firstPage.text(0.05, 0.5, txt, transform=firstPage.transFigure, size=14, ha='left', linespacing=2) + pdf.savefig() + plt.close() + + for roi in rois: + x, y, w, h = roi['x'], roi['y'] ,roi['w'] ,roi['h'] + crop1 = crop_image(image_1, roi['x'], roi['y'] ,roi['w'] ,roi['h']) + crop2 = crop_image(image_2, roi['x'], roi['y'] ,roi['w'] ,roi['h']) + figure, axes = plt.subplots(1, 2, figsize=(10, 5), sharex=True, sharey=True) + + ax = axes.ravel() + ax[0].imshow(crop1) + ax[1].imshow(crop2) + ax[0].set_title(f"new (x: {x}, y: {y}, w: {w}, h: {h})") + ax[1].set_title("ref") + + figure.canvas.draw() + xlabels = [item.get_text() for item in ax[0].get_xticklabels()] + ylabels = [item.get_text() for item in ax[0].get_yticklabels()] + for i, label in enumerate(xlabels): + try: # The minus signs for negative numbers is encoded as a "minus" (Unicode 2212). + xlabels[i] = int(label) + roi['x'] + except: + continue + + for i, label in enumerate(ylabels): + try: + ylabels[i] = int(label) + roi['y'] + except: + continue + + ax[0].set_xticklabels(xlabels) + ax[0].set_yticklabels(ylabels) + + plt.subplots_adjust(bottom=0.15, wspace=0.01) + pdf.savefig(figure, orientation='portrait') + plt.close() + + print("Report done: ", report_url) + return jsonify(report_url) + + +def crop_image(img, cropx, cropy, cropw, croph): + return img[cropy:cropy+croph, cropx:cropx+cropw] diff --git a/backend/backend/api/image_diff.py b/backend/backend/api/image_diff.py new file mode 100644 index 000000000..e80a2c7de --- /dev/null +++ b/backend/backend/api/image_diff.py @@ -0,0 +1,209 @@ +import os +import time + +import numpy as np +import skimage.color +import skimage.transform +from skimage.feature import peak_local_max, blob_dog # blob_log, blob_doh +from skimage.metrics import structural_similarity as ssim +from scipy import ndimage as ndi + +from cde.image import read_image + +plot_debug = False +if os.environ.get("PLOT_DEBUG"): + plot_debug = True + import matplotlib.pyplot as plt + + +def yiq(img1, img2): + start = time.time() + yuv1 = skimage.color.rgb2yiq(img1) + yuv2 = skimage.color.rgb2yiq(img2) + print("yuv time: {} sec".format(time.time()-start)) + delta2 = np.square(yuv1 - yuv2) # why square? + print("delta2 time: {} sec".format(time.time()-start)) + return delta2 @ [0.5053, 0.299, 0.1957] + + +def diff(image_1, image_2, diff_type="yiq"): + if diff_type == "yiq": + return yiq(image_1, image_2) + elif diff_type == "ssim": + # https://scikit-image.org/docs/stable/auto_examples/transform/plot_ssim.html + # print(image_1.shape) + # print(image_2.shape) + ssim_score, delta = ssim( + image_1, + image_2, + data_range=image_1.max()-image_1.min(), + channel_axis=2, + full=True, + # win_size=3, + ) + delta = 1-np.min(delta, axis=2) + # print("ssim_score", ssim_score) + # print("delta.shape", delta) + return delta + else: + # https://scikit-image.org/docs/stable/api/skimage.color.html#skimage.color.deltaE_ciede2000 + return getattr(skimage.color, f"deltaE_{diff_type}")( + skimage.color.rgb2lab(image_1), + skimage.color.rgb2lab(image_2), + ) # cie76 | ciede2000 | ciede94 + + +def rescale(image): + # TODO: To get better perf with huge (non-BMP?) images, we could use + # https://libvips.github.io/pyvips/intro.html#numpy-and-pil + # https://pypi.org/project/pyvips/ + # https://www.libvips.org/API/current/libvips-resample.html#vips-resize + # https://www.libvips.org/ + # https://stackoverflow.com/a/53728154 + start = time.time() + print(" shape: ", image.shape) + width = image.shape[0] + height = image.shape[1] + pixels = width * height + if pixels < 500_000: + return image, 1.0 + max_dim = max(width, height) + scale = float(512 / max_dim) + print(" scale: ", scale) + image_rescale = skimage.transform.rescale( + image, + scale, + mode='reflect', + channel_axis=2, + anti_aliasing=max_dim<8_000, # we ran into OOM... + ) + print(" rescale time: {} sec".format(time.time()-start)) + return image_rescale, scale + + +def plot_rois(delta, delta_max, coordinates): + fig, axes = plt.subplots(1, 3, figsize=(8, 3), sharex=True, sharey=True) + ax = axes.ravel() + ax[0].imshow(delta, cmap=plt.cm.gray) + ax[0].axis('off') + ax[0].set_title('Original') + + ax[1].imshow(delta_max, cmap=plt.cm.gray) + ax[1].axis('off') + ax[1].set_title('Maximum filter') + + ax[2].imshow(delta, cmap=plt.cm.gray) + ax[2].autoscale(False) + ax[2].plot(coordinates[:, 1], coordinates[:, 0], 'r.') + ax[2].axis('off') + ax[2].set_title('Peak local max') + + fig.tight_layout() + plt.show() + +def find_rois(image_1_path, image_2_path, diff_type, threshold, blob_diameter, count): + # since we have huge images, we try to avoid being out of memory + # and load one at a time if possible... + start = time.time() + image, meta = read_image(image_1_path) + image_shape = image.shape + print(f"read image 1: {time.time()-start}s") + image_1, scale = rescale(image) + print(f"rescaled image 1: {time.time()-start}s") + + image, meta = read_image(image_2_path) + assert image_shape == image.shape + print(f"read image 2: {time.time()-start}s") + image_2, _ = rescale(image) + + # print("image: ", image_1.shape) + # plt.imshow(image_1) + # return + + delta = diff(image_1, image_2, diff_type) + print("diff time: {} sec".format(time.time()-start)) + # print("delta", delta.shape) + # plt.imshow(delta) + # return + + + if True: + delta_size = 20 + delta_max = ndi.maximum_filter(delta, size=delta_size, mode='constant') + delta_max_max = delta_max.max() + # print(f"max diff: {delta.max()}") + # print(f"delta_max_max: {delta_max_max}") + coordinates = peak_local_max(delta, min_distance=delta_size) + # print(coordinates) + # print(len(coordinates)) + if plot_debug: + plot_rois(delta, delta_max, coordinates) + # print("delta_max.shape", delta_max.shape) + # print(coordinates) + blobs = [{ + "x": int(x/scale), + "y": int(y/scale), + "r": int(delta_size/2/scale), # TODO: improve: normalized laplacian... + "diff": float(delta_max[y, x] / delta_max_max), + } for y, x in coordinates.tolist()] + # print(blobs) + blobs.sort(key=lambda b: b["diff"], reverse=True) + return blobs[:count] + + width = image_1.shape[0] + height = image_1.shape[1] + blob_ratio = 0.1 # default ratio for blob diameter + if int(blob_diameter) == 0 : + blob_diameter = (width + height) / 2 * blob_ratio + + # print("blob_diameter: ", blob_diameter) # DEBUG + min_sigma = 5 # for blob_dog algorithm + max_sigma = int(blob_diameter) * scale + if min_sigma >= max_sigma: + min_sigma = 1 + + start = time.time() # DEBUG + blobs = blob_dog(delta, min_sigma=min_sigma, max_sigma=int(max_sigma), threshold=(float(threshold) / 100)) # Divide treshold to increase sensetivity + for blob in blobs: + blob[2] = np.ceil(blob[2]) + print("cluster time: {} sec".format(time.time()-start)) # DEBUG + + if plot_debug: + figure, ax = plt.subplots(figsize=(15, 15)) # DEBUG + ax.imshow(delta) # DEBUG + for blob in blobs: + y, x, r = blob # DEBUG + c = plt.Circle((x, y), r, color="red", linewidth=1, fill=False) # DEBUG + ax.add_patch(c) # DEBUG + plt.tight_layout() # DEBUG + plt.show() + + blobs[:, 0] = blobs[:, 0] * 1 / scale + blobs[:, 1] = blobs[:, 1] * 1 / scale + # The radius of each blob is approximately √2*σ + blobs[:, 2] = blobs[:, 2] * np.sqrt(2) + + # print("blobs size:", blobs.size / 3) # DEBUG + # plt.savefig('C:/Users/itamarp/Desktop/blobs.png', dpi=300) # DEBUG + blobs = [{"x": x, "y": y, "r": r} for x, y, r in blobs] + blobs.sort(key=lambda b: b["r"], reverse=True) + return blobs + + + +################################################################################ +if __name__ == "__main__": + from pathlib import Path + dir_new = '/algo/HP2/outputs/noar/CDE-Users/HW_ALG/3d/725cf7398b523a/CIS/tests/products/HP2/output/abs-test/d471da0e-al/ABS_9Stars_AG6_0x60' + dir_ref = '/algo/HP2/outputs/noar/CDE-Users/HW_ALG/89/02082f8eff5b0a/CIS/tests/products/HP2/output/sds-test/94fd955c-al/ABS_9Stars_AG6_0x60' + path = 'output.bmp' + start = time.time() + print(f"read 2 time: {time.time()-start}s") + find_rois( + Path(dir_new) / path, + Path(dir_ref) / path, + diff_type="yiq", + threshold=0.01, + blob_diameter=0 + ) + print(f"total time: {time.time()-start}s") diff --git a/backend/backend/api/integrations.py b/backend/backend/api/integrations.py new file mode 100755 index 000000000..8cedf5990 --- /dev/null +++ b/backend/backend/api/integrations.py @@ -0,0 +1,475 @@ +""" +Backend API for the integrations features. +""" +import os +import re +import time +import json +from urllib.parse import urlparse + +from flask import request, jsonify, make_response +import requests +from requests import Request, Session +from requests.utils import quote +from requests.auth import HTTPBasicAuth + +from backend import app +from ..config import qaboard_data_dir + +# We love our proxies +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + + +# TODO: Currently all users/projects share gitlab/jenkins credentials. +# Longer-term, we should use a centralized per user/project secret store + + + + +def gitlab_session_cookie(hostname, user, password, user_type="user"): + """ + There is not way to get a session cookie via the gitlab API, but we need them.... + Beware this function is likely fragile and may break with future gitlab updates. + user_type: can be "user" for gitlab default users, or ldap_user with LDAP. There are likely other valid options... + """ + # https://stackoverflow.com/questions/47948887/login-to-gitlab-with-username-and-password-using-curl + with requests.Session() as s: + s = requests.Session() + # curl for the login page to get a session cookie and the sources with the auth tokens + login_url = f'{hostname}/users/sign_in' + r = s.get(login_url) + # print([l for l in r.text.splitlines() if "ldap_user" in l]) + auth_matches = re.findall(r'> matches", matches) + try: + user_type, action, authenticity_token = matches[0] + except: + print(r.text) + print(f"Error with the gitlab login form. {matches}") + return None + login_url = f"{hostname}{action}" + # print(user_type, login_url, user, password, authenticity_token) + r = s.post( + login_url, + data={ + "username": user, + "password": password, + "authenticity_token": authenticity_token, + }, + ) + print(r) + # print(r.text) + # print(r.headers) + return s.cookies['_gitlab_session'] + + +gitlab_credentials = json.loads(os.environ.get('GITLAB_AUTH', '{}')) +# At startup we try to get session cookies for the gitlab hosts we have auth for. +# We use them to proxy e.g. image requests. +# They we will be cached in +gitlab_cookies_path = qaboard_data_dir / "gitlab_cookies.json" +try: # file not found, corrupt format... + with gitlab_cookies_path.open() as f: + gitlab_cookies = json.load(f) +except: + gitlab_cookies = {} +refresh_cookies = False +for hostname, auth in gitlab_credentials.items(): + if gitlab_cookies.get(hostname) and not refresh_cookies: + continue + print("Getting gitlab cookie for", hostname) + url = f"https://{hostname}" if not auth.get('http') else f"http://{hostname}" + gitlab_cookies[hostname] = gitlab_session_cookie( + url, auth['user'], auth['password'], auth.get('type', 'user') + ) + # write updates + with gitlab_cookies_path.open('w') as f: + json.dump(gitlab_cookies, f) + + +jenkins_credentials = json.loads(os.environ.get('JENKINS_AUTH', '{}')) +def jenkins_hostname_credentials(build_url): + hostname = urlparse(build_url).hostname + if hostname not in jenkins_credentials: + return None + credentials = jenkins_credentials[hostname] + return { + "auth": HTTPBasicAuth( + credentials['user'], + credentials['token'], + ), + "headers": { + "Jenkins-Crumb": credentials['crumb'], + }, + } + +# TODO: get password for gitlab-adm to avoid any auth and password changes +# TODO: if expired, renew the token... +@app.route("/api/v1/gitlab/proxy") +def proxy_gitlab(): + url = request.args['url'] + hostname = urlparse(url).hostname + if gitlab_cookies.get(hostname): + cookies = {'_gitlab_session': gitlab_cookies[hostname]} + else: + cookies = {} + # print(url) + r = requests.get(url, cookies=cookies, verify=False) + session = Session() + resp = make_response(r.content, r.status_code) + for k, v in r.headers.items(): + resp.headers.set(k, v) + return resp + # print(r) + # print(r.text) + # print(r.headers) + return r.content, r.status_code + +@app.route("/api/v1/webhook/proxy", methods=['POST']) +@app.route("/api/v1/webhook/proxy/", methods=['POST']) +def proxy_webook(): + """ + Proxy users' webhook triggers to avoid CORS issues. + """ + data = request.get_json() + print(data['method'], data.get('url')) + data['method'] = data['method'].upper() + if 'auth' in data: + # we could easily support other types of authentification + # https://2.python-requests.org/en/master/user/authentication/ + data['auth'] = HTTPBasicAuth(data['auth']['username'], data['auth']['password']) + session = Session() + r = Request(**data) + r_prepped = r.prepare() + r = session.send(r_prepped, verify=False) + # It would be great to just + # return r.content, r.status_code + # but e.g. Jenkins returns important data in its headers + resp = make_response(r.content, r.status_code) + # this might not be the cleanest way to pass headers, + # e.g. what happens to Content-Length? + for k, v in r.headers.items(): + if k.lower() == 'content-length': + continue + resp.headers.set(k, v) + return resp + + +@app.route("/api/v1/gitlab/job", methods=['POST']) +@app.route("/api/v1/gitlab/job/", methods=['POST']) +def gitlab_job(): + """ + Get information about a GitlabCI manual job. + """ + if "GITLAB_ACCESS_TOKEN" not in os.environ: + return jsonify({"error": f'Error: Missing GITLAB_ACCESS_TOKEN in environment variables'}), 500 + + data = request.get_json() + gitlab_api = f"{data['gitlab_host']}/api/v4" + gitlab_headers = { + 'Private-Token': os.environ['GITLAB_ACCESS_TOKEN'], + } + project_id = quote(data['project_id'], safe='') + if data.get('job_id'): + job_id = data['job_id'] + else: + # Get the latest pipeline for this commit + url = f"{gitlab_api}/projects/{project_id}/repository/commits/{data['commit_id']}" + r = requests.get(url, headers=gitlab_headers) + pipeline_id = r.json()['last_pipeline']['id'] + + # Get the list of manual jobs in that pipeline + # https://docs.gitlab.com/ee/api/jobs.html#list-pipeline-jobs + jobs = [] + page = 1 + total_pages = None + def get_jobs(page, per_page): + r = requests.get( + f"{gitlab_api}/projects/{project_id}/pipelines/{pipeline_id}/jobs", + params={ + "page": page, + "per_page": per_page, + }, + headers=gitlab_headers, + ) + total_pages = int(r.headers['X-Total-Pages']) if r.headers.get('X-Total-Pages') else 0 + return r.json(), total_pages + while total_pages is None or page <= total_pages: + jobs_page, total_pages = get_jobs(page=page, per_page=50) + jobs.extend(jobs_page) + page += 1 + + try: + matching_jobs = [j for j in jobs if data['job_name'] == j['name']] + for j in matching_jobs: + print(j['name'], j['id'], j["created_at"], j['status']) + except Exception as e: + return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 + if not matching_jobs: + return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 + # FIXME: sort by id + job_id = matching_jobs[-1]['id'] + + url = f"{gitlab_api}/projects/{project_id}/jobs/{job_id}" + try: + r = requests.get(url, headers=gitlab_headers) + print(r.json()) + return r.content, r.status_code + except Exception as e: + return jsonify({"error": f'Error: {e}'}), 500 + + + +@app.route("/api/v1/gitlab/job/play", methods=['POST']) +@app.route("/api/v1/gitlab/job/play/", methods=['POST']) +def gitlab_play_manual_job(): + """ + Trigger a GitlabCI manual job. + """ + if "GITLAB_ACCESS_TOKEN" not in os.environ: + return jsonify({"error": f'Error: Missing GITLAB_ACCESS_TOKEN in environment variables'}), 500 + data = request.get_json() + + gitlab_api = f"{data['gitlab_host']}/api/v4" + gitlab_headers = { + # FIXME: store the credentials in a "secret store", global per user/project + 'Private-Token': os.environ['GITLAB_ACCESS_TOKEN'], + } + project_id = quote(data['project_id'], safe='') + + # Get the latest pipeline for this commit + url = f"{gitlab_api}/projects/{project_id}/repository/commits/{data['commit_id']}" + r = requests.get(url, headers=gitlab_headers) + pipeline_id = r.json()['last_pipeline']['id'] + + # Get the list of manual jobs in that pipeline + # https://docs.gitlab.com/ee/api/jobs.html#list-pipeline-jobs + jobs = [] + page = 1 + total_pages = None + def get_jobs(page, per_page): + r = requests.get( + f"{gitlab_api}/projects/{project_id}/pipelines/{pipeline_id}/jobs", + params={ + "page": page, + "per_page": per_page, + }, + headers=gitlab_headers, + ) + total_pages = int(r.headers['X-Total-Pages']) if r.headers.get('X-Total-Pages') else 0 + return r.json(), total_pages + while total_pages is None or page <= total_pages: + jobs_page, total_pages = get_jobs(page=page, per_page=50) + jobs.extend(jobs_page) + page += 1 + + + try: + matching_jobs = [j for j in jobs if data['job_name'] == j['name']] + assert matching_jobs + for j in matching_jobs: + print(j['name'], j['id'], j["created_at"], j['status']) + except Exception as e: + return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 + + # Play the job + # https://docs.gitlab.com/ee/api/jobs.html + url = f"{gitlab_api}/projects/{project_id}/jobs/{matching_jobs[0]['id']}/play" + try: + r = requests.post(url, headers=gitlab_headers) + print(r.json()) + return r.content, r.status_code + except Exception as e: + print(url) + print(e) + return jsonify({"error": f"ERROR: when posting to {url}: {e}"}), 500 + + + +@app.route("/api/v1/jenkins/build", methods=['POST']) +@app.route("/api/v1/jenkins/build/", methods=['POST']) +def jenkins_build(): + """ + Get the status of a Jenkins build. + """ + data = request.get_json() + if "build_url" in data or "web_url" in data: + if "build_url" in data: + url = data['build_url'] + else: + url = data['web_url'] + if not url.endswith("/api/json"): + url += "/api/json" + else: + url = data['url'] + jenkins_credentials = jenkins_hostname_credentials(url) + if not jenkins_credentials: + return f"ERROR: No credentials for {url}", "403" + try: + # TODO: add something proper to do retriess + # https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#urllib3.util.Retry + # from requests.adapters import Retry, HTTPAdapter + # s = requests.Session() + # retries = Retry(total=5, backoff_factor=1, status_forcelist=[ 502, 503, 504 ]) + # s.mount('http://', HTTPAdapter(max_retries=retries)) + # s.get("http://httpstat.us/503") + + # @retry(tries=3) # pip install retry... + def fetch(): + # https://docs.python-requests.org/en/master/user/advanced/#timeouts + return requests.get(url, timeout=(60, 3.5*60), **jenkins_credentials) + # LOL Jenkins is super unstable... WIP until we add something proper... + import time + try: + r = fetch() + except: + try: + time.sleep(1) + r = fetch() + except: + try: + time.sleep(1) + r = fetch() + except: + try: + time.sleep(1) + r = fetch() + except: + r = fetch() + except Exception as e: + print(e) + return jsonify({"error": f"ERROR: checking the build status: {e}"}), 500 + try: + build_data = r.json() + except Exception as e: + print(r.text) + print(e) + return jsonify({"error": f"ERROR: malformed Jenkins response, when checking the build status: {e}", "text": r.text}), 500 + # print(build_data.get('building'), build_data.get('result')) + # https://javadoc.jenkins-ci.org/hudson/model/Result.html + allow_failure = False + if build_data.get('blocked'): + status = "BLOCKED" + if build_data.get('stuck'): + status = "STUCK" + if build_data['building']: + status = "running" + elif build_data.get('result'): + if build_data['result'] == 'SUCCESS': + status = "success" + elif build_data['result'] == 'UNSTABLE': + allow_failure = True + status = "UNSTABLE" + elif build_data['result'] == 'FAILURE': + status = "failed" + elif build_data['result'] == 'NOT_BUILT': + status = "NOT_BUILT" + elif build_data['result'] == 'ABORTED': + status = "ABORTED" + else: + return jsonify({"error": "ERROR: unknown status"}), 500 + else: + status = "canceled" + return jsonify({ + "status": status, + "allow_failure": allow_failure, + "web_url": url, + }) + + + +@app.route("/api/v1/jenkins/build/trigger", methods=['POST']) +@app.route("/api/v1/jenkins/build/trigger/", methods=['POST']) +def jenkins_build_trigger(): + """ + Trigger a Jenkins build. + """ + data = request.get_json() + if 'build_url' not in data: + return jsonify({"error": f"ERROR: the integration is missing `build_url` (in your qaboard.yaml)"}), 400 + jenkins_credentials = jenkins_hostname_credentials(data['build_url']) + if not jenkins_credentials: + return f"ERROR: No credentials for {data['build_url']}", "403" + build_url = re.sub("/$", "", data['build_url']) + build_trigger_url = f"{build_url}/buildWithParameters" + try: + params = { + "cause": data.get('cause', "Triggered via QA-Board"), + **data.get('params'), + } + if "token" in data: + params["token"] = data["token"] + else: + params["token"] = "qaboard" # FIXME: default to not setting it in the OSS version + r_build = requests.post( + build_trigger_url, + params=params, + **jenkins_credentials, + ) + except Exception as e: + print(build_trigger_url) + print(e) + return jsonify({"error": f"ERROR: When triggering job: {e}"}), 500 + + if 'location' not in r_build.headers: + return jsonify({"error": f"ERROR: the jenkins response is missing a `location` header. {r_build.text}"}), 500 + build_queue_location = f"{r_build.headers['location']}/api/json".replace("//api/json", "/api/json") + + def ensure_absolute(url): + # in some cases jenkins will return a relative location + if '://' not in url: + url_info = urlparse(build_url) + if not url.startswith('/'): + url = f"/{url}" + url = f"{url_info.scheme}://{url_info.netloc}{url}" + return url + + build_queue_location = ensure_absolute(build_queue_location) + time.sleep(5) # jenkins' "quiet period" + sleep_total = 5 + error = None + web_url = None + while not web_url and sleep_total < 30: + try: + r_get = requests.get( + build_queue_location, + **jenkins_credentials, + ) + r_get.raise_for_status() + error = None + except Exception as e: + error = str(e) + try: + web_url = r_get.json()['executable']['url'] + except Exception as e: + print(f"INFO: When reading build queue info, no build URL given at: {build_queue_location}. {e}") + try: + print(r_get.json()) + except Exception as ee: + print(f"WARNING: could not print the response: {ee}") + time.sleep(0.5) + sleep_total = sleep_total + 0.5 + if error: + return jsonify({"error": error}), 500 + response = { + "status": 'pending', + **r_get.json(), + } + if 'url' not in response: + response['url'] = build_queue_location + response['url'] = ensure_absolute(response['url']) + try: + if r_get.json().get('executable', {}).get('url'): + response['web_url'] = ensure_absolute(r_get.json()['executable']['url']) + except Exception: + pass + return jsonify(response) diff --git a/qaboard-backend/slamvizapp/api/milestones.py b/backend/backend/api/milestones.py similarity index 96% rename from qaboard-backend/slamvizapp/api/milestones.py rename to backend/backend/api/milestones.py index 6921561a2..124a278d4 100644 --- a/qaboard-backend/slamvizapp/api/milestones.py +++ b/backend/backend/api/milestones.py @@ -2,7 +2,7 @@ CRUD operations for project milestones. """ from flask import request, jsonify -from slamvizapp import app, db_session +from backend import app, db_session from sqlalchemy.orm.attributes import flag_modified from ..models import Project diff --git a/backend/backend/api/outputs.py b/backend/backend/api/outputs.py new file mode 100644 index 000000000..a13d4d8a0 --- /dev/null +++ b/backend/backend/api/outputs.py @@ -0,0 +1,208 @@ +import json +import datetime + +from flask import request, jsonify, redirect, make_response +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy.orm.exc import NoResultFound + +from qaboard.conventions import deserialize_config +from qaboard.api import dir_to_url + +from backend import app, db_session +from ..models import TestInput, CiCommit, Output + + +@app.route("/api/v1/output/", methods=['GET', 'PUT', 'DELETE']) +@app.route("/api/v1/output//", methods=['GET', 'PUT', 'DELETE']) +def crud_output(output_id): + try: + output = Output.query.filter(Output.id==output_id).one() + except NoResultFound: + if request.method == 'DELETE': + return {"status": "OK"} + return jsonify({"error": f"Cannot find output {output_id}"}), 400 + + if request.method == 'GET': + return jsonify(output.to_dict()) + + if request.method == 'PUT': + data = request.get_json() + if 'is_pending' in data: + output.is_pending = data['is_pending'] + if 'is_running' in data: + output.is_running = data['is_running'] + if 'is_failed' in data: + output.is_failed = data['is_failed'] + if 'data' in data: + if not output.data: + output.data = {} + output.data.update(data['data']) + flag_modified(output, "data") + if data.get('batch'): + output.data["batch"] = data["batch"] + flag_modified(output, "data") + if 'metrics' in data: + output.metrics = {**output.metrics, **data['metrics']} + flag_modified(output, "metrics") + db_session.add(output) + db_session.commit() + return jsonify(output.to_dict()) + + if request.method == 'DELETE': + if output.is_pending: + return {"error": "Please wait for the Output to finish running before deleting it"}, 500 + soft = request.args.get('soft') == 'true' + output.delete(soft=soft) + if not soft: + db_session.delete(output) + db_session.commit() + return {"status": "OK"} + + +@app.route('/api/v1/output/redo/', methods=['POST']) +@app.route('/api/v1/output/redo//', methods=['POST']) +def output_redo(output_id): + try: + output = Output.query.filter(Output.id==output_id).one() + except NoResultFound: + return jsonify({"error": f"Cannot find output {output_id}"}), 400 + try: + success = output.redo() + except Exception as e: + return jsonify({"error": f"{e}"}), 500 + if success: + return '{"status": "OK"}' + else: + return jsonify({"error": "The run failed to start. Check the 'redo.log' files in the output directories to know more."}), 500 + + +@app.route("/api/v1/output//manifest", methods=['GET']) +@app.route("/api/v1/output//manifest/", methods=['GET']) +def get_output_manifest(output_id): + try: + output = Output.query.filter(Output.id==output_id).one() + except NoResultFound: + return jsonify({"error": f"Cannot find output {output_id}"}), 400 + manifest_path = output.output_dir / "manifest.outputs.json" + if output.is_running or request.args.get('refresh') or not manifest_path.exists(): + manifest = output.update_manifest(compute_hashes=False) + return jsonify(manifest) + else: + # FIXME: in dev it will return http://backend/ and break the frontend who cannot connect + # in 2021 it seems the spec allow returning relative urls... + # Maybe we should return the manifest content instead... + # return redirect(dir_to_url(manifest_path), code=302) + response = make_response(manifest_path.read_text()) + response.headers['Content-Type'] = 'application/json' + return response + + + + + + + + +@app.route('/api/v1/output', methods=['POST']) +@app.route('/api/v1/output/', methods=['POST']) +def new_output_webhook(): + """Updates the database when we get new results.""" + data = request.get_json() + hexsha = data.get('commit_sha', data['git_commit_sha']) + # We get a handle on the Commit object related to our new output + try: + ci_commit = CiCommit.get_or_create( + session=db_session, + hexsha=hexsha, + project_id=data['project'], + data=data, + ) + except Exception as e: + return jsonify({"error": f"Could not find your commit ({data['git_commit_sha']}). {e}"}), 404 + + # update the last_output times, with a crude debouncing to avoid + # keeping locks on the tables too long + now = datetime.datetime.utcnow() + threshold = datetime.timedelta(seconds=5) + if not ci_commit.project.latest_output_datetime or now - ci_commit.project.latest_output_datetime > threshold: + ci_commit.project.latest_output_datetime = now + if not ci_commit.latest_output_datetime or now - ci_commit.latest_output_datetime > threshold: + ci_commit.latest_output_datetime = now + + # We make sure the Test on which we ran exists in the database + test_input_path = data.get('rel_input_path', data.get('input_path')) + if not test_input_path: + return jsonify({"error": "the input path was not provided"}), 400 + test_input = TestInput.get_or_create( + db_session, + path=test_input_path, + database=data['database'], + autocommit=True, + ) + if data.get('input_metadata'): + test_input.data['metadata'] = data['input_metadata'] + flag_modified(test_input, "data") + + # We save the basic information about our result + batch = ci_commit.get_or_create_batch(data['batch_label']) + if not batch.data: + batch.data = {} + batch.data.update({"type": data['job_type']}) + + platform = data['platform'] + # for backward-compat with old clients + if platform == 'lsf': + platform = 'linux' + + configurations = deserialize_config(data['configuration']) if 'configuration' in data else data['configurations'] + output = Output.get_or_create(db_session, + batch=batch, + platform=platform, + configurations=configurations, + extra_parameters=data['extra_parameters'], + test_input=test_input, + ) + output.output_type = data.get('input_type', '') + + if not output.data: + output.data = {} + if "data" in data: # e.g. storage, job_options, batch name + if not output.data: + output.data = {} + output.data.update(data['data']) + flag_modified(output, "data") + output.data["user"] = data['user'] + # we can only trust CI outputs to run on the exact code from the commit + output.data["ci"] = data['job_type'] == 'ci' + if data.get('batch'): + output.data["batch"] = data["batch"] + if output.deleted: + output.deleted = False + + # prefix_output_dir for backward-compatibility + ci_commit.commit_dir_override = data.get('artifacts_commit', data.get('commit_ci_dir')) + if not ci_commit.commit_dir_override.startswith('/'): # or "some-protocol://" + ci_commit.commit_dir_override = None # just ignore... + output.output_dir_override = data['output_directory'] + + # We update the output's status + output.is_running = data.get('is_running', False) + if output.is_running: + output.is_pending = True + else: + output.is_pending = data.get('is_pending', False) + + # We save the output's metrics + if not output.is_pending: + metrics = data.get('metrics', {}) + output.metrics = metrics + output.is_failed = data.get('is_failed', False) or metrics.get('is_failed') + + db_session.add(ci_commit) + db_session.add(output) + db_session.commit() + return jsonify(output.to_dict()) + + + + diff --git a/backend/backend/api/saml/advanced_settings.json b/backend/backend/api/saml/advanced_settings.json new file mode 100755 index 000000000..cdfa35247 --- /dev/null +++ b/backend/backend/api/saml/advanced_settings.json @@ -0,0 +1,36 @@ +{ + "security": { + "nameIdEncrypted": false, + "authnRequestsSigned": false, + "logoutRequestSigned": false, + "logoutResponseSigned": false, + "signMetadata": false, + "wantMessagesSigned": false, + "wantAssertionsSigned": false, + "wantNameId" : false, + "wantNameIdEncrypted": false, + "wantAssertionsEncrypted": false, + "requestedAuthnContext": false, + "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "digestAlgorithm": "http://www.w3.org/2000/09/xmldsig#sha1" + }, + "contactPerson": { + "technical": { + "givenName": "technical_name", + "emailAddress": "technical@example.com" + }, + "support": { + "givenName": "support_name", + "emailAddress": "support@example.com" + } + }, + "organization": { + "en-US": { + "name": "sp_test", + "displayname": "SP test", + "url": "http://sp.example.com" + } + } +} + + diff --git a/backend/backend/api/saml/certs/README b/backend/backend/api/saml/certs/README new file mode 100755 index 000000000..7e837fb9c --- /dev/null +++ b/backend/backend/api/saml/certs/README @@ -0,0 +1,13 @@ +Take care of this folder that could contain private key. Be sure that this folder never is published. + +Onelogin Python Toolkit expects that certs for the SP could be stored in this folder as: + + * sp.key Private Key + * sp.crt Public cert + * sp_new.crt Future Public cert + + +Also you can use other cert to sign the metadata of the SP using the: + + * metadata.key + * metadata.crt diff --git a/backend/backend/api/saml/dev_settings.json b/backend/backend/api/saml/dev_settings.json new file mode 100755 index 000000000..42c04e8ad --- /dev/null +++ b/backend/backend/api/saml/dev_settings.json @@ -0,0 +1,28 @@ +{ + "strict": true, + "debug": true, + "sp": { + "entityId": "https://localhost:5001", + "assertionConsumerService": { + "url": "https://localhost:5001/api/auth/saml20/login/?acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": "https://localhost:5001/api/auth/saml20/login/?sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + }, + "idp": { + "entityId": "http://sts-dev.secsso.net/adfs/services/trust", + "singleSignOnService": { + "url": "https://sts-dev.secsso.net/adfs/ls/", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "singleLogoutService": { + "url": "https://sts-dev.secsso.net/adfs/ls/?wa=wsignoutcleanup1.0", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": "MIIC4DCCAcigAwIBAgIQG40DR9OSaolO+JHldUbqdzANBgkqhkiG9w0BAQsFADAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwHhcNMTgwNzMwMDA0MDUyWhcNMzgwNzI1MDA0MDUyWjAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCAjlN1aipmUwA++3KpSgNDDe3JEwzUyc9qjZ22js5Tu/4L40x56H9lsWmwITq157RNTYa/cad67AnMII/Azo+6QArTsYNl1Cr6UWPxZFSOv8do5Hi3ymsdH2n9oNymvAL0mv0c0GHLu8OvB9lMzv2XL71d68Ql0gp+OlxOzwzfoM4Si98OEdbm9eZRLWq+SbadfpfOkKt5ncNOX3Y7Q2fnItTnpOJuw89Kac9jCf3zMT/6qjb4nX8M3glkOXDsISRG4BXegJXfBHk3wUyIGPOjuzKYWPo3NtbuyPak5xtcL21vNzRkztOsIEJmBEqrc7TMtfP75QYOoeJbHVCfRfxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAApqobdLJLXHnJy0EcLgdlJLAXpGOy8IM+RssaVJCdE9JWn/59BvFTsBMzuj8nGPERJFrsjsygyjpeE46XXJx21crcdOQEsWtnOfeFdhZSZyRLn4yF1xuX0CKltdRBfl+cgDGrEpiRR3ZzfJCKuUvxS8lrquvMJ9yXsnwiJsny+UGQ+QLMTvBR7kY7X4fJhoo/paB1vPBBD92HUPdni2tEpmQ0ID0IeBXFmOky/xmSjU2itS+HjNIWhtlE/ZBhLtmryeiXgt3SElOp3buYs/A0Vz2ycXx4nwldWSt5IQcpVFdBV4tkJxuPPm7dKMmBrQtp5hFEkjw6q6JUJHkT+lTnE=" + } +} \ No newline at end of file diff --git a/backend/backend/api/saml/settings.json b/backend/backend/api/saml/settings.json new file mode 100755 index 000000000..c906af40f --- /dev/null +++ b/backend/backend/api/saml/settings.json @@ -0,0 +1,28 @@ +{ + "strict": true, + "debug": true, + "sp": { + "entityId": "https://qaboard.samsungds.net", + "assertionConsumerService": { + "url": "https://qaboard.samsungds.net/api/auth/saml20/login/?acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": "https://qaboard.samsungds.net/api/auth/saml20/login/?sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + }, + "idp": { + "entityId": "http://sts.secsso.net/adfs/services/trust", + "singleSignOnService": { + "url": "https://sts.secsso.net/adfs/ls/", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "singleLogoutService": { + "url": "https://sts.secsso.net/adfs/ls/?wa=wsignoutcleanup1.0", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": "MIIC2DCCAcCgAwIBAgIQJTsBS/w6qJ5OnrWPQQfnujANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQDEx1BREZTIFNpZ25pbmcgLSBzdHMuc2Vjc3NvLm5ldDAeFw0xODA4MDkwOTAyMTVaFw0zODA4MDQwOTAyMTVaMCgxJjAkBgNVBAMTHUFERlMgU2lnbmluZyAtIHN0cy5zZWNzc28ubmV0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDxXn2VDXCxfIXqtILZCL0zLJbY56P6zGWTm1t+JUbCKxFXqjQDQI65mxsu3o6llrsGTQvzQt1/F7bgmDSP/Z5ZSKxaBPz3SK5K/ylTw1Zh30jecvW25ND2LMcD/D+ezbCIwkbHeMDvUqjwaWc4eoKpVvoGj3CkKQ7xTkmcjbttowLyr2Iq5pNmDrlSPEHlCC22our3W/PrE/jpCktSDn3F43tm4q+aFFYnoeufeYYh3BCwv0Whlxc1PS0iA4qfRCemor501ABa1YGPFRcHJi8FxY9nKNyF9eZ3DdHsrDh6RznK7WUcHmDh8aHKXmLsJ2D7pQmhV4ao5IvE18uw+hwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBOgYSeQy0ekpdlOVoOlqUUMI/BiNHecOfIjjxr08XHoPDIBP93PDmPLasHZLK3pRLs0aCUrYjNJTfx0BRqG+RycfccwJmVz8r89E6/XDmjSVqJWRfmzqWNA7gTRI2NqFcAy4CTSAYoP5R1RbhmlY+Q1uVhfNCvDJGI5fhombXKBkb6etZs90KU7hFs/bj7dKVecfABmN0udVkki+9lZ+bvbcL32opyzOxvMhX1so9HJEPglrTve6OGyE119PvGFdQZU8nvqR2aoXbk9GTN38ufNze2ClPTbTHO/elEAYGGem5eh6XOyXtZ1TXLsHl983x5lJcjjUzIt+iPOBCgEE2W" + } +} \ No newline at end of file diff --git a/backend/backend/api/saml/settings_example.json b/backend/backend/api/saml/settings_example.json new file mode 100755 index 000000000..40425818c --- /dev/null +++ b/backend/backend/api/saml/settings_example.json @@ -0,0 +1,39 @@ +{ + "strict": true, + "debug": true, + "sp": { + "entityId": "https://localhost:44364", + "assertionConsumerService": { + "url": "https://localhost:44364/?acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": "https://localhost:44364/?sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + }, + "idp": { + "entityId": "http://sts-dev.secsso.net/adfs/services/trust", + "singleSignOnService": { + "url": "https://sts-dev.secsso.net/adfs/ls/", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "singleLogoutService": { + "url": "https://sts-dev.secsso.net/adfs/ls/?wa=wsignoutcleanup1.0", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": "MIIC4DCCAcigAwIBAgIQG40DR9OSaolO+JHldUbqdzANBgkqhkiG9w0BAQsFADAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwHhcNMTgwNzMwMDA0MDUyWhcNMzgwNzI1MDA0MDUyWjAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCAjlN1aipmUwA++3KpSgNDDe3JEwzUyc9qjZ22js5Tu/4L40x56H9lsWmwITq157RNTYa/cad67AnMII/Azo+6QArTsYNl1Cr6UWPxZFSOv8do5Hi3ymsdH2n9oNymvAL0mv0c0GHLu8OvB9lMzv2XL71d68Ql0gp+OlxOzwzfoM4Si98OEdbm9eZRLWq+SbadfpfOkKt5ncNOX3Y7Q2fnItTnpOJuw89Kac9jCf3zMT/6qjb4nX8M3glkOXDsISRG4BXegJXfBHk3wUyIGPOjuzKYWPo3NtbuyPak5xtcL21vNzRkztOsIEJmBEqrc7TMtfP75QYOoeJbHVCfRfxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAApqobdLJLXHnJy0EcLgdlJLAXpGOy8IM+RssaVJCdE9JWn/59BvFTsBMzuj8nGPERJFrsjsygyjpeE46XXJx21crcdOQEsWtnOfeFdhZSZyRLn4yF1xuX0CKltdRBfl+cgDGrEpiRR3ZzfJCKuUvxS8lrquvMJ9yXsnwiJsny+UGQ+QLMTvBR7kY7X4fJhoo/paB1vPBBD92HUPdni2tEpmQ0ID0IeBXFmOky/xmSjU2itS+HjNIWhtlE/ZBhLtmryeiXgt3SElOp3buYs/A0Vz2ycXx4nwldWSt5IQcpVFdBV4tkJxuPPm7dKMmBrQtp5hFEkjw6q6JUJHkT+lTnE=" + } +} + +// Idp EntityId +// 1. Dev : http://sts-dev.secsso.net +// 2. Asia : http://sts.secsso.net +// 3. US : http://stsus.secsso.net +// 4. EU : http://stseu.secsso.net +// x509cert +// 1. Dev : MIIC4DCCAcigAwIBAgIQG40DR9OSaolO+JHldUbqdzANBgkqhkiG9w0BAQsFADAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwHhcNMTgwNzMwMDA0MDUyWhcNMzgwNzI1MDA0MDUyWjAsMSowKAYDVQQDEyFBREZTIFNpZ25pbmcgLSBzdHMtZGV2LnNlY3Nzby5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCAjlN1aipmUwA++3KpSgNDDe3JEwzUyc9qjZ22js5Tu/4L40x56H9lsWmwITq157RNTYa/cad67AnMII/Azo+6QArTsYNl1Cr6UWPxZFSOv8do5Hi3ymsdH2n9oNymvAL0mv0c0GHLu8OvB9lMzv2XL71d68Ql0gp+OlxOzwzfoM4Si98OEdbm9eZRLWq+SbadfpfOkKt5ncNOX3Y7Q2fnItTnpOJuw89Kac9jCf3zMT/6qjb4nX8M3glkOXDsISRG4BXegJXfBHk3wUyIGPOjuzKYWPo3NtbuyPak5xtcL21vNzRkztOsIEJmBEqrc7TMtfP75QYOoeJbHVCfRfxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAApqobdLJLXHnJy0EcLgdlJLAXpGOy8IM+RssaVJCdE9JWn/59BvFTsBMzuj8nGPERJFrsjsygyjpeE46XXJx21crcdOQEsWtnOfeFdhZSZyRLn4yF1xuX0CKltdRBfl+cgDGrEpiRR3ZzfJCKuUvxS8lrquvMJ9yXsnwiJsny+UGQ+QLMTvBR7kY7X4fJhoo/paB1vPBBD92HUPdni2tEpmQ0ID0IeBXFmOky/xmSjU2itS+HjNIWhtlE/ZBhLtmryeiXgt3SElOp3buYs/A0Vz2ycXx4nwldWSt5IQcpVFdBV4tkJxuPPm7dKMmBrQtp5hFEkjw6q6JUJHkT+lTnE= +// 2. Asia : MIIC2DCCAcCgAwIBAgIQJTsBS/w6qJ5OnrWPQQfnujANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQDEx1BREZTIFNpZ25pbmcgLSBzdHMuc2Vjc3NvLm5ldDAeFw0xODA4MDkwOTAyMTVaFw0zODA4MDQwOTAyMTVaMCgxJjAkBgNVBAMTHUFERlMgU2lnbmluZyAtIHN0cy5zZWNzc28ubmV0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuDxXn2VDXCxfIXqtILZCL0zLJbY56P6zGWTm1t+JUbCKxFXqjQDQI65mxsu3o6llrsGTQvzQt1/F7bgmDSP/Z5ZSKxaBPz3SK5K/ylTw1Zh30jecvW25ND2LMcD/D+ezbCIwkbHeMDvUqjwaWc4eoKpVvoGj3CkKQ7xTkmcjbttowLyr2Iq5pNmDrlSPEHlCC22our3W/PrE/jpCktSDn3F43tm4q+aFFYnoeufeYYh3BCwv0Whlxc1PS0iA4qfRCemor501ABa1YGPFRcHJi8FxY9nKNyF9eZ3DdHsrDh6RznK7WUcHmDh8aHKXmLsJ2D7pQmhV4ao5IvE18uw+hwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBOgYSeQy0ekpdlOVoOlqUUMI/BiNHecOfIjjxr08XHoPDIBP93PDmPLasHZLK3pRLs0aCUrYjNJTfx0BRqG+RycfccwJmVz8r89E6/XDmjSVqJWRfmzqWNA7gTRI2NqFcAy4CTSAYoP5R1RbhmlY+Q1uVhfNCvDJGI5fhombXKBkb6etZs90KU7hFs/bj7dKVecfABmN0udVkki+9lZ+bvbcL32opyzOxvMhX1so9HJEPglrTve6OGyE119PvGFdQZU8nvqR2aoXbk9GTN38ufNze2ClPTbTHO/elEAYGGem5eh6XOyXtZ1TXLsHl983x5lJcjjUzIt+iPOBCgEE2W +// 3. US : MIIC3DCCAcSgAwIBAgIQcbJpxEgz461KIaQVOySfQTANBgkqhkiG9w0BAQsFADAqMSgwJgYDVQQDEx9BREZTIFNpZ25pbmcgLSBzdHN1cy5zZWNzc28ubmV0MB4XDTE5MDgwOTAxMDgxMloXDTM5MDgwNDAxMDgxMlowKjEoMCYGA1UEAxMfQURGUyBTaWduaW5nIC0gc3RzdXMuc2Vjc3NvLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOnVPJUpXWs+rlU6M/favT8WJhQRtL0XP7h3wSNZLvIkrFWIEYTEDf7uR4akjQqeZ6O/qnes1RH1QtsA5WhdC/K/aOYMsqVK3dN91IuNhuc2nR0MoNzlA7KnM3dWgnzx7zi7v6L7eIBjEQhvw0KwKOJewEB7gnQ4/OgBdRENq3ZMsT8rSVvVYfIQWxYWy5Nw3K7hiu6X732LAnlY8OV5A3MjjyA+1+kWTD4/5yC5pOJ5SQip/vlAVT4trrHGr1q7qQ4J6fmL41+LBlLiRDQfp67il3hITnzYbQRaGPc+/9Cjte29LHDqacSX0vQoItncap01LcrONTP3bVnVHJcdgrMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAUBcvWu35kqSXLmH61pOc1857DFZS0jlWhiUAboTfKkSxeRbdaAUZQ1QtMbMtUnQaWYB7v223AgHoOc4rjiBNpaYECGfQtUeDJjGjzutn0CUOlCXl6anBgpbKcwexnt1H/NkWmnWmIv1BI3XzVbIDMFej+XQ7kk5zZzsl2bm/EA8Yf5FuRN0/MCQX+2iT9EGKkNIIODnydJ+/daA82qjGzAG7Yk5unvDlhEA8tOBWjg29DewkTon5+VcYQYrPnpwpgZRG0NZjwHNxOgZntla3c5DkNsMuQQ571OMTSdA/aridC0XJ4M7N73wSlFvImS+6o5+5lE9FJCCHHTM8PNTcUg== +// 4. EU : MIIC3DCCAcSgAwIBAgIQHluESBiJ3oNEUiXgs27zQjANBgkqhkiG9w0BAQsFADAqMSgwJgYDVQQDEx9BREZTIFNpZ25pbmcgLSBzdHNldS5zZWNzc28ubmV0MB4XDTE5MDYxMTAxMDAyOVoXDTM5MDYwNjAxMDAyOVowKjEoMCYGA1UEAxMfQURGUyBTaWduaW5nIC0gc3RzZXUuc2Vjc3NvLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANtBEIFRXk79kAIvOmwwud12iH5PiUj2qpZsgxpgG4WYNjtgfFDrwCE4TwejhPhfpey3hFflwzfi+deqUvEQqRPnLgEwFSooTfMB1UXAgqXp7GoFl9qohdw40hLnV5FCE+ahZ70MCCoGwVtD7VWosvJJ33Eud8/37qps9AULk3xBeXUCLMDWtzcfNxY1Oi6po46DMJd2w8Aoqy+AqtsvuFwfyfYo1dizOs9pLbZUDKRWfJXoKtJn7emrEGjYDUNCRkTskSRAA53/MkfzTdOcHwDEtQ3dt6CratrlDfkTNWcnCv8UUw7ccm3QkX3YZobFEMZUs0GnX9uuOSjsvw/GxiMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAlCUbXFk71/Ftm1UYpqvaRda7WSbT9nct4YpgFZMwHjPqrv6l+Z46Me4E9m3gGGXnsxJEnkPHJTkSH4yi8KqGc1WMZyOLwX1T5i/5NvX9VMCPNg7b/A4qUJd0xW8tD0TspQK7Z+u0mlDG72E2jegqLDqJI3VA+IEaPT6jO5chADJbON4tCXVyO7EW8dmtfryloF99pR1w41znLuHGAl/KxbfSgGYYdiluLD7lX4NbIiv5BfBWk+53s7+a3nVC4oOQ3UY83nqoekGrko7Z+hijXuc02h2Prg0M20AoqhTwNpIZ3nzcBsR3c3dNoXX9aZwcRaESy0U9L/sdWQNl4Jtyrw== \ No newline at end of file diff --git a/backend/backend/api/saml/users_restrict_example.yml b/backend/backend/api/saml/users_restrict_example.yml new file mode 100644 index 000000000..c6c85696d --- /dev/null +++ b/backend/backend/api/saml/users_restrict_example.yml @@ -0,0 +1,50 @@ +# Configuration file for users authentication. +# supports any column from the database users table. +# qaboard will check each key against the user that's trying to login. +# once a key and value are matched, the authentication succeeded (an "OR" relationship). + +# Example - 4 different keys for authentication: +# user_name: for 'user1'/'user2'/'john.doe'. +# email: for 'mr.nobody@samsung.com' +# data: ...CompId: for any user with the CompId 'C123'/'C777'. +# data: ...GrdName: for any user with the GrdName 'Staff/Team Leader'. + + +projects: + LSC/Calibration: + user_name: + - user1 + email: + - mr.nobody@samsung.com + data: + # LDAP + - OU=SIRC Users + + CDE-Users/HW_ALG/CIS: + user_name: + - user2 + - john.doe + + CDE-Users/HW_ALG/PSP_2x/: + data: + - OU=SIRC Users + + +login: + user_name: + - user1 + - user2 + - john.doe + - user3 + + email: + - mr.nobody@samsung.com + - user3@samsung.com + +data: + http://schemas.company.com/2023/11/CompId: + - C123 + - C777 + http://schemas.company.com/2023/11/GrdName: + - Staff + - Team Leader diff --git a/backend/backend/api/tasks.py b/backend/backend/api/tasks.py new file mode 100644 index 000000000..aafae73a0 --- /dev/null +++ b/backend/backend/api/tasks.py @@ -0,0 +1,37 @@ +""" +Start celery tasks that wait for SSH connections to the worker +and stay live until it disconnects. + +This will be used by WebCDE to use celery as job scheduler to +manage remote sessions. +""" +import time + +from celery.app.control import Inspect +from qaboard.runners.celery_app import app as celery_app, ssh_task + +from backend import app + + +# https://github.com/celery/celery/blob/main/celery/app/control.py#L340 +inspector = Inspect(app=celery_app) + + +@app.post("/api/v1/task/celery/") +def task_celery(id): + result = ssh_task.delay(id) + while result.status == "PENDING": + time.sleep(1) + # TODO: result.abort() after some time? + print(f"{result} {result.id} {result.status}") + statuses = inspector.query_task(result.id) + for hostname, tasks_info in statuses.items(): + if result.id not in tasks_info: + continue + status, info = tasks_info[result.id] + return { + "hostname": hostname, + "status": status, + **info, + } + return "Could not start task", 500 diff --git a/backend/backend/api/tuning.py b/backend/backend/api/tuning.py new file mode 100755 index 000000000..8e76134cf --- /dev/null +++ b/backend/backend/api/tuning.py @@ -0,0 +1,464 @@ +""" +APIs related to parameter tuning +""" +import re +import os +import sys +import json +import uuid +import datetime +import itertools +import subprocess +from shlex import quote +from pathlib import Path +from typing import Dict, Any + +import yaml +from flask import request, jsonify +from sqlalchemy.orm.exc import NoResultFound + +from qaboard.utils import merge +from qaboard.iterators import iter_inputs, resolve_aliases +from qaboard.conventions import deserialize_config, batches_files + +from backend import app, db_session +from ..models import CiCommit, Project +from ..config import qaboard_data_shared_dir + + +def get_groups_path(project_id, name="extra-batches"): + """ + Return the path of the file where we save the groups of tests we defined for a project. + Creates it if it does not exist yet. + """ + path = qaboard_data_shared_dir / project_id / f"{name}.yml" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + f.write("""# Docs:\n# https://samsung.github.io/qaboard/docs/batches-running-on-multiple-inputs""") + return path + + + +@app.route("/api/v1/tests/groups", methods=["GET", "POST"]) +def groups(): + """ + Return or update the groups of tests we defined for a project. + TODO: We could just make it part of the database, why bother with files... + It could be saved as test as project.data.test_groups + We would *just* need to write the migration, and it would save 30 lines of code. + """ + project_id = request.args["project"] + name = request.args["name"] + groups_path = get_groups_path(project_id, name=name) + if request.method == "POST": + data = request.get_json() + try: + yaml.load(data["groups"], Loader=yaml.SafeLoader) + except Exception as e: + return jsonify(str(e)), 400 + with groups_path.open("w") as f: + f.write(data["groups"]) + return jsonify("OK") + else: + try: + with groups_path.open("r") as f: + return f.read() + except: + return ( + jsonify( + {"error": f"Could not open or read {groups_path}"} + ), + 500, + ) + + +def get_commit_batches_paths(ci_commit): + batches_paths = [] + commit_config = ci_commit.data.get('qatools_config', {}) + commit_group_files = batches_files( + commit_config, + None, + Path(ci_commit.project.id), + Path(ci_commit.project.id_relative), + ci_commit.repo_artifacts_dir, + ) + print(commit_group_files, file=sys.stderr) + # custom groups have priority over the commit's groups + for group_file in commit_group_files: + if (ci_commit.repo_artifacts_dir / group_file).exists(): + batches_paths.insert(0, ci_commit.repo_artifacts_dir / group_file) + return batches_paths + + +@app.route("/api/v1/tests/group", methods=["POST"]) +def get_group(): + if not request.args["name"]: + return jsonify({"tests": []}) + + project_id = request.args["project"] + project = Project.get_or_create(session=db_session, id=project_id) + data = request.get_json() + try: + groups = list(data["groups"]) + except Exception as e: + return jsonify(str(e)), 400 + + message = None + batches_paths = [get_groups_path(project_id, name=group) for group in groups] + + commit_id = request.args.get("commit") + if commit_id: + try: + ci_commit = CiCommit.query.filter( + CiCommit.project_id == project_id, + CiCommit.hexsha.startswith(commit_id), + ).one() + except NoResultFound: + return jsonify("Sorry, the commit id was not found"), 404 + qatools_config = ci_commit.data.get("qatools_config", {}) + + if not ci_commit.repo_artifacts_dir.exists(): + message = f""" +

The artifacts folder does not exist. +
{ci_commit.repo_artifacts_dir} +

+

For tuning to work, you can manually call

+
+          git checkout {commit_id}
+          # build whatever is needed
+          qa save-artifacts
+          
+

Normally it is done by the CI, but maybe you only worked on this commit locally, or something deleted the folder...

+ """ + else: + commit_batches_paths = get_commit_batches_paths(ci_commit) + if not commit_batches_paths: + message = f""" +

Could not load the inputs.batches files defined in qaboard.yaml. +
{ci_commit.repo_artifacts_dir} +

+ +

For tuning to work, you can manually call

+
+            git checkout {commit_id}
+            # build whatever is needed
+            qa save-artifacts
+            
+ +

Normally it is done by the CI, but maybe you only worked on this commit locally, or something deleted the folder...

+ """ + batches_paths = [*commit_batches_paths, *batches_paths] + else: + qatools_config = project.data.get("qatools_config", {}) + + + has_custom_iter_inputs = False + # TODO: make it more robust in case of "from iters import *" + qatools_config['project']['entrypoint'] = ci_commit.repo_artifacts_dir / qatools_config['project']['entrypoint'] + if qatools_config['project']['entrypoint'].exists(): + with qatools_config['project']['entrypoint'].open() as f: + entrypoint_source = f.read() + has_custom_iter_inputs = re.search(r'^\s*(def iter_inputs\(|from .* import.* iter_inputs)', entrypoint_source, re.MULTILINE) + # project fallback? + if has_custom_iter_inputs: + cwd = ci_commit.artifacts_dir + parent_including_cwd = [*list(reversed(list(cwd.parents))), cwd] + envrcs = [f'source "{p}/.envrc"\n' for p in parent_including_cwd if (p / '.envrc').exists()] + cmd = ' '.join([ + 'qa', + 'batch', + *list(itertools.chain.from_iterable((('--batches-file', f'"{f}"') for f in batches_paths))), + '--list', + request.args["name"], + ]) + cmd = '\n'.join([*envrcs, cmd]) + print(cmd) + try: + process = subprocess.run( + ['bash', '-c', cmd], + cwd=cwd, + encoding="utf-8", + capture_output=True, + ) + # print(cmd) + # print(process.stdout) + print(process.stderr) + process.check_returncode() + except: + return jsonify({"error": str(process.stdout), "cmd": str(cmd)}), 500 + return jsonify({"tests": json.loads(process.stdout), "message": message}) + + # We don't need to seperate the two cases, but + # doing so might let us avoid a fork and qa startup... + # like in qaboard/config.py + config_inputs = qatools_config.get('inputs', {}) + config_inputs_types = config_inputs.get('types', {}) + default_input_type = config_inputs_types.get('default', 'default') + from qaboard.conventions import get_settings + input_settings = get_settings(default_input_type, qatools_config) + # like in qaboard/qa.py + from qaboard.config import get_default_configuration, get_default_database + default_configuration = get_default_configuration(input_settings) + default_configurations = deserialize_config(default_configuration) + default_database = get_default_database(input_settings) + print('group', request.args["name"], batches_paths) + try: + tests = list( + iter_inputs( + [request.args["name"]], # batches + batches_paths, # batches_files, + default_database, # database + default_configurations, # default_configuration + 'linux', # platform + {"type": 'lsf'}, # default_job_configuration + qatools_config, + default_inputs_settings=input_settings, + ) + ) + return jsonify({ + "tests": [{"input_path": str(run_context.rel_input_path), "configurations": run_context.configurations} for run_context in tests], + "message": message, + }) + except Exception as e: + print(f'Error: {e}') + return jsonify({"tests": [], "error": str(e)}) + + +def _generate_batch_script(ci_commit, user, working_directory, command_id, batch_command, data): + """Generate the qa_batch.sh script (shared across all runners).""" + parent_including_cwd = [*list(reversed(list(working_directory.parents))), working_directory] + envrcs = [f'source "{p}/.envrc"\n' for p in parent_including_cwd if (p / '.envrc').exists()] + + default_user = os.environ.get('QABOARD_DEFAULT_USER', 'qaboard') + outputs_dir_prefix = str(ci_commit.outputs_dir).replace(f'/outputs/{default_user}/', f'/outputs/{user}/') + script = "".join([ + "#!/bin/bash\n", + 'export LC_ALL=en_US.utf8;\n', + 'export LANG=en_US.utf8;\n\n', + 'export MPLBACKEND=agg;\n', + ('\n'.join(envrcs) + '\n') if envrcs else "", + "set -xe\n\n", + f'cd "{working_directory}";\n\n', + f"\nexport CI=true;\n", + f"\nexport GIT_COMMIT='{ci_commit.hexsha}';\n", + f"export QABOARD_TUNING=true;\n\n", + f"export QA_OUTPUTS_COMMIT='{outputs_dir_prefix}';\n\n", + f"export QATOOLS_CI_COMMIT_DIR='{ci_commit.outputs_dir}';\n\n", + f"export QA_BATCH_COMMAND_ID='{command_id}';\n\n", + f"{batch_command};\n\n", + ]) + return script + + +def _dispatch_local(qa_batch_path, batch_dir): + """Run batch script locally via subprocess.""" + cmd = ['bash', '-c', f'bash "{qa_batch_path}" &>> "{batch_dir}/log.txt"'] + print(cmd) + out = subprocess.run(cmd, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out.check_returncode() + + +def _dispatch_celery(qa_batch_path, batch_dir): + """Run batch script via celery worker. Injects broker URL into script.""" + broker_url = os.environ.get('CELERY_BROKER_URL', 'pyamqp://guest:guest@qaboard:5672//') + qaboard_host = os.environ.get('QABOARD_HOST', 'localhost') + qaboard_protocol = os.environ.get('QABOARD_PROTOCOL', 'http') + + celery_env = "".join([ + f"export QABOARD_PROTOCOL={qaboard_protocol}\n", + f"export QABOARD_HOST={qaboard_host}\n", + f"export CELERY_BROKER_URL={broker_url}\n", + f"export no_proxy={qaboard_host},proxy,rabbitmq,qaboard\n", + ]) + with qa_batch_path.open("r") as f: + content = f.read() + content = content.replace("#!/bin/bash\n", f"#!/bin/bash\n{celery_env}", 1) + with qa_batch_path.open("w") as f: + f.write(content) + + cmd = ['bash', '-c', f'bash "{qa_batch_path}" &>> "{batch_dir}/log.txt"'] + print(cmd) + out = subprocess.run(cmd, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out.check_returncode() + + +def _dispatch_lsf(qa_batch_path, batch_dir, user, ci_commit, do_optimize): + """Run batch script via LSF job submission (SSH + bsub).""" + # TODO: We use a bridge server to submit - ideally we should use + # some LSF API to do it, but their docs/auth are terrible. + qatools_config = ci_commit.project.data.get("qatools_config", {}) + lsf_config = qatools_config.get('runners', qatools_config).get("lsf", {}) + default_queue = lsf_config.get('queue', 'default') + queue = lsf_config.get('long_queue', 'default') if do_optimize else default_queue + # TODO: this is SIRC-specific to switch user - would need a better solution + # for LSF but also for other runners... + bsub = "bsub" if os.environ.get("QABOARD_DEFAULT_USER") != "ispq" else f'bsub_su "{user}"' + start_script = "\n".join([ + "#!/bin/bash", + "set -xe", + "", + f'mkdir -p "{batch_dir}"', + f'{bsub} -q "{queue}" -o "{batch_dir}/log.lsf.txt" -sp 4000 ' + f"'bash \"{qa_batch_path}\" &>> \"{batch_dir}/log.txt\"'", + ]) + print(start_script) + + start_path = batch_dir / "start.sh" + with start_path.open("w") as f: + f.write(start_script) + + lsf_bridge = os.environ.get('QA_RUNNERS_LSF_BRIDGE', '') + if lsf_bridge: + cmd = lsf_bridge.replace('{command}', f'bash "{start_path}"') + else: + cmd = " ".join([ + "LC_ALL=en_US.utf8 LANG=en_US.utf8", + "ssh", "-q", "-tt", + "-o StrictHostKeyChecking=no", + os.environ.get('QA_LSF_SSH_TARGET', 'localhost'), + f'\'bash "{start_path}"\'', + ]) + print(cmd) + out = subprocess.run(cmd, shell=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out.check_returncode() + + +@app.route("/api/v1/commit//batch", methods=["POST"], strict_slashes=False) +def start_tuning(hexsha): + """ + Request that we run extra tests for a given project. + """ + project_id = request.args["project"] + data = request.get_json() + + # TODO: use the logged-in user + user = data['user'] + + try: + ci_commit = CiCommit.query.filter( + CiCommit.project_id == project_id, + CiCommit.hexsha.startswith(hexsha) + ).one() + except NoResultFound: + return jsonify("Sorry, the commit id was not found"), 404 + + if "qatools_config" not in ci_commit.project.data: + return jsonify("Please create `qaboard.yaml`"), 404 + + ci_commit.latest_output_datetime = datetime.datetime.now() + ci_commit.latest_output_datetime = datetime.datetime.now() + batch = ci_commit.get_or_create_batch(data['batch_label']) + db_session.add(ci_commit) + db_session.commit() + + if ci_commit.deleted: + # Now that we updated the last_output_datetime, it won't be deleted again until a little while + return jsonify("Artifacts for this commit were deleted! Re-run your CI pipeline, or `git checkout / build / qa --ci save-artifacts`"), 404 + try: + groups = list(data["groups"]) + except Exception as e: + return jsonify(str(e)), 400 + + commit_batches_paths = get_commit_batches_paths(ci_commit) + batches_paths = [get_groups_path(project_id, name=group) for group in groups] + batches_paths = [*commit_batches_paths, *batches_paths] + merged_batches : Dict[str, Any] = {} + for c in batches_paths: + with c.open() as f: + c_dict = yaml.load(f, Loader=yaml.SafeLoader) + merged_batches = merge(c_dict, merged_batches) + merged_batches['aliases'] = merged_batches.get('aliases', merged_batches.get('groups', {})) # backward-compat + + batches = str(data['selected_group']) + batches = list(resolve_aliases(batches, merged_batches['aliases'])) + + # FIXME: handle pipelines. replace with a generic solution. + for b in batches: + batch_context = merged_batches.get(b,{}) + if batch_context.get('type', " ") == 'pipeline': + for key in batch_context.keys(): + if key.lower() in ['configuration', 'configurations']: + configs = batch_context.get(key, []) + for step in configs: + if 'batch' in step.keys(): + step_config = step.get('batch') + if isinstance(step_config, str): batches.append(step_config) + elif isinstance(step_config, list): batches = batches + [b for b in step_config if isinstance(b, str)] + batches = list(resolve_aliases(batches, merged_batches['aliases'])) + + merged_batches = { key:value for key, value in merged_batches.items() if key in ['aliases', 'database', *batches]} + # TODO: filter the aliases, but it requires care in case of multiple levels of aliases... + + # We store in this directory the scripts used to run this new batch, as well as the logs + # We may instead want to use the folder where this batch's results are stored + # Or even store the metadata in the database itself... + prev_mask = os.umask(000) + + batch_dir = batch.batch_dir + # FIXME: if the output directory includes "{user}", we will use the current user (qaboard) + # but it's likely better to use the user that requested the tuning + default_user = os.environ.get('QABOARD_DEFAULT_USER', 'qaboard') + batch_dir = Path(str(batch_dir).replace(f'/outputs/{default_user}/', f'/outputs/{user}/')) + if not batch.batch_dir_override: + batch.batch_dir_override = str(batch_dir) + db_session.add(batch) + db_session.commit() + + if not batch_dir.exists(): + batch_dir.mkdir(exist_ok=True, parents=True) + os.umask(prev_mask) + + command_id = str(uuid.uuid4()) + merged_batches_path = f'{batch_dir}/batches-{command_id[:8]}.yaml' + with Path(merged_batches_path).open('w') as f: + f.write(yaml.dump(merged_batches)) + + working_directory = ci_commit.artifacts_dir + print(working_directory) + + # This will make us do automated tuning, versus a single manual batch + do_optimize = data['tuning_search']['search_type'] == 'optimize' + if do_optimize: + # we write somewhere the optimzation search configuration + # it needs to be accessed from LSF so we can't use temporary files... + config_path = batch_dir / 'optim-config.yaml' + checkpoint_path = batch_dir / 'checkpoint.pkl' + config_option = f"--config-file '{config_path}' --checkpoint '{checkpoint_path}'" + with config_path.open("w") as f: + f.write(data['tuning_search']['parameter_search']) + else: + config_option = f"--tuning-search {quote(json.dumps(data['tuning_search']))}" + + overwrite = "--action-on-existing run" if data["overwrite"] in ("on", True) else "--action-on-existing sync" + batch_command = " ".join([ + "qa", + f"--platform '{data['platform']}'" if "platform" in data else "", + f"--label '{data['batch_label']}'", + "optimize" if do_optimize else "batch", + f'--batches-file {merged_batches_path} ' + f"--batch '{data['selected_group']}'", + # f"--runner=local", # uncomment if testing from Samsung SIRC where LSF is the default + config_option, + f"{overwrite} --no-wait" if not do_optimize else '', + ]) + print(batch_command) + + qa_batch_script = _generate_batch_script(ci_commit, user, working_directory, command_id, batch_command, data) + print(qa_batch_script) + qa_batch_path = batch_dir / "qa_batch.sh" + with qa_batch_path.open("w") as f: + f.write(qa_batch_script) + + runner = os.environ.get('QABOARD_TUNING_RUNNER', 'local') + try: + if runner == 'lsf': + _dispatch_lsf(qa_batch_path, batch_dir, user, ci_commit, do_optimize) + elif runner == 'celery': + _dispatch_celery(qa_batch_path, batch_dir) + else: + _dispatch_local(qa_batch_path, batch_dir) + except Exception: + error_log = (batch_dir / 'log.txt').read_text() if (batch_dir / 'log.txt').exists() else "Failed to start batch" + return jsonify({"error": error_log, "cmd": runner}), 500 + return jsonify({"cmd": runner, "stdout": "OK"}) diff --git a/backend/backend/api/webhooks.py b/backend/backend/api/webhooks.py new file mode 100755 index 000000000..980c513bc --- /dev/null +++ b/backend/backend/api/webhooks.py @@ -0,0 +1,72 @@ +""" +Here is the "write" part of the API, to signal more data is ready. +It includes the actual webhooks sent e.g. by Gitlab, as well as +API calls to update batches and outputs. +""" +import sys +import json + +from flask import request, jsonify +from sqlalchemy.orm.attributes import flag_modified + +from backend import app, db_session +from ..models import CiCommit, Output +from ..models.Project import update_project + + +@app.route('/api/v1/commit///batches', methods=['DELETE']) +@app.route('/api/v1/commit///batches/', methods=['DELETE']) +@app.route('/api/v1/commit//batches', methods=['DELETE']) +@app.route('/api/v1/commit//batches/', methods=['DELETE']) +def delete_commit(commit_id, project_id=None): + try: + ci_commits = CiCommit.query.filter(CiCommit.hexsha == commit_id) + if project_id: + ci_commits = ci_commits.filter(CiCommit.project_id == project_id) + for ci_commit in ci_commits.yield_per(1000): + print("DELETING", ci_commit) + if ci_commit.hexsha in ci_commit.project.milestone_commits: + return f"403 ERROR: Cannot delete milestones", 403 + for batch in ci_commit.batches: + print(f" > {batch}") + stop_status = batch.stop(db_session) + if "error" in stop_status: + return jsonify(stop_status), 500 + batch.delete(session=db_session) + return {"status": "OK"} + except Exception as e: + return f"404 ERROR {e}: {commit_id} in {project_id}", 404 + return f"404 ERROR: Cannot find commit", 404 + + + + +@app.route('/webhook/gitlab', methods=['GET', 'POST']) +def gitlab_webhook(): + """If Gitlab calls this endpoint every push, we get avatars and update our local copy of the repo.""" + # https://docs.gitlab.com/ce/user/project/integrations/webhooks.html + data = json.loads(request.data) + print(data, file=sys.stderr) + update_project(data, db_session) + return "{status:'OK'}" + + +@app.route('/webhook/github', methods=['GET', 'POST']) +def github_webhook(): + """If GitHub calls this endpoint every push, we normalize the payload and update our local copy of the repo.""" + # https://docs.github.com/en/webhooks/webhook-events-and-payloads#push + data = json.loads(request.data) + print(data, file=sys.stderr) + normalized = { + 'ref': data['ref'], + 'checkout_sha': data.get('after'), + 'project': { + 'path_with_namespace': data['repository']['full_name'], + 'web_url': data['repository']['html_url'], + 'name': data['repository']['name'], + 'hosting_type': 'github', + } + } + update_project(normalized, db_session) + return "{status:'OK'}" + diff --git a/backend/backend/backend.py b/backend/backend/backend.py new file mode 100644 index 000000000..8572fb20c --- /dev/null +++ b/backend/backend/backend.py @@ -0,0 +1 @@ +from .backend import app diff --git a/backend/backend/clean.py b/backend/backend/clean.py new file mode 100755 index 000000000..ecb9fbc85 --- /dev/null +++ b/backend/backend/clean.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python +""" +Remove old outputs and artifacts from storage. +By default, the default reference branch, active commits and milestone are not deleted + +The default configuration is: +```yaml +# qaboard.yaml +storage: + garbage: + after: 1month + # supports human-readable values: `2weeks`, `1year`, `3months`... +``` + + +**Artifacts** are not deleted by default, you have to specify: + +```yaml +storage: + garbage: + after: 1month + artifacts: + delete: true + keep: # optionnal... + - binary # +``` + +Notes: +- If you change those settings, old artifacts don't get deleted. +- When runs use a commit that was deleted, or you upload manifests for a deleted commit, it is marked undeleted. +``` + +""" +import re +import sys +import json +import glob +import datetime +from pathlib import Path + +import click +from click import secho +from sqlalchemy import func, and_, asc, or_, not_ + +from .database import db_session, Session +from .models import Project, CiCommit, Batch, Output + + +now = datetime.datetime.utcnow() + + +# TODO: remove __all__ the output folders for deleted results +3months +# TODO: remove all files in artifacts by fixing permission issues + +@click.command() +@click.option('--clean-untracked-artifacts', is_flag=True, help="Delete untracked artifacts") +@click.option('--artifacts-root', 'artifacts_roots', multiple=True, help="Where to look for artifacts") +@click.option('--use-cache', is_flag=True, help="Cache protected commits from milestones") +def clean_untracked_hwalg_artifacts(clean_untracked_artifacts, artifacts_roots, use_cache): + """ + WARNING: don't run this unless you know what you are doing + """ + from .git_utils import git_pull + cache_path = Path('cache.milestones.json') + if cache_path.exists() and use_cache: + secho("WARNING: Using CACHED MILESTONES commits", fg='yellow') + milestone_commits = set(json.loads(cache_path.read_text())) + else: + milestone_commits = set() + projects = (db_session + .query(Project) + .filter(Project.id.startswith('CDE-Users/HW_ALG')) + .filter(not_(Project.id.startswith('CDE-Users/HW_ALG/ALG_GEN'))) + ) + for project in projects: + print(project) + milestone_commits.update(set(project.milestone_commits)) + with cache_path.open('w') as f: + json.dump(list(milestone_commits), f) + secho(f"Protecting {len(milestone_commits)} commits", fg='blue') + + hwalg = db_session.query(Project).filter(Project.id == 'CDE-Users/HW_ALG').one() + git_pull(hwalg.repo) + + if not artifacts_roots: + artifacts_roots = [ + # '/stage/algo_data/ci/CDE-Users/HW_ALG/commits', + '/algo/CIS_artifacts/CDE-Users/HW_ALG', + '/algo/PSP_2x_artifacts/CDE-Users/HW_ALG', + '/algo/KITT_ISP_artifacts/CDE-Users/HW_ALG', + # '/stage/algo_data/ci/CDE-Users/HW_ALG', + ] + for artifacts_root in artifacts_roots: + artifacts_root = Path(artifacts_root) + def iter_hashsha_dir(): + # artifacts_root = Path('/stage/algo_data/ci/CDE-Users/HW_ALG/commits') + # for directory in artifacts_root.iterdir(): + # hexsha = directory.name.split('__')[-1] + # yield hexsha, directory + # return? + for hash2 in artifacts_root.iterdir(): + if len(hash2.name) != 2: + continue + for hash16 in hash2.iterdir(): + hexsha = hash2.name + hash16.name + yield hexsha, hash16 + + for hexsha, artifact_dir in iter_hashsha_dir(): + try: + commit = hwalg.repo.commit(hexsha) + hexsha = commit.hexsha + except: # force pushes, rebases... some commits won't be fetched + commit = None + try: + created_datetime = commit.authored_datetime + except: + ctime = artifact_dir.stat().st_ctime + created_datetime = datetime.datetime.fromtimestamp(ctime).astimezone() + is_old = created_datetime < now.astimezone() - parse_time('3weeks') + if is_old and not any([c.startswith(hexsha) for c in milestone_commits]): + print('DELETE', artifact_dir, created_datetime) + ci_commit = CiCommit( + hexsha=hexsha, + project=hwalg, + ) + try: + nb_manifests_dir = 0 + for qatools_path in artifact_dir.rglob('manifests'): + nb_manifests_dir += 1 + ci_commit.commit_dir_override = qatools_path.parent + print(ci_commit.commit_dir_override) + ci_commit.delete() + if not nb_manifests_dir: + ci_commit.commit_dir_override = artifact_dir + print(ci_commit.commit_dir_override) + ci_commit.delete() + except Exception as e: # empty parent folders will be deleted, including the folder we iterate in... + # __pycache__ can be owned by a different user that the one that created the folder... + print(e) + # return + + + +@click.command() +@click.option('--outputs-root', 'outputs_roots', multiple=True, help="Where to look for outputs") +@click.option('--use-cache', is_flag=True, help="Cache protected commits from milestones") +@click.option('--user', required=True, help="The user name to delete the quota for") +def clean_untracked_hwalg_outputs(outputs_roots, user, use_cache): + """ + WARNING: don't run this unless you know what you are doing + """ + from .git_utils import git_pull + cache_path = Path('cache.milestones.json') + if cache_path.exists() and use_cache: + secho("WARNING: Using CACHED MILESTONES commits", fg='yellow') + milestone_commits = set(json.loads(cache_path.read_text())) + else: + milestone_commits = set() + projects = (db_session + .query(Project) + .filter(Project.id.startswith('CDE-Users/HW_ALG')) + .filter(not_(Project.id.startswith('CDE-Users/HW_ALG/ALG_GEN'))) + ) + for project in projects: + print(project) + milestone_commits.update(set(project.milestone_commits)) + with cache_path.open('w') as f: + json.dump(list(milestone_commits), f) + + secho(f"Protecting {len(milestone_commits)} commits", fg='blue') + hwalg = db_session.query(Project).filter(Project.id == 'CDE-Users/HW_ALG').one() + git_pull(hwalg.repo) + if not outputs_roots: + outputs_roots = [ + f"/algo/CIS/outputs/{user}/CDE-Users/HW_ALG", + f"/algo/PSP_2x/outputs/{user}/CDE-Users/HW_ALG", + f"/algo/KITT_ISP/outputs/{user}/CDE-Users/HW_ALG", + ] + + outputs_roots = [glob.glob(outputs_root) for outputs_root in outputs_roots] + outputs_roots = [item for sublist in outputs_roots for item in sublist] # flatten list + for outputs_root in outputs_roots: + outputs_root = Path(outputs_root) + def iter_hashsha_dir(): + for hash2 in outputs_root.iterdir(): + if len(hash2.name) != 2: + continue + for hash16 in hash2.iterdir(): + hexsha = hash2.name + hash16.name + yield hexsha, hash16 + + for hexsha, output_dir in iter_hashsha_dir(): + try: + commit = hwalg.repo.commit(hexsha) + hexsha = commit.hexsha + except: # force pushes, rebases... some commits won't be fetched + commit = None + print(f"commit {hexsha} doesn't exist in DB") + try: + created_datetime = commit.authored_datetime + except: + ctime = output_dir.stat().st_ctime + created_datetime = datetime.datetime.fromtimestamp(ctime).astimezone() + + is_old = created_datetime < now.astimezone() - parse_time('3weeks') + if is_old and not any([c.startswith(hexsha) for c in milestone_commits]): + print('DELETE', output_dir, created_datetime) + ci_commit = CiCommit( + hexsha=hexsha, + project=hwalg, + ) + try: + nb_manifests_dir = 0 + for qatools_path in output_dir.rglob('manifests'): + nb_manifests_dir += 1 + ci_commit.commit_dir_override = qatools_path.parent + print(ci_commit.commit_dir_override) + ci_commit.delete() + if not nb_manifests_dir: + ci_commit.commit_dir_override = output_dir + print(ci_commit.commit_dir_override) + ci_commit.delete() + except Exception as e: # empty parent folders will be deleted, including the folder we iterate in... + # __pycache__ can be owned by a different user that the one that created the folder... + print(e) + # return + + + +@click.command() +@click.option('--project', 'project_ids', help="Regular expressions to match projects", multiple=True) +@click.option('--before', help="Overwrites what's defined in the project config. 1month, 3days..") +@click.option('--can-delete-reference-branch', is_flag=True, help="Allows deleting results on the reference branch (e.g. master/develop). The latest commit will be kept.") +@click.option('--can-delete-outputs/--cannot-delete-outputs', is_flag=True, default=True, help="Allows deleting artifacts.") +@click.option('--can-delete-artifacts', is_flag=True, help="Allows deleting artifacts.") +@click.option('--dryrun', is_flag=True) +@click.option('--verbose', is_flag=True) +def clean(project_ids, before, can_delete_reference_branch, can_delete_outputs, can_delete_artifacts, dryrun, verbose): + if before and not project_ids: + secho('[ERROR] when using --before you need to use --project', fg='red') + exit(1) + + projects = db_session.query(Project) #.filter(Project.id == 'CDE-Users/HW_ALG/CIS') + for project in projects: + if project.data.get("legacy"): + continue + if project_ids and not any([re.match(project_id, project.id) for project_id in project_ids]): + continue + secho(project.id, fg='blue', bold=True) + if not project.repo: + secho(f'[WARNING] Could not clone/read the git repo for {project.id}', fg='yellow') + # return + continue + + try: + gc_config = project.data.get("qatools_config", {}).get("storage", {}).get('garbage', {}) + except: # e.g. storage is defined as a single string + gc_config = {} + can_delete_reference_branch = can_delete_reference_branch or gc_config.get('can_delete_reference_branch') + before = gc_config.get('after', '1month') if not before else before + old_treshold = now - parse_time(before) + secho(f"deleting data older than {old_treshold}", dim=True) + + commits = ( + db_session.query(CiCommit) + .filter(CiCommit.project == project) + .filter(CiCommit.deleted == False) + # we could check those rare occurences from python-land... + .filter(CiCommit.hexsha.notin_(project.milestone_commits)) + .filter(or_( + bool(CiCommit.latest_output_datetime) and CiCommit.latest_output_datetime < old_treshold, + not CiCommit.latest_output_datetime and CiCommit.authored_datetime < old_treshold, + )) + .order_by(CiCommit.authored_datetime.desc()) + + ) + if not can_delete_reference_branch: + commits = commits.filter(CiCommit.branch.notin_(project.protected_refs)) + + for commit in commits.yield_per(1000): + # if '/algo/' not in str(commit.artifacts_dir): + # continue + # print(commit.artifacts_dir) + # cis_dir = str(self.artifacts_dir).replace("KITT_ISP", "CIS") + # continue + secho(f"@{commit.project_id} {commit.branch} {commit.hexsha} {commit.authored_datetime}", fg='cyan') + outputs = (db_session.query(Output).join(Batch).filter(Batch.ci_commit_id == commit.id)) + + nb_outputs = 0 + nb_outputs_deleted = 0 + for o in outputs: + if not can_delete_outputs: + continue + nb_outputs += 1 + if o.deleted: + continue + nb_outputs_deleted += 1 + print(" ", o) + try: + o.delete(dryrun=dryrun) # ignore=['*.json', '*.txt'], + if not dryrun: + db_session.add(o) + except Exception as e: + print(e) + # raise e + try: + o.update_manifest() + except: + pass + gc_config_artifacts = gc_config.get('artifacts', {}) + deleted_artifacts = False + if gc_config_artifacts.get('delete') == True or can_delete_artifacts: + undeleted_commits_from_subprojects = ( + db_session.query(CiCommit) + .filter(CiCommit.project_id.startswith(commit.project_id)) + .filter(CiCommit.deleted == False) + .filter(CiCommit.hexsha == commit.hexsha) + ) + if undeleted_commits_from_subprojects: + print(f"> skippping {commit}: undeleted_commits_from_subprojects") + continue + + secho(f" Deleting artifacts", fg='cyan', dim=True) + try: + commit.delete(keep=gc_config_artifacts.get('keep', []), dryrun=dryrun) + deleted_artifacts = True + except Exception as e: + print(e) + continue + if not dryrun: + if nb_outputs_deleted or deleted_artifacts: + db_session.add(commit) + if not nb_outputs and deleted_artifacts and can_delete_outputs: + print(f"DELETE {commit}") + db_session.delete(commit) + + db_session.commit() + + + +delta_re = re.compile(r'^((?P[\.\d]+?)y(ear)?s?)? *((?P[\.\d]+?)m(onth)?s?)? *((?P[\.\d]+?)w(eek)?s?)? *((?P[\.\d]+?)d(ay)?s?)? *((?P[\.\d]+?)h(our)?s?)? *((?P[\.\d]+?)min(ute)?s?)? *((?P[\.\d]+?)s(econd)?s?)?$') +def parse_time(time_str): + """ + Parse a time string e.g. (2h13m) into a timedelta object. + Modified from virhilo's answer at https://stackoverflow.com/a/4628148/851699 + :param time_str: A string identifying a duration. (eg. 2h13m) + :return datetime.timedelta + """ + parts = delta_re.match(time_str) + assert parts is not None, f"Could not parse any time information from '{time_str}'. Examples of valid strings: '1y', '2months 20d', '8h', '2d8h5min20s', '2min4s'" + groupdict = parts.groupdict() + if not groupdict.get('days'): + groupdict['days'] = 0 + else: + groupdict['days'] = float(groupdict['days']) + if groupdict.get('weeks'): + groupdict['days'] = groupdict['days'] + 7 * float(groupdict['weeks']) + del groupdict['weeks'] + if groupdict.get('months'): + groupdict['days'] = groupdict['days'] + 31 * float(groupdict['months']) + del groupdict['months'] + if groupdict.get('years'): + groupdict['days'] = groupdict['days'] + 365 * float(groupdict['years']) + del groupdict['years'] + time_params = {name: float(param) for name, param in groupdict.items() if param} + return datetime.timedelta(**time_params) + + +if __name__ == '__main__': + if '--clean-untracked-artifacts' in sys.argv: + clean_untracked_hwalg_artifacts() + else: + clean() diff --git a/backend/backend/clean_big_files.py b/backend/backend/clean_big_files.py new file mode 100644 index 000000000..da459b343 --- /dev/null +++ b/backend/backend/clean_big_files.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +import sys +import datetime +import subprocess +from pathlib import Path + +from sqlalchemy import func, and_, asc, or_, not_ + +from .database import db_session, Session +from .models import Output + + + +now = datetime.datetime.utcnow() +from .clean import parse_time +query_start = now - parse_time('6months') +# query_start = now - parse_time('1day') + + +def main(): + outputs = (db_session + .query(Output) + .filter(Output.created_date > query_start) + .order_by(Output.created_date.desc()) + ) + print(outputs.count()) + nb_bad_files = 0 + total_size = 0 + errors = [] + for output in outputs.yield_per(1000): + if not output.output_dir.exists(): + continue + if not (output.output_dir / 'manifest.outputs.json').exists(): + output.update_manifest() + for path in output.output_dir.rglob('*'): + size = path.stat().st_size + if size > 1_000_000_000: + nb_bad_files += 1 + total_size += size + print(nb_bad_files, path, size) + try: + path.unlink() + except: + errors.append(path) + # exit(0) + # exit(0) + print(f"{nb_bad_files} files for {total_size}") + if errors: + print(f"ERRORS!") + for e in errors: + print(e) + # 237 + with Path('/home/arthurf/errors.txt').open('w') as f: + for e in errors: + print(e, file=f) + +main() \ No newline at end of file diff --git a/backend/backend/config.py b/backend/backend/config.py new file mode 100755 index 000000000..18bb2d18a --- /dev/null +++ b/backend/backend/config.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path + +qaboard_url = os.getenv('QABOARD_URL', 'http://qaboard') + +# we clone our repositories locally here to access commit metadata +git_server = os.getenv('GITLAB_HOST', 'https://gitlab.com') +github_token = os.getenv('GITHUB_ACCESS_TOKEN', '') + +# Where we save "non-metadata" qaboard data +qaboard_data_dir = Path(os.getenv('QABOARD_DATA_DIR', '/var/qaboard')).resolve() +qaboard_data_dir.mkdir(exist_ok=True, parents=True) + +# Where we save custom per-project groups (currently used only for extra-runs and tuning in api/tuning.py) +qaboard_data_shared_dir = Path(os.environ.get("QABOARD_DATA_SHARED_DIR", qaboard_data_dir / 'shared')) +# Where we clone git repositories +qaboard_data_git_dir = Path(os.environ.get("QABOARD_DATA_GIT_DIR", qaboard_data_dir / 'git')) + +default_storage_root = Path('/mnt/qaboard') +default_outputs_root = default_storage_root +default_artifacts_root = default_storage_root diff --git a/qaboard-backend/slamvizapp/database.py b/backend/backend/database.py old mode 100644 new mode 100755 similarity index 79% rename from qaboard-backend/slamvizapp/database.py rename to backend/backend/database.py index 391aa03ca..738105e3e --- a/qaboard-backend/slamvizapp/database.py +++ b/backend/backend/database.py @@ -12,24 +12,25 @@ # https://github.com/PyMySQL/mysqlclient-python db_type = os.getenv('QABOARD_DB_TYPE', 'postgresql') -db_user = os.getenv('QABOARD_DB_USER', 'ci') -db_password = os.getenv('QABOARD_DB_PASSWORD', 'dvsdvs') -db_host = os.getenv('QABOARD_DB_HOST', 'localhost') +db_user = os.getenv('QABOARD_DB_USER', 'qaboard') +db_password = os.getenv('QABOARD_DB_PASSWORD', 'password') +db_host = os.getenv('QABOARD_DB_HOST', 'db') db_port = os.getenv('QABOARD_DB_PORT', 5432) -db_name = os.getenv('QABOARD_DB_NAME', 'slamvizapp') +db_name = os.getenv('QABOARD_DB_NAME', 'qaboard') db_echo = bool(os.getenv('QABOARD_DB_ECHO', False)) import ujson import psycopg2.extras psycopg2.extras.register_default_json(loads=lambda x: ujson.loads) +psycopg2.extras.register_default_jsonb(loads=lambda x: ujson.loads) engine_url = f'{db_type}://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' engine = create_engine( engine_url, echo=db_echo, - pool_size=100, - max_overflow=10, + pool_size=10, + max_overflow=5, json_deserializer=ujson.loads, json_serializer=ujson.dumps, ) @@ -44,14 +45,14 @@ # This is the recommended integration with Flask # It scopes session within HTTP requests -from flask import _app_ctx_stack +from greenlet import getcurrent Session = sessionmaker(bind=engine) db_session = scoped_session( sessionmaker( autocommit=False, autoflush=False, bind=engine), - scopefunc=_app_ctx_stack.__ident_func__ + scopefunc=getcurrent ) Base = declarative_base() # prints (no name) Base.query = db_session.query_property() diff --git a/backend/backend/fs_utils.py b/backend/backend/fs_utils.py new file mode 100644 index 000000000..32f8c55a9 --- /dev/null +++ b/backend/backend/fs_utils.py @@ -0,0 +1,148 @@ +""" +Deal with filesystem common needs and issues + +# delete a file with +# python backend/fs_utils.py 11611:10 ~/test.yaml +""" +import os +import pwd +import sys +import pickle +import shutil +import tempfile +import traceback +import subprocess +from pathlib import Path + + + + +def rmtree(path: Path) -> int: + nb_deleted = 0 + if path.is_dir(): # delete the children first + for p in path.iterdir(): + nb_deleted += rmtree(p) + + print("RM", path) + try: + if path.is_file(): + path.unlink() + else: + path.rmdir() + return 1 + except: + # Already deleted? + if not path.exists(): + return 0 + + # Permission issues? + # we need to be able to delete files owned by any user + # since we don't have access to the real NFS root, we need to su as the owner of each file + stat = path.stat() + try: + try: # the user running the server needs SETUID/SETGID capabilities + as_user(f"{stat.st_uid}:{stat.st_gid}", rmtree, path) + return 1 + except: # as a fallback, we can try to use sudo... + command = ["sudo", "-tt", "python", __file__, f"{stat.st_uid}:{stat.st_gid}", str(path)] + print(command) + subprocess.run(command, check=True) + return 1 + except Exception as e: + message = f"ERROR {e}: Could not remove: {path}" + print(message) + raise Exception(message) + + + + +def rm_empty_parents(path: Path): + for parent in path.parents: + try: + is_empty = not any(parent.iterdir()) + except FileNotFoundError: + is_empty = True + if is_empty: + rmtree(parent) + else: + break + + +def as_user(user, f, *args, **kwargs): + """Call a function as a given user, assuming the current user can setuid/setgid (~root)""" + tf = tempfile.NamedTemporaryFile(delete=False) + Path(tf.name).chmod(0o777) + pid = os.fork() + if pid == 0: + # child - do the work and exit + try: + if ":" in user: + uid, gid = user.split(':') + uid = int(uid) + gid = int(gid) + else: + pwnam = pwd.getpwnam(user) + assert user == pwnam.pw_name + uid = pwnam.pw_uid + gid = pwnam.pw_gid + + os.setegid(gid) + os.seteuid(uid) + # print(user, os.geteuid(), os.getegid()) + print("as:", user) + # print("f:", f) + # print("args:", args) + # print("kargs:", kwargs) + try: + result = f(*args, **kwargs) + except Exception as e: + result = e + # print("result:", result) + pickle.dump(result, open(tf.name, 'wb'), pickle.HIGHEST_PROTOCOL) + # print("from child:", tf.name, Path(tf.name).read_bytes()) + except Exception as e: + print(f"ERROR in child process: {e}") + pickle.dump(e, open(tf.name, 'wb'), pickle.HIGHEST_PROTOCOL) + traceback.print_exc(file=sys.stdout) + finally: + os._exit(0) + # parent - wait for the child to do its work and keep going as root + pid, status = os.waitpid(pid, 0) + # print(pid, status) + if status != 0: + print(f"ERROR: Child ({pid}) exited with {status}") + os._exit(1) + # print("from parent", tf.name) + # print(Path(tf.name).read_bytes()) + # print(pickle.load(open(tf.name, 'rb'))) + return_value = pickle.load(open(tf.name, 'rb')) + if isinstance(return_value, Exception): + raise return_value + return return_value + + +# Before using setuid to delete files as their user, we would try more complicated things... +# This assumes all users are mapped in /etc/passwd, but it's annoying to maintain! +# from pwd import getpwnam +# def open_permissions(path): +# owner = path.owner() +# # FIXME: wrap the whole ssh arg with '' +# # assuming you can SSH... +# if owner == 'sircdevops': +# subprocess.run(f'ssh sircdevops@sircdevops-vdi chmod -R 777 "{path}"', shell=True, check=True) +# else: +# pwname = getpwnam(owner) +# owner = f"{pwname.pw_uid}:{pwname.pw_uid}" # TODO: need passwd up to date.. +# subprocess.run(f'ssh arthurf-vdi drun --cpu --skip_resources -v "{path}:{path}" --no-lsf -v /home/arthurf/gosu-i386:/usr/local/bin/gosu:ro ubuntu:trusty gosu {owner} chmod -R 777 "{path}"', shell=True, check=True) + + +if __name__ == "__main__": + if len(sys.argv) > 3 or len(sys.argv) == 1: + raise ValueError("Usage: [uid:gid] path-to-delete") + if len(sys.argv) == 2: + path = Path(sys.argv[1]) + rmtree(path) + else: + uid_gid = sys.argv[1] + path = Path(sys.argv[2]) + as_user(uid_gid, rmtree, path) diff --git a/backend/backend/git_utils.py b/backend/backend/git_utils.py new file mode 100644 index 000000000..ff4c2a655 --- /dev/null +++ b/backend/backend/git_utils.py @@ -0,0 +1,100 @@ +import os +from urllib.parse import urlparse + +from git import Repo +from git import RemoteProgress +from git.exc import NoSuchPathError, InvalidGitRepositoryError + +from .fs_utils import as_user + +class Repos(): + """Holds data for multiple repositories.""" + + def __init__(self, git_server, clone_directory): + self._repos = {} + self.git_server = git_server + if not self.git_server.endswith('/'): + self.git_server = self.git_server + '/' + self.clone_directory = clone_directory + + def _authenticated_clone_url(self, project_path, hosting_type=None, web_url=None): + """Build an authenticated clone URL for GitHub or GitLab.""" + if hosting_type == 'github': + github_token = os.environ.get('GITHUB_ACCESS_TOKEN', '') + if web_url: + parsed = urlparse(web_url) + host = parsed.hostname + scheme = parsed.scheme + else: + host = 'github.com' + scheme = 'https' + if github_token: + return f"{scheme}://x-access-token:{github_token}@{host}/{project_path}" + return f"{scheme}://{host}/{project_path}" + else: + # GitLab (default) + gitlab_token = os.environ.get('GITLAB_ACCESS_TOKEN', '') + if gitlab_token: + return self.git_server.replace('://', f"://oauth2:{gitlab_token}@") + project_path + return f"{self.git_server}{project_path}" + + def __getitem__(self, project_path, hosting_type=None, web_url=None): + """ + Return a git-python Repo object representing a clone + of $QABOARD_GIT_SERVER/project_path at $QABOARD_DATA_DIR + + project_path: the full git repository namespace, eg group/repo + hosting_type: 'github' or 'gitlab' (default) + web_url: the web URL of the repo (used to derive host for GitHub Enterprise) + """ + clone_location = str(self.clone_directory / project_path) + try: + repo = Repo(clone_location) + except InvalidGitRepositoryError: + from fs_utils import rmtree + rmtree(clone_location) # fail, and hopefully it will work better next time... + except NoSuchPathError: + try: + clone_url = self._authenticated_clone_url(project_path, hosting_type=hosting_type, web_url=web_url) + print(f'Cloning <{project_path}> to {self.clone_directory}') + # https://gitpython.readthedocs.io/en/stable/reference.html#git.repo.base.Repo.clone_from + repo = Repo.clone_from( + clone_url, + str(clone_location), + ) + except Exception as e: + print(f'[ERROR] Could not clone: {e}. Please set $QABOARD_DATA_DIR to a writable location and verify your network settings') + raise(e) + self._repos[project_path] = repo + return self._repos[project_path] + + def get(self, project_path, hosting_type=None, web_url=None): + """Like __getitem__ but accepts hosting context parameters.""" + return self.__getitem__(project_path, hosting_type=hosting_type, web_url=web_url) + + +def git_pull(repo): + """Updates the repo and warms the cache listing the latests commits..""" + class MyProgressPrinter(RemoteProgress): + def update(self, op_code, cur_count, max_count=100.0, message="[No message]"): + # print('...') + # print(op_code, cur_count, max_count, (cur_count or 0)/max_count, message) + pass + try: + for fetch_info in repo.remotes.origin.fetch(progress=MyProgressPrinter()): + # print(f"Updated {fetch_info.ref} to {fetch_info.commit}") + pass + except Exception as e: + print(e) + +def find_branch(commit_hash, repo): + """Tries to get from which branch a commit comes from. It's a *guess*.""" + std_out = repo.git.branch(contains=commit_hash, remotes=True) + branches = [l.split(' ')[-1] for l in std_out.splitlines()] + important_branches = ['origin/release', 'origin/master', 'origin/develop'] + for b in important_branches: + if b in branches: + return b + if branches: + return branches[0] + return 'unknown' \ No newline at end of file diff --git a/backend/backend/hybrid_cache.py b/backend/backend/hybrid_cache.py new file mode 100644 index 000000000..6845ec6dd --- /dev/null +++ b/backend/backend/hybrid_cache.py @@ -0,0 +1,63 @@ +import os +import functools +import pickle +import threading + +import redis + +redis_client = redis.Redis( + host=os.environ.get("REDIS_HOST", "localhost"), + port=int(os.environ.get("REDIS_PORT", "6379")), + db=0, +) + +# Thread-local cache +thread_local = threading.local() + + + +def hybrid_cache(ttl=60, maxsize=128): + """Hybrid cache: thread-local (fast) + Redis (shared). + The TTL only affects redis, thread-local cache has no TTL, so make sure workers are killed before the ttl expires to keep freshness garantees. + """ + + def decorator(func): + local_cache = functools.lru_cache(maxsize=maxsize)(func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Ensure thread has its own cache instance + if not hasattr(thread_local, "cache"): + thread_local.cache = {} + + cache_key = f"{func.__name__}:{args}:{kwargs}" + print(cache_key) + + + # 1️⃣ Check thread-local cache + # We don't have a TTL here since we kill workers after a while... + if cache_key in thread_local.cache: + return thread_local.cache[cache_key] + + + # 2️⃣ Check Redis cache + result = redis_client.get(cache_key) + if result is not None: + try: + result = pickle.loads(result) + thread_local.cache[cache_key] = result # Store in local cache + return result + except: + pass + + # 3️⃣ Compute result and store in both caches + result = func(*args, **kwargs) + + thread_local.cache[cache_key] = result # Store in local cache + redis_client.setex(cache_key, ttl, pickle.dumps(result)) # Store in Redis + + return result + + return wrapper + + return decorator diff --git a/backend/backend/models/Batch.py b/backend/backend/models/Batch.py new file mode 100755 index 000000000..43c70fdc6 --- /dev/null +++ b/backend/backend/models/Batch.py @@ -0,0 +1,208 @@ +""" +Represents runs belonging to the same commit. +It might by a CI job, or tuning experiments. +""" +import os +import uuid +import datetime +from pathlib import Path + +import numpy as np +from sqlalchemy import ForeignKey, Integer, String, DateTime, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import UniqueConstraint, Column +from sqlalchemy.orm import relationship +from sqlalchemy import func, case + +from qaboard.conventions import batch_folder_name +from qaboard.api import dir_to_url + +from backend.models import Base, Output + + + +class Batch(Base): + __tablename__ = 'batches' + id = Column(Integer, primary_key=True) + created_date = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + data = Column( + JSONB(), + nullable=False, + default=dict, + server_default='{}', + # FIXME? + # https://stackoverflow.com/questions/38961396/sqlalchemy-set-default-value-for-postgres-json-column + # says we should use this? + # default=text("'{}'::jsonb"), + # server_default=text("'{}'::jsonb"), + ) + + ci_commit_id = Column(Integer(), ForeignKey('ci_commits.id'), index=True) + ci_commit = relationship("CiCommit", back_populates="batches", foreign_keys=[ci_commit_id]) + + # identifies eg whether it is the default CI job, or a tuning experiment... + label = Column(String(), default="default") + + __table_args__ = (UniqueConstraint('ci_commit_id', 'label', name='_ci_commit__label'),) + + batch_dir_override = Column(String()) + + outputs = relationship("Output", + back_populates="batch", + cascade="all, delete-orphan" + ) + + + @property + def batch_dir(self): + if self.batch_dir_override: + return Path(self.batch_dir_override) + else: + return self.ci_commit.outputs_dir / batch_folder_name(self.label) + + + def to_dict(self, session, with_outputs=False, with_aggregation=None): + # TODO: limit outputs? avoid loading all? + metrics_to_aggregate = with_aggregation if with_aggregation else {} + if with_outputs: + outputs = {'outputs': {o.id: o.to_dict() for o in self.outputs}} + else: + outputs = {} + result = ( + session.query( + func.sum(case((~Output.is_failed & ~Output.is_pending, 1), else_=0)).label('valid_outputs'), + func.sum(case((Output.is_pending, 1), else_=0)).label('pending_outputs'), + func.sum(case((Output.is_running, 1), else_=0)).label('running_outputs'), + func.sum(case((Output.is_failed, 1), else_=0)).label('failed_outputs'), + func.sum(case((Output.deleted, 1), else_=0)).label('deleted_outputs') + ) + .filter(Output.batch_id == self.id) + .one() + ) + return { + 'id': self.id, + 'commit_id': self.ci_commit.hexsha, + 'label': self.label, + 'created_date': self.created_date.isoformat(), + 'data': self.data if self.data else {}, # None check for old batches (todo: migrate them properly) + 'batch_dir_url': dir_to_url(self.batch_dir), + 'aggregated_metrics': {}, # aggregated_metrics(self.outputs, metrics_to_aggregate), + 'valid_outputs': result.valid_outputs or 0, + 'pending_outputs': result.pending_outputs or 0, + 'running_outputs': result.running_outputs or 0, + 'failed_outputs': result.failed_outputs or 0, + 'deleted_outputs': result.deleted_outputs or 0, + **outputs, + } + + def __repr__(self): + return (f"") + + def rename(self, label, db_session): + # Note that the output directories will still be based on the old label, we don't move/copy anything + self.label = label + db_session.add(self) + db_session.commit() + + def redo(self, only_failed=False, only_deleted=False): + # in case it was deleted without QA-Board being made aware + if not self.ci_commit.artifacts_dir.exists(): + print("Restoring artifacts") + self.ci_commit.save_artifacts() + + success = True + command_id = uuid.uuid4() + for output in self.outputs: + if only_failed and not output.is_failed: + continue + if only_deleted and not output.deleted: + continue + output_success = output.redo(command_id=command_id) + success = success and output_success + return success + + def stop(self, session): + if not any([o.is_pending for o in self.outputs]): + return {} + + # TODO: it's a bit overkill to stop everything, and may even yield errors... + # TODO: can we after the stop() just mark all outputs as is_pending:False ? + errors = [] + for command_id, command in self.data.get('commands', {}).items(): + print(f"stopping {command['runner']} {command_id}") + from qaboard.runners.job import JobGroup + # Default to something reasonnable, but it likely won't work out-of-the-box for all runners + if command['runner'] == "lsf": + bridge = os.environ.get("QA_RUNNERS_LSF_BRIDGE") + else: + bridge = None + jobs = JobGroup(job_options={ + "type": command['runner'], + "command_id": command_id, + **command, + "bridge": bridge, + }) + try: + jobs.stop() + except Exception as e: + print(e) + raise e + errors.append(str(e)) + continue + if errors: + return {"error": errors} + else: + for o in self.outputs: + if o.is_pending: + o.is_failed = True + o.is_running = False + o.is_pending = False + session.add(o) + session.commit() + return {} + + def delete(self, session, soft=False, only_failed=False, filter=None): + """ + Delete the batch and all related outputs. + By default it will be a "hard" delete where the metadata+files are deleted from the database/disk. + With soft deletes, only the files are deleted. + Note: You should call .stop() before. + """ + still_has_outputs = False + for output in self.outputs: + if only_failed and not output.is_failed: + still_has_outputs = True + continue + output.delete(soft=soft, filter=filter) + if not soft: + session.delete(output) + if not still_has_outputs and not soft: + session.delete(self) + session.commit() + + +# TODO: refactor with proper SQL, or use triggers to keep updated +def aggregated_metrics(outputs, metrics_to_aggregate): + if not metrics_to_aggregate: + return {} + + valid_outputs = [o for o in outputs if not o.is_failed and not o.is_pending] + aggregated = {} + for metric, treshold in metrics_to_aggregate.items(): + values = np.array([ + o.metrics[metric] for o in valid_outputs + if metric in o.metrics and not o.metrics[metric] is None + ]) + has_values = values.shape[0]>0 + try: + aggregated[f'{metric}_median'] = np.median(values) if has_values else np.NaN + aggregated[f'{metric}_average'] = np.average(values) if has_values else np.NaN + # aggregated[f'{metric}_pc_bad'] = np.mean(values < treshold) if has_values else np.NaN + except: + continue + # TODO: Use metric metadata to know if smaller_is_better, or pass the info in metrics_to_aggregate + # aggregated[f'{metric}_threshold_bad'] = treshold + # Remove NaN values + return {k: v for k, v in aggregated.items() if v == v} diff --git a/backend/backend/models/CiCommit.py b/backend/backend/models/CiCommit.py new file mode 100755 index 000000000..4e8efbe61 --- /dev/null +++ b/backend/backend/models/CiCommit.py @@ -0,0 +1,400 @@ +""" +A version of the code on which we ran SLAM performance test. +""" +import re +import json +import fnmatch +import subprocess +from pathlib import Path + +from requests.utils import quote +from sqlalchemy import Column, Boolean, Integer, String, DateTime, JSON, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import or_, UniqueConstraint, orm +from sqlalchemy.orm import relationship, reconstructor, joinedload +from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.attributes import flag_modified + +from qaboard.conventions import get_commit_dirs +from qaboard.api import dir_to_url + +from backend.models import Base, Batch, Output +from ..utils import get_avatar_url, get_github_avatar_url +from ..fs_utils import rm_empty_parents, rmtree +from ..git_utils import find_branch + + + +class CiCommit(Base): + """Refers to a git commit of the code + on which we ran some SLAM performance test (likely in the CI). + We keep some useful data in the database, but for the rest it used gitpython. + """ + __tablename__ = 'ci_commits' + id = Column(Integer(), primary_key=True) + hexsha = Column(String(), index=True, nullable=False) + project_id = Column(String(), ForeignKey('projects.id'), index=True) + + project = relationship("Project", back_populates="ci_commits") + __table_args__ = (UniqueConstraint('project_id', 'hexsha', name='_project_hexsha'),) + + data = Column(JSONB(), nullable=False, default=dict, server_default='{}') + + authored_datetime = Column(DateTime(timezone=True), index=True) + committer_name = Column(String(), index=True) + message = Column(String()) + # We use as branch the first branch that the commit was seen on, or the project's reference branch if it was used. + # TODO: we should also store the tags we witnessed the commit used with. + branch = Column(String(), index=True) # first added as.. we ignore tags? + # In the end there having a commit's parents is not all that useful for QA-Board: + # not all commits are used for runs: e.g. CI runs only on pushed commits, so + # that info is not enough to reconstruct the commit graph. + # Right now we don't display parents in the web application, so we also remove it from the API. + # Instead of JSON, we could use an Array of String instead, so that we can search for descendents. But do we really need it? + parents = Column(JSON()) + + commit_dir_override = Column(String()) + # Right now we don't really use this field, it's always "git". + # The client uses "local" in case there is no git info, but + # even then it doesn't send the information! + commit_type = Column(String(), default='git') + + batches = relationship("Batch", + back_populates="ci_commit", + cascade="all, delete-orphan", + order_by=Batch.created_date, + ) + + latest_output_datetime = Column(DateTime(timezone=True)) + deleted = Column(Boolean(), default=False) + + + @orm.reconstructor + def init_on_load(self): + if not self.data: + self.data = {} + + def get_or_create_batch(self, label): + matching_batches = [b for b in self.batches if b.label == label] + if matching_batches: return matching_batches[0] + return Batch(ci_commit=self, label=label) + + @property + def ci_batch(self): + return self.get_or_create_batch('default') + + + @property + def authored_date(self): + return self.authored_datetime.date() + + @property + def artifacts_dir(self) -> Path: + """Returns the folder in all the artifacts for this commit are stored.""" + if self.project.id_relative: + return self.repo_artifacts_dir / self.project.id_relative + else: + return self.repo_artifacts_dir + + @property + def repo_artifacts_dir(self) -> Path: + if self.commit_dir_override: + if self.project.id_relative: + repo_artifacts_dir = re.sub(f'{self.project.id_relative}$', '', self.commit_dir_override) + return Path(repo_artifacts_dir) + else: + return Path(self.commit_dir_override) + config = self.data.get('qatools_config', {}) + config_storage = self.project.storage_roots(config) + return config_storage['artifacts'] / get_commit_dirs(self) / config_storage["subproject"] + + @property + def artifacts_url(self) -> str: + return dir_to_url(self.artifacts_dir) + + @property + def repo_artifacts_url(self) -> str: + return dir_to_url(self.repo_artifacts_dir) + + + @property + def repo_outputs_dir(self): + config = self.data.get('qatools_config', {}) + return self.project.storage_roots(config)['outputs'] / get_commit_dirs(self) + + @property + def outputs_url(self) -> str: + return dir_to_url(self.outputs_dir) + + @property + def outputs_dir(self): + # output dirs are now always saved, so we only call this to get output locations with usual conventions + # e.g. when starting new tuning runs + if self.project.id_relative: + return self.repo_outputs_dir / self.project.id_relative + else: + return self.repo_outputs_dir + + + def __repr__(self): + branch = re.sub('origin/', '', self.branch) if self.branch else 'None' + return f"" + + + + def __init__(self, hexsha, *, project, branch=None, message=None, parents=None, authored_datetime=None, committer_name=None, commit_type='git'): + self.hexsha = hexsha + self.project = project + self.branch = branch + self.message = message if message else '' + self.parents = parents + self.authored_datetime = authored_datetime + self.committer_name = committer_name if committer_name else 'unknown' + self.commit_type = commit_type + self.latest_output_datetime = authored_datetime + self.data = {} + + + def save_artifacts(self): + # Restores the artifacts that are defined in the source code + # It won't restore binaries, users are expected to redo their CI on their own + import tempfile + import git + from ..git_utils import git_pull + # workaround for SIRC, trying to save artifacts will crash because the storage assumes a product name + if self.project.id_relative.endswith("tests/products"): + return + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + # if it fails in dev, chmod -R 777 /var/qaboard/git/CDE-Users/HW_ALG/.git + git_pull(self.project.repo) + self.project.repo.git.worktree("add", tmp_dir_path, self.hexsha) + # tmp_repo = git.Repo(tmp_dir_path) + command = ['qa', 'save-artifacts', '--out', str(self.repo_artifacts_dir)] + print(command) + print(tmp_dir_path / self.project.id_relative) + subprocess.run(command, cwd=tmp_dir_path / self.project.id_relative, check=True) + + def delete(self, ignore=None, keep=None, dryrun=False): + """ + Delete the commit's artifacts, and mark it as delete. + NOTE: We don't touch batches/outputs, you have to deal with them yourself. + See hard_delete() in api/webhooks.py and clean.py + """ + # print(self.artifacts_dir) + manifest_dir = self.artifacts_dir / 'manifests' + delete_errors = False + nb_manifests = 0 + nb_deleted = 0 + if manifest_dir.exists(): + for manifest in manifest_dir.iterdir(): + nb_manifests += 1 + if keep and manifest in keep: + continue + print(f' ...deleting artifacts: {manifest.name}') + has_error = False + try: + with manifest.open() as f: + files = json.load(f) + except: + delete_errors = True + continue + for file in files.keys(): + if keep and file in keep: + continue + if ignore: + if any([fnmatch.fnmatch(file, i) for i in ignore]): + continue + file_to_delete = self.repo_artifacts_dir / file + print(str(file_to_delete)) + # raise ValueError + if not dryrun: + try: + if file_to_delete.exists(): + rmtree(file_to_delete) + rm_empty_parents(file_to_delete) + nb_deleted += 1 + except: + has_error = True + print(f"WARNING: Could not remove: {file_to_delete}") + # raise ValueError + if not has_error: + try: # FIXME: umask 0 when writing the manifest file! + rmtree(manifest) + rm_empty_parents(manifest) + except: + pass + delete_errors = delete_errors or has_error + if not nb_manifests: + print(f"[{self.authored_datetime}] No artifact manifests found. Deleting everything in {self.artifacts_dir}") + nb_deleted = rmtree(self.artifacts_dir) + rm_empty_parents(self.artifacts_dir) + + if not delete_errors and nb_deleted: + self.deleted = True + + @staticmethod + def get_or_create(session, hexsha, project_id, data=None): + try: + ci_commit =(session.query(CiCommit) + .filter( + CiCommit.project_id==project_id, + CiCommit.hexsha.startswith(hexsha), + ) + .one()) + except NoResultFound: + # FIXME: if we have a short-hash, we should fail + try: + from backend.models import Project + project = Project.get_or_create(session=session, id=project_id) + if data and data.get('qaboard_config'): + is_initialization = not project.data or 'qatools_config' not in data + reference_branch = data["qaboard_config"]['project'].get('reference_branch', 'master') + is_reference = data.get("commit_branch") == reference_branch + if is_initialization or is_reference: + # FIXME: We put in Project.data.git the content of + # https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#push-events + # FIXME: We should really have Project.data.gitlab/github/... + if "git" not in project.data: + project.data["git"] = {} + if "path_with_namespace" not in project.data["git"] and "name" in data["qaboard_config"].get("project", {}): # FIXME: it really should be Project.root + project.data["git"]["path_with_namespace"] = data['project_root'] or data["qaboard_config"]["project"]["name"] + project.data.update({'qatools_config': data['qaboard_config']}) + if "qaboard_metrics" in data: + project.data.update({'qatools_metrics': data["qaboard_metrics"]}) + flag_modified(project, "data") + try: + git_parents = data["commit_parents"] + git_message = data["commit_message"] + git_committer_name = data["commit_committer_name"] + git_authored_datetime = data["commit_authored_datetime"] + git_branch = data["commit_branch"] + except Exception as e: + # If the project is connected to a git repo, we try to use it + # But it is not required... + git_commit = project.repo.commit(hexsha) + git_parents = [c.hexsha for c in git_commit.parents] + git_message = git_commit.message + git_committer_name = git_commit.committer.name + git_authored_datetime = git_commit.authored_datetime + # commits belong to many branches, so this is a guess + git_branch = find_branch(hexsha, project.repo) + ci_commit = CiCommit( + hexsha, + project=project, + commit_type='git', # we don't use anything else + parents=git_parents, + message=git_message, + committer_name=git_committer_name, + authored_datetime=git_authored_datetime, + branch=git_branch, + ) + if data and data.get('project_root'): + if not ci_commit.data: + ci_commit.data = {} + if not ci_commit.data.get('git'): + ci_commit.data['git'] = {} + ci_commit.data['git'].update({"path_with_namespace": data['project_root']}) + flag_modified(ci_commit, "data") + + if data: + if 'qaboard_config' in data: + ci_commit.data.update({'qatools_config': data['qaboard_config']}) + if "qaboard_metrics" in data: + ci_commit.data.update({'qatools_metrics': data['qaboard_metrics']}) + flag_modified(ci_commit, "data") + try: + session.add(ci_commit) + session.commit() + except IntegrityError: + # https://stackoverflow.com/questions/2546207/does-sqlalchemy-have-an-equivalent-of-djangos-get-or-create + session.rollback() + ci_commit =( + session.query(CiCommit) + .filter( + CiCommit.project_id==project_id, + CiCommit.hexsha.startswith(hexsha), + ) + .one() + ) + except ValueError: + error = f'[ERROR] ValueError: could not create a commit for {hexsha}' + print(error) + raise ValueError(error) + if not ci_commit.data: + ci_commit.data = {} + return ci_commit + + def _get_avatar_url(self): + """Return the avatar URL using the appropriate hosting provider.""" + hosting_type = self.project.data.get('git', {}).get('hosting_type', 'gitlab') + if hosting_type == 'github': + web_url = self.project.data.get('git', {}).get('web_url', '') + return get_github_avatar_url(self.committer_name, web_url) + return get_avatar_url(self.committer_name) + + def to_dict(self, db_session, with_aggregation=None, with_batches=None, with_outputs=False): + repo_artifacts_url = self.repo_artifacts_url + artifacts_url = self.artifacts_url + out = { + 'id': self.hexsha, + # 'type': self.commit_type, + 'branch': re.sub('origin/', '', self.branch), + # Not used anywhere in the web application, and it's not all that useful (see earlier comment) + # 'parents': [p for p in self.parents] if self.parents else [], + 'message': self.message, + 'committer_name': self.committer_name, + 'committer_avatar_url': self._get_avatar_url(), + 'authored_datetime': self.authored_datetime.isoformat(), + 'authored_date': self.authored_date.isoformat(), + 'latest_output_datetime': self.latest_output_datetime.isoformat() if self.latest_output_datetime else None, + 'deleted': self.deleted, + "data": self.data, + 'outputs_url': dir_to_url(self.outputs_dir), + 'artifacts_url': artifacts_url, + 'repo_artifacts_url': repo_artifacts_url, + 'commit_dir_url': artifacts_url, # backward compat for a while if projects using QA-Board rely on the API... + 'repo_commit_dir_url': repo_artifacts_url, # idem + 'batches': { + b.label: b.to_dict(db_session, with_outputs=with_outputs, with_aggregation=with_aggregation) + for b in self.batches + if not with_batches or b.label in with_batches + }, + } + if with_outputs: + out["data"] = self.data + return out + + + + +def latest_successful_commit(session, project_id, branch, batch_label=None, within_last=20): + """ + Returns the latest commit on a given branch where we got outputs. + Only the latest within_last commits are checked... + """ + ci_commits = (session + .query(CiCommit) + .options(joinedload(CiCommit.batches)) + .filter( + CiCommit.project_id==project_id, + or_( + # fallback to "any" commit with results + not branch, + # we try to be accomodating with the usual remote branch name + CiCommit.branch==branch, CiCommit.branch==f'origin/{branch}') + ) + .order_by(CiCommit.authored_datetime.desc()) + .limit(within_last) + ) + valid_outputs = lambda b: [o for o in b.outputs if not (o.is_failed or o.is_pending)] + for ci_commit in ci_commits: + if not batch_label: + if any([valid_outputs(b) for b in ci_commit.batches]): + return ci_commit + if batch_label: + if valid_outputs(ci_commit.get_or_create_batch(batch_label)): + return ci_commit + diff --git a/backend/backend/models/Output.py b/backend/backend/models/Output.py new file mode 100755 index 000000000..67f7cc97d --- /dev/null +++ b/backend/backend/models/Output.py @@ -0,0 +1,363 @@ +""" +Describes an Output from `qa run`. +""" +import os +import json +import uuid +import fnmatch +import datetime +import subprocess +from pathlib import Path + +from sqlalchemy import Column, ForeignKey, Index +from sqlalchemy.orm import relationship +from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound +from sqlalchemy import text, and_, Integer, String, Float, Boolean, DateTime, JSON +from sqlalchemy.dialects.postgresql import JSONB + +from qaboard.conventions import slugify, slugify_hash, make_hash, serialize_config +from qaboard.utils import save_outputs_manifest +from qaboard.api import dir_to_url + +from backend.models import Base +from backend.fs_utils import rm_empty_parents, rmtree + + + + +class Output(Base): + __tablename__ = 'outputs' + id = Column(Integer, primary_key=True) + + batch_id = Column(Integer(), ForeignKey('batches.id'), index=True) + batch = relationship("Batch", back_populates="outputs",) + created_date = Column(DateTime, default=datetime.datetime.utcnow, index=True) # TODO make it desc + # when we delete an output we still keep the metadata and manifest + # to *really* delete it, feel free to delete Output.output_dir and remove the row + deleted = Column(Boolean(), default=False) + + + #### Where results are stored (eg logs, images, 6dof, whatever) + # It is easier if there is a centralized way of storing results, but + # we let people override this to use disk with different quotas + # or even random folder (like for the CIS projects) + output_dir_override = Column(String(), index=True) + #### What we ran + # Different output types (slam/6dof, cis/siemens...) are visualized differently + output_type = Column(String()) + test_input_id = Column(Integer(), ForeignKey('test_inputs.id'), index=True) + test_input = relationship("TestInput", lazy='joined', back_populates="outputs") + + platform = Column(String()) + configurations = Column(JSONB(), nullable=False, default=list, server_default='[]') + extra_parameters = Column(JSONB(), nullable=False, default=dict, server_default='{}') + + # https://stackoverflow.com/questions/6626810/multiple-columns-index-when-using-the-declarative-orm-extension-of-sqlalchemy + # CREATE INDEX CONCURRENTLY idx_outputs_config_params ON outputs (batch_id, test_input_id, configurations, extra_parameters, platform) + # CREATE INDEX CONCURRENTLY idx_outputs_configurations ON outputs (configurations) + # CREATE INDEX CONCURRENTLY idx_outputs_extra_parameters ON outputs (extra_parameters) + __table_args__ = ( + # https://stackoverflow.com/questions/30885846/how-to-create-jsonb-index-using-gin-on-sqlalchemy + # https://www.postgresql.org/docs/8.3/indexes-opclass.html + # CREATE INDEX idx_outputs_data_user ON outputs((data -> 'user')); + # ProgrammingError: (psycopg2.errors.UndefinedObject) data type json has no default operator class for access method "btree" + # https://sqlalche.me/e/14/f405 + Index('idx_outputs_data_user', text("(data->>'user')")),#, postgresql_ops={'user': 'text_pattern_ops'}), + Index('idx_outputs_filter', "batch_id", "test_input_id", "platform"), + # we can't create an btree index on everything because JSON values can be big + # https://github.com/doorkeeper-gem/doorkeeper/wiki/How-to-fix-PostgreSQL-error-on-index-row-size + # https://dba.stackexchange.com/questions/162820/values-larger-than-1-3-of-a-buffer-page-cannot-be-indexed + # Index('idx_outputs_filter', "batch_id", "test_input_id", "configurations", "extra_parameters", "platform"), + # Note: we can't use ,postgresql_using='hash' for multiple columns + # Index('idx_outputs_configurations', "configurations", postgresql_using='hash'), + # Index('idx_outputs_extra_parameters', "extra_parameters", postgresql_using='hash'), + # but we could for single columns.. + ) + + #### How good we ran + is_pending = Column(Boolean(), default=False) + is_running = Column(Boolean(), default=False) # a running ouput is still pending... + is_failed = Column(Boolean(), default=False) + + metrics = Column(JSON(), nullable=False, default=dict, server_default='{}') + data = Column(JSON(), nullable=False, default=dict, server_default='{}') + + + def copy(self): + o = Output() + o.batch_id = self.batch_id + o.batch = self.batch + o.created_date = self.created_date + o.output_dir_override = self.output_dir_override + o.output_type = self.output_type + o.test_input_id = self.test_input_id + o.test_input = self.test_input + o.platform = self.platform + o.configurations = self.configurations + o.extra_parameters = self.extra_parameters + o.is_pending = self.is_pending + o.is_running = self.is_running + o.is_failed = self.is_failed + o.metrics = self.metrics + o.data = self.data + return o + + @property + def configuration(self): + return serialize_config(self.configurations) + + @property + def output_folder(self): + full_configurations = [self.platform] if self.platform != 'lsf' else [] + full_configurations.extend([*self.configurations, self.extra_parameters]) + return f'{slugify_hash(full_configurations, maxlength=16)}/{self.test_input.output_folder}' + + @property + def output_dir(self): + if self.output_dir_override is not None: + return Path(self.output_dir_override) + return self.batch.batch_dir / self.output_folder + + @property + def output_dir_url(self): + return dir_to_url(self.output_dir) + + def __repr__(self): + return (f"[Output " + f"ci_commit.hexsha='{self.batch.ci_commit.hexsha[:8]}' " + f"batch='{self.batch.label}' " + f"platform='{self.platform}' " + f"config='{self.configuration}' " + f"filename='{self.test_input.filename}' /]") + + def to_dict(self): + cols = [ + 'id', + 'output_type', + 'platform', + 'configurations', + 'extra_parameters', + 'metrics', + 'is_failed', + 'is_pending', + 'is_running', + 'data', + 'deleted', + ] + as_dict = {c: getattr(self, c) for c in cols} + return { + **as_dict, + 'created_date': self.created_date.isoformat(), + 'output_dir_url': self.output_dir_url, + 'test_input_database': str(self.test_input.database), + 'test_input_path': str(self.test_input.path), + 'test_input_metadata': self.test_input.data['metadata'] if (self.test_input.data and 'metadata' in self.test_input.data) else {}, + } + + @staticmethod + def get_or_create(session, **kwargs): + try: + return session.query(Output).filter( + and_( + Output.batch_id == kwargs['batch'].id, + Output.test_input_id == kwargs['test_input'].id, + Output.platform == kwargs['platform'], + Output.configurations == kwargs['configurations'], + Output.extra_parameters == kwargs['extra_parameters'], + ) + ).one() + except NoResultFound: + output = Output( + batch=kwargs['batch'], + test_input=kwargs['test_input'], + platform=kwargs['platform'], + configurations=kwargs['configurations'], + extra_parameters=kwargs['extra_parameters'], + ) + # session.add(output) + # session.commit() + return output + + except MultipleResultsFound: + print('WARNING: MultipleResultsFound') + # this should not happen. Quick and dirty fix: + output = session.query(Output).filter( + and_( + Output.batch_id == kwargs['batch'].id, + Output.test_input_id == kwargs['test_input'].id, + Output.platform == kwargs['platform'], + Output.configurations == kwargs['configurations'], + Output.extra_parameters == kwargs['extra_parameters'], + ) + ).delete() + output = Output( + batch=kwargs['batch'], + test_input=kwargs['test_input'], + platform=kwargs['platform'], + configurations=kwargs['configurations'], + extra_parameters=kwargs['extra_parameters'], + ) + session.add(output) + session.commit() + return output + + + def redo(self, command_id=None): + # in case it was deleted without QA-Board being made aware + if not self.batch.ci_commit.artifacts_dir.exists(): + print("Restoring artifacts") + self.batch.ci_commit.save_artifacts() + + if not command_id: + command_id = uuid.uuid4() + extra_parameters = json.dumps(self.extra_parameters, sort_keys=True) + job_options = self.data.get("job_options", {}) + job_options_cli = "" + if not job_options: + # for backward compatibility, it's a good defaut at SIRC + job_options_cli = " --lsf-max-memory 20000" + elif job_options['type'] == "lsf": + # TODO: support other runners... maybe create an ad-hoc functions in their classes... + if 'queue' in job_options: + job_options_cli += f" --lsf-queue '{job_options['queue']}'" + if 'max_memory' in job_options and job_options['max_memory'] != 0: + job_options_cli += f" --lsf-max-memory '{job_options['max_memory']}'" + if 'resources' in job_options and job_options['resources']: + job_options_cli += f" --lsf-resources '{job_options['resources']}'" + if 'max_threads' in job_options and job_options['max_threads'] != 0: + job_options_cli += f" --lsf-threads '{job_options['max_threads']}'" + command = ' '.join([ + 'qa', + f'--label "{self.batch.label}"', + f"--configuration '{self.configuration}'", + f"--database '{self.test_input.database}'", + f"--type '{self.output_type}'", + f"--tuning '{extra_parameters}'", + 'batch', + '--no-wait', + job_options_cli, + '--action-on-existing=run', + '--action-on-pending=run', + f'"{self.test_input.path}"', + # FIXME: if forwarded_args in parsed(self.configuration), add it.. + ]) + + user = self.data.get("user", "ispq") + outputs_dir_prefix = str(self.batch.ci_commit.outputs_dir).replace('/outputs/ispq/', f'/outputs/{user}/') + script = '\n'.join([ + '#!/bin/bash', + 'set -ex', + # needed... + f"export CI=true;", + f"export GIT_COMMIT='{self.batch.ci_commit.hexsha}';", + f"export QA_OUTPUTS_COMMIT='{outputs_dir_prefix}'", + # backward compatibility with previous qa versions, remove later... + f"export QATOOLS_CI_COMMIT_DIR='{outputs_dir_prefix}'", + f"export QABOARD_TUNING=true;", + f'export QA_BATCH_COMMAND_ID={command_id}', + "", + # get the env right + f'umask 0', + f'mkdir -p "{self.batch.ci_commit.artifacts_dir}"', + f'cd "{self.batch.ci_commit.artifacts_dir}"', + 'set +ex', + '[[ -f ".envrc" ]] && source .envrc', + '[[ -f "../.envrc" ]] && source ../.envrc', + '[[ -f "../../.envrc" ]] && source ../../.envrc', + '[[ -f "../../../.envrc" ]] && source ../../../.envrc', + 'set -ex', + command, + ]) + if not self.output_dir.exists(): + prev_mask = os.umask(000) + try: + self.output_dir.mkdir(parents=True) + except Exception as e: + os.umask(prev_mask) + raise e + os.umask(prev_mask) + logs_path = self.output_dir / 'log.txt' + script_path = self.output_dir / 'redo.sh' + with script_path.open('w') as f: + f.write(script) + print(f'"{script_path}"') + script_exec = "bash" if user == "ispq" else f'bsub_su {user} -I bash' + p = subprocess.run(f'ssh ispq@ispq-vdi \'{script_exec} "{script_path}"\' > "{logs_path}" 2>&1', shell=True) + success = p.returncode == 0 + return success + + + def delete(self, soft=True, ignore=None, filter=None, dryrun=False): + """ + Delete the output's output files. + It's soft by default, in that we still keep the metadata. + For a full hard delete, you'll also want to `session.delete(output)` + """ + output_dir = self.output_dir + if not output_dir.exists(): + self.deleted = True + print(f"WARN: already deleted: {output_dir}") + return + + if not soft: + print(output_dir) + rmtree(output_dir) + rm_empty_parents(output_dir) + else: + # If a run crashes, or in case of network issues, the manifests may not be updated... + manifest_path = output_dir / 'manifest.outputs.json' + if manifest_path.exists(): + try: + with manifest_path.open() as f: + files = json.load(f) + except Exception as e: + print(f"{e}: corrupted manifest {manifest_path}") + rmtree(output_dir) + rm_empty_parents(output_dir) + self.deleted = True + return + for file in files.keys(): + if file in ['manifest.outputs.json', 'manifest.inputs.json']: + continue + if ignore: + if any([fnmatch.fnmatch(file, i) for i in ignore]): + continue + if filter and not fnmatch.fnmatch(file, filter): + continue + output_file = output_dir / file + if not output_file.exists(): + continue + print(f'{output_file}') + if not dryrun: + rmtree(output_file) + rm_empty_parents(output_dir) + else: + self.delete(soft=False) + return + if not filter: # better not TODO: update .data.storage at least + self.deleted = True + + + def update_manifest(self, compute_hashes=True): + qatools_config = self.batch.ci_commit.project.data.get('qatools_config', {}) + os.umask(0) + return save_outputs_manifest(self.output_dir, config=qatools_config, compute_hashes=compute_hashes) + + def update_metrics(self, filepath=None): + """Updates the metrics from a file""" + if not filepath: + filepath = self.output_dir / 'metrics.json' + try: + with filepath.open() as f: + metrics = json.load(f) + is_serializable = lambda v: not v != v # avoid NaN values + metrics = {k:v for k, v in metrics.items() if is_serializable(v)} + setattr(self, 'metrics', metrics) + if 'is_failed' in metrics: setattr(self, 'is_failed', metrics['is_failed']) + except: + print(f'[WARNING] Output.update_metrics: failed to read {filepath}') + # we *could* return False then consider the run crashed if more than X time has passed... + # metrics = {'is_failed': True} + # metrics = {} + self.is_pending = False + self.is_running = False diff --git a/backend/backend/models/Project.py b/backend/backend/models/Project.py new file mode 100755 index 000000000..d39db1389 --- /dev/null +++ b/backend/backend/models/Project.py @@ -0,0 +1,281 @@ +""" +Describes a project +""" +import re +import sys +import json +import yaml +import traceback +from pathlib import Path +from functools import lru_cache + +from sqlalchemy.orm import relationship +from sqlalchemy import Column, ForeignKey +from sqlalchemy import String, DateTime +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import cast, type_coerce +from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm.attributes import flag_modified + +import qaboard +from qaboard.config import storage_roots + +# "from X import Y" can cause circular import errors.. +from backend.models import Base, CiCommit +# import backend.models as models +from backend import repos +from ..git_utils import git_pull +from ..config import default_outputs_root, default_artifacts_root + +class Project(Base): + __tablename__ = 'projects' + id = Column(String(), primary_key=True) + data = Column(JSONB(), nullable=False, default=dict, server_default='{}') + latest_output_datetime = Column(DateTime()) + + ci_commits = relationship("CiCommit", order_by=CiCommit.authored_datetime, back_populates="project") + + + def storage_roots(self, default_qaboard_config=None): + """ + The locations where we save outputs and artifacts for this project. + """ + id_git = self.id_git + qaboard_config = self.data.get('qatools_config', {}) + if not qaboard_config.get('storage'): + qaboard_config = default_qaboard_config + try: + outputs_root, artifacts_root, subproject_for_artifacts = storage_roots(qaboard_config, Path(self.id), Path(self.id_relative)) + except Exception as e: + print(e) + outputs_root = default_outputs_root + artifacts_root = default_artifacts_root + subproject_for_artifacts = Path() + return { + "outputs": outputs_root / id_git, + "artifacts": artifacts_root / id_git, + "subproject": subproject_for_artifacts, + } + + @property + def id_git(self) -> str: + """ + QA-Board can handle sub-projects. They share a git repo, but are based at different paths. + The `id_git` is the name of the repository. + """ + gitlab_id_git = self.data.get('git', {}).get('path_with_namespace', None) + if gitlab_id_git: # if the project is not linked to gitlab it will cause issues + return gitlab_id_git + else: + # we assume 2 hierarchical layer max.... FIXME: it should saved as a fixed property of the project! + try: + match = re.search('([^/]+/[^/]+).*', self.id) + return match.groups(0)[0] + except: + return self.id + + + @property + def id_relative(self) -> str: + """ + QA-Board can handle sub-projects. They share a git repo, but are based at different paths. + The `id_relative` is the project's directory, relative to the repository root. + """ + # FIXME: this could really by computed when the project is updated, or cached... + if self.id == self.id_git: + return '' + else: + return self.id.replace(self.id_git, '')[1:] + + + @property + def repo(self): + try: + return repos[self.id_git] + except Exception as e: + print(f"Could not get repo for <{self.id_git}>: {e}") + pass + return None + + + + @property + def protected_refs(self): + # git references that are explicitely protected from deletion in qaboard.yaml + project_config = self.data.get("qatools_config", {}).get("project", {}) + reference_branch = project_config.get("reference_branch", "master") + return [ + reference_branch, + *project_config.get("milestones", []), + ] + + @property + def milestone_commits(self): + # users can write commits as milestones... + def get_git_commit(repo, commit): + try: + return repo.commit(commit) + except: + return None + # we will save the latest commit on the protected branches + # FIXME: it requires git access for now, but we should query CiCommits.filter(CiCommits.branch==r) + protected_commit_milestones = [self.repo.commit(r).hexsha for r in self.protected_refs if get_git_commit(self.repo, r)] + # secho(f" protected commit milestones: {protected_commit_milestones}", dim=True) + + protected_refs = self.protected_refs + protected_refs = [*protected_refs, *[f'origin/{r}' for r in protected_refs]] + # secho(f" protected branches: {protected_refs}", dim=True) + + # commits store as "branch" the first branch they were seen with. So they are never listed with tags. + # we need to ask git for info on the milestones refs: what commit does it correspond to? + # FIXME: we should store tags too somehow! + repo_tags = {t.tag.tag for t in self.repo.tags if t.tag} if self.repo else set() + protected_tags_commits = [self.repo.tags[m].commit.hexsha for m in protected_refs if m in repo_tags] + # secho(f" protected commits from tags: {protected_tags_commits}", dim=True) + + # protect milestones defined via the web application + project_webapp_milestone_commits = [m['commit'] for m in self.data.get("milestones", {}).values()] + # secho(f" protected milestones: {project_webapp_milestone_commits}", dim=True) + return [*protected_commit_milestones, *protected_tags_commits, *project_webapp_milestone_commits] + + + @staticmethod + def get_or_create(session, **kwargs): + try: + project = session.query(Project).filter_by(**kwargs).one() + except NoResultFound: + project = Project(**kwargs) + if not project.data: + project.data = {} + return project + + def __repr__(self): + return f"" + + + + + +def is_relative_to(path : Path, path_maybe_parent : Path) -> bool: + try: + relative_path = path.relative_to(path_maybe_parent) + return True + except: + return False + + +def update_project_data(project, data, db_session): + project.data.update({'git': data['project']}) + db_session.add(project) + # https://stackoverflow.com/questions/30088089/sqlalchemy-json-typedecorator-not-saving-correctly-issues-with-session-commit + flag_modified(project, "data") + db_session.commit() + + + + +def update_project(data, db_session): + # TODO: refactor, call the logic in Commit.get_or_create + branch = data['ref'][11:] # data['ref'] => 'refs/heads/feature/Imu_preintegration' + commit_id = data['checkout_sha'] + if not commit_id: + return + + # Update the root project - all subprojects depend on it + root_project_id = data['project']['path_with_namespace'] # eg => dvs/psp_swip + root_project = Project.get_or_create(session=db_session, id=root_project_id) + update_project_data(root_project, data, db_session) + + hosting_type = data['project'].get('hosting_type') + web_url = data['project'].get('web_url') + try: + repo = repos.get(root_project_id, hosting_type=hosting_type, web_url=web_url) + git_pull(repo) + except: + print(f"Could not fetch the git info for {root_project_id}") + return + + @lru_cache(maxsize=128) + def parsed_content(commit_id, path): + """Read and parse a file in the git repository""" + try: + content = repo.git.show(f'{commit_id}:{path}') + if not content: + return None + if str(path).endswith('yaml'): + return yaml.load(content, Loader=yaml.SafeLoader) + elif str(path).endswith('json'): + return json.loads(content) + return yaml.load(content, Loader=yaml.SafeLoader) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + info = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + print(info, file=sys.stderr) + return None + + + # List all the files named "qaboard.yaml" in this commit + repo_files = repo.git.ls_tree('--name-only', '-r', commit_id).splitlines() + projects_config_paths = [Path(f) for f in repo_files if re.search(r'(/|^)qa(board|tools).yaml', f)] + for subproject_config_path in projects_config_paths: + project_id = str(root_project_id / subproject_config_path.parent) + # Make sure the it exists in the database, with up-to-date metadata + project = Project.get_or_create(session=db_session, id=project_id) + update_project_data(project, data, db_session) + # we don't want to create commits ahead of time anymore + # it started to cause issues with a huge monorepo with 100s of projects + # and doesn't bring much value to users + # try: + # ci_commit = CiCommit.get_or_create( + # session=db_session, + # hexsha=commit_id, + # project_id=project_id, + # data={"commit_branch": branch}, + # ) + # except Exception as e: + # exc_type, exc_value, exc_traceback = sys.exc_info() + # info = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + # print(info, file=sys.stderr) + # return f"404 ERROR: with commit id {commit_id} in project {project_id}: {info}", 404 + + # To update the (sub)project configuration stored in the database, + # we first need to read relevant qaboard.yaml files from this commit. + config_paths = [p for p in projects_config_paths if is_relative_to(subproject_config_path.parent, p.parent)] + config_paths.sort(key=lambda p: len(str(p))) + + qatools_config = {} + for config_path in config_paths: + config = parsed_content(commit_id, config_path) + qatools_config = qaboard.merge(config, qatools_config) + if "project" not in qatools_config: + qatools_config["project"] = {} + qatools_config['project']['name'] = project_id + + # # We store the QA-Board configuration twice: at the project level and at the commit level + # # - Commit-level info is important to let users easily tweak the outputs and metrics + # # they want to see when working on their branches + # ci_commit.data.update({'qatools_config': qatools_config}) + # - Project-level information is used as a default or when showing in the UI list of commits + # It is only updated when there are changes on the "reference branch" (eg master, develop...) + # This said, we also update project-level data when it's the first time we get a QA-Board config for a project + is_initialization = 'qatools_config' not in project.data + reference_branch = qatools_config['project'].get('reference_branch', 'master') + is_reference = branch == reference_branch + if is_initialization or is_reference: + project.data.update({'qatools_config': qatools_config,}) + flag_modified(project, "data") + + metrics_path = qatools_config.get('outputs', {}).get('metrics') + if metrics_path: + metrics = parsed_content(commit_id, metrics_path) + if metrics: + # ci_commit.data.update({'qatools_metrics': metrics}) + # flag_modified(ci_commit, "data") + if is_initialization or is_reference: + project.data.update({'qatools_metrics': metrics}) + flag_modified(project, "data") + + # print('project.data :', project.data) + # db_session.add(ci_commit) + db_session.add(project) + db_session.commit() diff --git a/qaboard-backend/slamvizapp/models/TestInput.py b/backend/backend/models/TestInput.py similarity index 68% rename from qaboard-backend/slamvizapp/models/TestInput.py rename to backend/backend/models/TestInput.py index 9044ad352..1df173bb0 100644 --- a/qaboard-backend/slamvizapp/models/TestInput.py +++ b/backend/backend/models/TestInput.py @@ -11,8 +11,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import IntegrityError -from slamvizapp.models import Base +from qaboard.conventions import slugify_hash +from backend.models import Base @@ -35,16 +37,22 @@ class TestInput(Base): @property def output_folder(self): """returns test path without any extension""" - return Path(self.path).with_suffix('') + input_dir = Path(self.path).with_suffix('') + if len(input_dir.as_posix()) > 70: + input_dir = Path(slugify_hash(input_dir.as_posix(), maxlength=70)) + return input_dir + @property def filename(self): """The path without .bin""" return self.path.split('/')[-1] - # misc data - data = Column(JSON(), default={}) + data = Column(JSON(), nullable=False, default=dict, server_default='{}') + @property + def abs_path(self): + return (self.database + "/" + self.path).replace("//", "/") def __init__(self, database, path): self.path = str(path) @@ -55,7 +63,7 @@ def __repr__(self): @staticmethod - def get_or_create(session, database, path): + def get_or_create(session, database, path, autocommit=False): try: test_input = (session .query(TestInput) @@ -64,8 +72,18 @@ def get_or_create(session, database, path): ) except NoResultFound: test_input = TestInput(database=str(database), path=str(path)) - # session.add(test_input) - # session.commit() + if autocommit: + try: + session.add(test_input) + session.commit() + except IntegrityError: + session.rollback() + test_input = (session + .query(TestInput) + .filter_by(database=str(database), path=str(path)) + .one() + ) + if not test_input.data: test_input.data = {} return test_input diff --git a/backend/backend/models/User.py b/backend/backend/models/User.py new file mode 100644 index 000000000..a4ee6acd0 --- /dev/null +++ b/backend/backend/models/User.py @@ -0,0 +1,91 @@ +import secrets +import datetime + +from flask_login import UserMixin +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship + +from backend.models import Base + + + +class User(Base, UserMixin): + __tablename__ = 'users' + + id = Column(Integer, primary_key=True) + created_date = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + + user_name = Column(String(), unique=True) + full_name = Column(String(), unique=False) + email = Column(String(), unique=True) + + password = Column(String()) + tokens = relationship("Token", back_populates="user") + + login_type = Column(String()) + data = Column(JSONB(), nullable=False, default=dict, server_default='{}') + + + def __repr__(self): + return (f" /home/arthurf/add_storage.log 2>&1 +Usage: +- docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --no-deps backend bash +- [arthurf] python backend/scripts/add_output_data_storage.py + +- run staging +- deloy new cli +- run on prod +- migrate json->jsonb +- sql! +""" +import os +import json +import time +import datetime + +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text +from qaboard.utils import total_storage, save_outputs_manifest, outputs_manifest + +from backend.models import Output +from backend.database import db_session, Session + +db_session.autoflush = False + +os.umask(0) + +batch_size = 1_000 +output_total = 1_700_000 # est. + + +start = time.time() + + +def get_storage(output): + manifest_path = output.output_dir / 'manifest.outputs.json' + if not output.output_dir.exists(): + return 0 + try: + with manifest_path.open() as f: + manifest = json.load(f) + except: # e.g. if the manifest does not exist, if it is corrupted... + manifest = outputs_manifest(output.output_dir) + try: + with manifest_path.open('w') as f: + json.dump(manifest, f, indent=2) + except Exception as e: + print(f"[WARNING] Could not write the manifest: {e}") + finally: + return total_storage(manifest) + + +def migrate(): + batch = [] + batch_size = 500 + output_total = db_session.query(Output).filter(text("(data->'storage') is null")).count() + start_batch = time.time() + print(f'Without storage {output_total}') + + outputs = (db_session.query(Output) + .filter(text("(data->'storage') is null")) + # .filter(Output.is_pending==False) + # .filter(text("outputs.created_date < now() - '15 days'::interval")) + .order_by(Output.created_date.desc()) + # .limit(10000) + # .yield_per(batch_size) + .enable_eagerloads(False) + ) + updated = 0 + now = time.time() + for idx, o in enumerate(outputs): + # print(o) + if o.data is None: + o.data = {} + # if '/' in o.batch.ci_commit.hexsha: + # continue + + try: + print(o.output_dir) + if "C:\\" in str(o.output_dir): + o.output_dir_override = o.output_dir_override.replace("\\", "/").replace("C:/netapp/algo_data", "/stage/algo_data") + if not o.output_dir_override.startswith("/stage/algo_data"): + continue + else: + db_session.add(o) + db_session.commit() + storage = get_storage(o) + print(f" Storage: {storage/1024/1024:.2f}MB at {o.output_dir}") + except Exception as e: + print("[Error-Skipping]", o, e) + continue + if storage is None: + continue + updated += 1 + batch.append({ + "id": o.id, + "data": { + **o.data, + "storage": storage, + }, + }) + if idx and idx % batch_size == 0: + print(o) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + batch = [] + # break + + print(f"DONE, now committing storage [elapsed time: {now - start:.1f}s]") + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + db_session.commit() + return updated + + + +if __name__ == '__main__': + print('Adding storage info') + while migrate(): + print('Still results to update...') + exit(0) + + + print("Starting migration") + def query(since): + print(since) + return (db_session.query(Output) + .enable_eagerloads(False) + .filter(Output.created_date > since) + .order_by(Output.created_date.desc()) + # .limit(batch_size) + # .all() + # .yield_per(batch_size) + ) + + print("Starting updates") + most_recent = datetime.datetime.now() - datetime.timedelta(days=7) + for idx, output in enumerate(query(most_recent)): + if output.is_pending: + continue + if output.data.get('storage'): + continue + + manifest_path = output.output_dir / 'manifest.outputs.json' + if not manifest_path.exists(): + if not output.output_dir.exists(): + output.data['storage'] = 0 + db_session.add(output) + flag_modified(output, "data") + continue + else: + manifest = save_outputs_manifest(output.output_dir) + else: + with manifest_path.open() as f: + manifest = json.load(f) + manifest_path.chmod(0o777) + + output.data['storage'] = total_storage(manifest) + flag_modified(output, "data") + db_session.add(output) + + if idx % batch_size == 0: + print(f"{idx/output_total:.1%}", output, f"{output.data['storage']/1024/1024:.01f} MB") + + + db_session.commit() diff --git a/backend/backend/scripts/add_output_data_user.py b/backend/backend/scripts/add_output_data_user.py new file mode 100644 index 000000000..815042b71 --- /dev/null +++ b/backend/backend/scripts/add_output_data_user.py @@ -0,0 +1,107 @@ +""" +python add_storage_info.py + +Usage: +- docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --no-deps backend bash +- [arthurf] python backend/scripts/add_output_data_storage.py + +- run staging +- deloy new cli +- run on prod +- migrate json->jsonb +- sql! +""" +import json +import time +import datetime + +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text +from qaboard.utils import total_storage, save_outputs_manifest, outputs_manifest + +from backend.models import Output +from backend.database import db_session, Session + +db_session.autoflush = False + + +db_session +batch_size = 1_000 + + +start = time.time() + + +def get_user(output): + if output.output_dir.exists(): + return output.output_dir.owner() + # TODO: fix the changed location with (output.output_dir.parent / input_path.name) + # if output.batch.ci_commit.artifacts_dir.exists(): + # return output.batch.ci_commit.artifacts_dir.owner() + # TODO: 1. batch command 2. committer + return None + +def migrate(): + batch = [] + batch_size = 2_000 + # batch_size = 70000 + output_total = db_session.query(Output).filter(text("(data->'user') is null")).count() + start_batch = time.time() + print(f'Without username {output_total}') + + outputs = (db_session.query(Output) + .filter(text("(data->'user') is null")) + .order_by(Output.created_date.desc()) + .enable_eagerloads(False) + .limit(10000) + ) + updated = 0 + now = time.time() + for idx, o in enumerate(outputs): + # print(o) + if o.data is None: + o.data = {} + # if '/' in o.batch.ci_commit.hexsha: + # continue + + try: + print(o.output_dir) + user = get_user(o) + print(f" User: {user}") + # exit(0) + except Exception as e: + print("[Error-Skipping]", o, e) + continue + if not user: + continue + updated += 1 + batch.append({ + "id": o.id, + "data": { + **o.data, + "user": user, + }, + }) + if idx and idx % batch_size == 0: + print(o) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + batch = [] + # break + + print(f"DONE, now committing users [elapsed time: {now - start:.1f}s]") + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + db_session.commit() + return updated + + + +if __name__ == '__main__': + print('Adding user info') + while migrate(): + print('Still work to do...') + exit(0) diff --git a/backend/backend/scripts/debug_missing_storage.py b/backend/backend/scripts/debug_missing_storage.py new file mode 100644 index 000000000..dfbf71e79 --- /dev/null +++ b/backend/backend/scripts/debug_missing_storage.py @@ -0,0 +1,113 @@ +import json +from datetime import datetime +from pathlib import Path + +from backend.models import Output +from backend.database import db_session, Session + +user = 'itamarp' +user = 'omera' + +# find /algo/HM6/outputs -user itamarp > itamarp.txt +owned_files_path = Path(f'/home/arthurf/qaboard/{user}.txt') +owned_files_info_path = owned_files_path.with_suffix('.info.txt') + +# NFS used 65536 blocks, but empty blocks don't count in the quotas +# so we can use the underlying FS block size +f_frsize = 4096 + +def update(): + nb_folders = 0 + nb_files = 0 + total_size = 0 + total_storage = 0 + files = [] + for line in owned_files_path.read_text().splitlines(): + nb_files += 1 + path = Path(line.strip()) + if not path.exists(): + print("missing", path) + continue + if path.is_dir(): + nb_folders += 1 + continue + size = path.stat().st_size + total_size += size + storage = (size // f_frsize) * f_frsize + f_frsize if size % f_frsize != 0 else 0 + total_storage += storage + files.append((str(path), size, storage)) + print(f"total_size {total_size/1024/1024:.3f}MB ({total_size})") + print(f"storage {total_storage/1024/1024:.3f}MB ({total_storage})") + print("nb_folders", nb_folders) + print("nb_files", nb_files) + total_size += nb_folders * 8 * 1024 + total_storage += nb_folders * 8 * 1024 + # total_storage += (nb_files - nb_folders) * 4096 + # inode? + print(f"total_size {total_size/1024/1024:.3f}MB ({total_size})") + print(f"storage {total_storage/1024/1024:.3f}MB ({total_storage})") + with owned_files_info_path.open('w') as f: + json.dump(files, f) + +refresh = False +# refresh = True +if not owned_files_info_path.exists() or refresh: + update() +with owned_files_info_path.open() as f: + files = json.load(f) +files_info = [(Path(path), size, storage) for path, size, storage in files] + +def filter_files(filter): + import re + files = [(path, size, storage) for path, size, storage in files_info if re.search(filter, str(path))] + total_storage = 0 + # files.sort(key=lambda x: -x[1]) + for path, size, storage in files[:50]: + print(f"{storage/1024/1024:.2f}MB", path) + total_storage += storage + print(f"filtered {filter} storage {total_storage/1024/1024:.3f}MB ({total_storage})") +# filter_files('(log(.lsf)?.txt|manifest)') +# filter_files('/share/') # 12G +# exit(0) + +# todo: check for files 1 parent is a dir with manifest.outputs.json + + +files = [path for path, size, storage in files_info] +# the manifest output files may end up owned by arthurf +output_dirs = set([path.parent for path in files if path.name == 'run.json']) + +def is_in_output_dirs(path): + return any([parent in output_dirs for parent in path.parents]) + +# for file in files: +# if file.is_file() and not is_in_output_dirs(file): +# # when tuning those files are created +# is_batch_related = file.name in ['start.sh', 'log.txt', 'qa_batch.sh'] +# is_from_export = '/share/' in str(file) +# if not is_batch_related and not is_from_export: +# print(file) +# exit(0) + + +print(len(output_dirs), "output directories") +max_ctime = 0 +output_dirs_missing = [] +for output_dir in output_dirs: + # find all run.json, check parentin + output = db_session.query(Output).filter(Output.output_dir_override==str(output_dir)).one_or_none() + if not output: + output_dirs_missing.append(output_dir) + ctime = output_dir.stat().st_ctime + print(datetime.fromtimestamp(ctime)) + if ctime > max_ctime: + max_ctime = ctime + print(output_dir) + # exit(0) + + +print(len(output_dirs), "output directories on disk") +print(len(output_dirs_missing), "missing in QA-Board - seems fine to delete") +print(max_ctime, "latest") +print(datetime.datetime.fromtimestamp(max_ctime)) +# TODO: check in DB wtf where is it? like dir ... \ No newline at end of file diff --git a/backend/backend/scripts/delete_orphan_output_dir.py b/backend/backend/scripts/delete_orphan_output_dir.py new file mode 100644 index 000000000..4fd18527e --- /dev/null +++ b/backend/backend/scripts/delete_orphan_output_dir.py @@ -0,0 +1,65 @@ +""" +docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --user=root --rm --no-deps -v /home/arthurf/qaboard/services/backend/passwd:/etc/passwd -e QABOARD_DATA_GIT_DIR=/home/arthurf -e MIGRATION_PROJECT backend python /qaboard/backend/backend/scripts/delete_orphan_output_dir.py +""" +import os +import sys +import shutil +from pathlib import Path + +from backend.models import Output +from backend.database import db_session, Session +from backend.fs_utils import as_user, rm_empty_parents, rmtree + + + + +root = Path("/algo/HP1/outputs") +# root = Path("/algo/HM6/outputs/omera/CDE-Users/HW_ALG/81/96978392ec9c86/CIS/tests/products/HM6/output") +root = Path("/algo/CIS/outputs/sircdevops") + +# output_dir_file = "run.json" +# output_dir_file = "kiwi_log.txt" +output_dir_file = "manifest.outputs.json" + +def delete(output_dir: Path): + for path in output_dir.iterdir(): + if path.name == output_dir_file: + continue + owner = path.owner() + def _delete(path): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + try: + _delete(path) + except: + as_user(owner, _delete, path) + as_user((output_dir / output_dir_file).owner(), lambda p: p.unlink(), output_dir / output_dir_file) + # it causes the glob to fail... + # rm_empty_parents(path) + +directories = list(root.rglob(output_dir_file)) +print(f"{len(directories)} directories will be checked") +errors = [] +for run_json in directories: + output_dir = run_json.parent + output = None + try: + output = db_session.query(Output).filter(Output.output_dir_override==str(output_dir)).one_or_none() + except Exception as e: + print("ERROR:", output_dir) + print(e) + if not output: + print(f"? {output_dir}") + try: + rmtree(output_dir) + except: + errors.append(output_dir) +for error in errors: + print(error) + +# in algo/hm6/omera still more that what should be... +# find /algo/HM6/outputs -name omera > omera.txt + +# ? data folder / version \ No newline at end of file diff --git a/backend/backend/scripts/delete_remaining_data_from_deleted_outputs.py b/backend/backend/scripts/delete_remaining_data_from_deleted_outputs.py new file mode 100644 index 000000000..9f0373bb7 --- /dev/null +++ b/backend/backend/scripts/delete_remaining_data_from_deleted_outputs.py @@ -0,0 +1,83 @@ +""" +cd qaboard +docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --rm --user=root --no-deps -v /home/arthurf/qaboard/services/backend/passwd:/etc/passwd backend python /qaboard/backend/backend/scripts/delete_remaining_data_from_deleted_outputs.py +""" +import sys +import time +import shutil +import traceback + +import click + +from backend.models import Output +from backend.database import db_session, Session + +from migration_utils import get_username +from backend.fs_utils import as_user +from migration_utils import rm_files_not_listed_in_manifests + +dryrun = '--dry-run' in sys.argv +# Progress will be printed every batch/100 +batch_size = 10_000 +start = time.time() + + +def check_output(output): + try: + output_dir_exists = output.output_dir.exists() + except: + output_dir_exists = False + if output_dir_exists: + print(f"[{output.id}]", output.output_dir) + try: + output.delete(soft=False) + except: + delete = lambda o: o.delete(soft=False) + owner = output.output_dir.owner() + as_user(owner, delete, output) + + + +def run_delete(min_id): + deleted_outputs = (db_session + .query(Output) + .filter(Output.deleted==True) + ) + if min_id: + deleted_outputs = deleted_outputs.filter(Output.id >= min_id) + deleted_outputs = (deleted_outputs + .order_by(Output.created_date.asc()) + .enable_eagerloads(False) + ) + output_total = deleted_outputs.count() + click.secho(f'- outputs to check: {output_total}', bold=True, fg='blue') + should_continue = output_total > batch_size + + start_batch = time.time() + updated = 0 + now = time.time() + + for idx, o in enumerate(deleted_outputs.limit(batch_size)): + # o, batch, commit = result + check_output(o) + if idx and idx % batch_size/100 == 0: + print(o) + print(o.batch.ci_commit) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + return updated, o.id, should_continue + + +def main(): + # Optionnally, you can give an id to start from... + last_id = 3457400 # None + + should_continue = True + while should_continue: + click.secho('Deleting...', bold=True, fg='blue') + nb_updated, last_id, should_continue = run_delete(last_id) + click.secho(f"nb_updated={nb_updated}, last_id={last_id}, should_continue={should_continue}", fg='blue') + click.secho('DONE', fg='green') + +main() diff --git a/backend/backend/scripts/fix-nobody-manifest.py b/backend/backend/scripts/fix-nobody-manifest.py new file mode 100644 index 000000000..586731ef9 --- /dev/null +++ b/backend/backend/scripts/fix-nobody-manifest.py @@ -0,0 +1,77 @@ +""" +Usage: +- docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --no-deps backend bash +- [arthurf] python backend/scripts/fix-nobody-manifest.py +""" +import os +import json +import time +import datetime +from pathlib import Path + +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text +from qaboard.utils import total_storage, save_outputs_manifest, outputs_manifest + +from backend.models import Output +from backend.database import db_session, Session + +db_session.autoflush = False + +os.umask(0) + + + +def get_storage(output): + manifest_path = output.output_dir / 'manifest.outputs.json' + if not output.output_dir.exists(): + return 0 + try: + with manifest_path.open() as f: + manifest = json.load(f) + except: # e.g. if the manifest does not exist, if it is corrupted... + manifest = outputs_manifest(output.output_dir) + try: + with manifest_path.open('w') as f: + json.dump(manifest, f, indent=2) + except Exception as e: + print(f"[WARNING] Could not write the manifest: {e}") + finally: + return total_storage(manifest) + + +def migrate(): + outputs = (db_session.query(Output) + .filter(text("outputs.created_date > now() - '2 days'::interval")) + .order_by(Output.created_date.desc()) + .enable_eagerloads(False) + ) + updated = 0 + now = time.time() + for idx, o in enumerate(outputs): + # try: + manifest_path = o.output_dir / 'manifest.outputs.json' + if 'C:\\' in str(manifest_path): + continue + if not manifest_path.exists() or manifest_path.owner() == 'nobody' or manifest_path.owner() == 'arthurf': + print(o.id, o.output_dir) + if manifest_path.exists(): + manifest_path.unlink() + storage = get_storage(o) + o.data['storage'] = storage + db_session.add(o) + flag_modified(o, "data") + db_session.commit() + # except Exception as e: + # print("[Error-Skipping]", o, e) + # continue + + + return updated + + +if __name__ == '__main__': + while migrate(): + print('Still results to update...') + + diff --git a/backend/backend/scripts/fix_bad_output_dir_name.py b/backend/backend/scripts/fix_bad_output_dir_name.py new file mode 100644 index 000000000..8a8dd1240 --- /dev/null +++ b/backend/backend/scripts/fix_bad_output_dir_name.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +""" +Fix stuff. +docker compose -f docker-compose.yml -f development.yml -f sirc.yml run --rm --no-deps -v /home/arthurf/qaboard/services/backend/passwd:/etc/passwd -e QABOARD_DATA_GIT_DIR=/home/arthurf backend python /qaboard/backend/backend/scripts/fix_bad_output_dir_name.py +""" +import os +import sys +import time +import shutil +import traceback +from pathlib import Path + +import click +import requests +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text + +from backend.models import Project, CiCommit, Batch, Output +from backend.database import db_session, Session + +from migration_utils import get_username + + +# Progress will be printed every batch/100 +batch_size = 10_000 +start = time.time() + + +# TODO +# ? go over all deleted outputs +# make sure deleted, delete parents + + +def migrate_output(output): + if output.output_dir.exists(): + return + + tentative_output_dir = output.output_dir.parent / Path(output.test_input.path).with_suffix('').name + if not tentative_output_dir.exists(): + click.secho(f" ? {output.output_dir_override}") + click.secho(f" {output}") + click.secho(f" {output.test_input}") + return + + output.output_dir_override = str(tentative_output_dir) + output.data["user"] = tentative_output_dir.owner() + click.secho(f" ✔ {output.data['user']} {output.output_dir_override}", fg='green') + # exit(0) + + + flag_modified(output, "data") + db_session.add(output) + db_session.commit() + # click.secho(" 🆗", fg='green') + # exit(0) + + +def migrate(min_id): + # it's an aweful join... + all_outputs = (db_session + .query(Output) + ) + if min_id: + all_outputs = all_outputs.filter(Output.id >= min_id) + outputs = (all_outputs + # .filter(text("(outputs.data->'migrated') is null")) + .filter(text("outputs.output_dir_override is not null")) + .filter(text("outputs.output_dir_override like '/algo%'")) + # .filter(text("outputs.created_date < now() - '15 days'::interval")) + # .filter(text("output_dir_override like '/stage/algo_data/ci/CDE-Users/HW_ALG/%/CIS/output%'")) + # .filter(Output.is_pending==False) + # .filter(Output.deleted==False) + .order_by(Output.created_date.asc()) + .enable_eagerloads(False) + ) + click.secho(f'- outputs total: {all_outputs.count()}', bold=True, fg='blue') + output_total = outputs.count() + click.secho(f'- outputs to migrate: {output_total}', bold=True, fg='blue') + should_continue = output_total > batch_size + start_batch = time.time() + updated = 0 + now = time.time() + + if not output_total: + for result in all_outputs.limit(1): + o = result + # o, batch, commit = result + print(o) + print(o.output_dir) + return updated, None, should_continue + + for idx, result in enumerate(outputs.limit(batch_size)): + o = result + # print(o, batch, commit) + if o.data is None: + o.data = {} + ## legacy outputs from the previous CI + # if '/' in o.batch.ci_commit.hexsha: + # continue + + migrate_output(o) + if idx and idx % batch_size/100 == 0: + print(o) + print(o.batch.ci_commit) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + return updated, o.id, should_continue + + +def main(): + # Optionnally, you can give an id to start from... + # it helps if there are many non-migrated runs that fail because of whatever and + # you don't want to wait until the migration fails to migrate them again! + last_id = None #1245250 # None + + should_continue = True + while should_continue: + click.secho('Migrating...', bold=True, fg='blue') + nb_updated, last_id, should_continue = migrate(last_id) + click.secho(f"nb_updated={nb_updated}, last_id={last_id}, should_continue={should_continue}", fg='blue') + click.secho('DONE', fg='green') + +main() diff --git a/backend/backend/scripts/gen_parallel_migration.py b/backend/backend/scripts/gen_parallel_migration.py new file mode 100755 index 000000000..869a08d93 --- /dev/null +++ b/backend/backend/scripts/gen_parallel_migration.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +""" +Usage: + cd qaboard + ./backend/backend/scripts/gen_parallel_migration.py CDE-Users/HW_ALG/CIS 10 + ./backend/backend/scripts/gen_parallel_migration.py --all 10 + bash migration/start.sh + +Needs a server to sync user quotas.. +- python backend/backend/scripts/user_storage_server.py +- update the server location in migrate.py with QUOTA_SERVER + +Note: +- Best not run this on the production server, just in case +""" +import os +import sys +from pathlib import Path + + +def gen_script(project, parallel_jobs, start=False): + yamls = "-f docker-compose.yml -f development.yml -f sirc.yml" + start_script = "" + migration_dir = Path('migration') + migration_dir.mkdir(exist_ok=True) + + for i in range(parallel_jobs): + project_name = project.split('/')[-1] + logs = migration_dir / f"{project_name}-{i:02}.txt" + script = migration_dir / f"{project_name}-{i:02}.sh" + with script.open('w') as f: + f.write(f"""#!/bin/bash +set -x +export MIGRATION_PROJECT={project} +export MIGRATION_JOBS={parallel_jobs} +export MIGRATION_INDEX={i} + +# Avoid "Too many levels of symbolic links" errors +./at-sirc-before-up.py --shallow > /dev/null + +docker compose {yamls} pull backend +docker compose {yamls} run --rm --user=root --no-deps -v /home/arthurf/qaboard/services/backend/passwd:/etc/passwd -e QABOARD_DATA_GIT_DIR=/home/arthurf -e MIGRATION_PROJECT -e MIGRATION_INDEX -e MIGRATION_JOBS backend /qaboard/backend/backend/scripts/migrate.py +# docker compose {yamls} down -v +""") + command = f" bsub -q alg_isp_q -P migration -o {logs} bash {script}" + start_script += f"{command}\n" + + start_script_path = migration_dir / f"{project_name}-start.sh" + with start_script_path.open('w') as f: + f.write(start_script) + start_command = f"bash {start_script_path}" + print("# To start the migration, run:") + print(start_command) + os.system(start_command) + + +if len(sys.argv) < 2: + print("ERROR: Usage is gen_parallel_migration.py $project [nb_parallel_jobs=1]") + sys.exit(1) +parallel_jobs = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + +project = sys.argv[1] +if project != "--all": + gen_script(project, parallel_jobs=parallel_jobs) +else: + # import requests + # projects = requests.get("https://qa/api/v1/projects", verify=False).json() + projects = [ + # "CDE-Users/HW_ALG/CIS/tests/products/HM2", 8T 38k . + # "CDE-Users/HW_ALG/CIS/tests/products/HM3", 18T 126k + # "CDE-Users/HW_ALG/CIS/tests/products/HP1", 13T 57k + # "CDE-Users/HW_ALG/PSP_2x", 3.8T 270k . + # "CDE-Users/HW_ALG/PSP_2x/tests/products/ASPv43", 1T 20k . + # "LSC/Calibration", + # "tof/swip_tof", + # "tof/python_isp_chain", + # "dvs/SIRC-VINS", + ] + for project in projects: + if "HW_ALG" in project: + print(f"Migrating {project}") + gen_script(project, parallel_jobs=parallel_jobs, start=True) diff --git a/qaboard-backend/slamvizapp/scripts/init_database.py b/backend/backend/scripts/init_database.py similarity index 82% rename from qaboard-backend/slamvizapp/scripts/init_database.py rename to backend/backend/scripts/init_database.py index e74e98f78..bd98e9ba6 100755 --- a/qaboard-backend/slamvizapp/scripts/init_database.py +++ b/backend/backend/scripts/init_database.py @@ -9,8 +9,9 @@ from alembic.config import Config from alembic import command -import slamvizapp -from slamvizapp.database import engine, Session +import backend +from backend.database import engine, Session, Base + @click.command() @@ -32,8 +33,8 @@ def stamp_schema_version(): """Write the schema version stamp to the database, in case it is missing.""" with engine.begin() as connection: alembic_cfg = Config() - alembic_cfg.set_main_option("script_location", "slamvizapp:alembic") - path = Path(slamvizapp.__file__).parent / 'alembic.ini' + alembic_cfg.set_main_option("script_location", "backend:alembic") + path = Path(backend.__file__).parent / 'alembic.ini' alembic_cfg.config_file_name = str(path) alembic_cfg.attributes['connection'] = connection command.stamp(alembic_cfg, "head") diff --git a/backend/backend/scripts/migrate.py b/backend/backend/scripts/migrate.py new file mode 100755 index 000000000..f690fb171 --- /dev/null +++ b/backend/backend/scripts/migrate.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python +""" +Migrates a projects' outputs to their default location. + +Usage: +0. Clean old results.. + ssh qa + docker compose -f docker-compose.yml -f development.yml -f sirc.yml exec -T backend qaboard_clean --project 'CDE-Users/HW_ALG/CIS$' --before 3months --can-delete-reference-branch # --can-delete-artifacts +1. Save the remote user list + $ getent --service=sss passwd >> ./services/backend/passwd + Manually remove those entries and redo if needed... +2. Needs a server to sync user quotas.. + $ python backend/backend/scripts/user_storage_server.py + # update the server location in migrate.py with QUOTA_SERVER +3. You'll run the gen_parallel_migration.py in the end... + +-- how much left to migrate? +SQL STORAGE +select + projects.id, + to_char(sum((outputs.data->>'storage')::text::numeric/1000000000), '9999999999999.99') as storage_GB, + count(*) as nb +from outputs + left join batches on batches.id=outputs.batch_id + left join ci_commits on ci_commits.id=batches.ci_commit_id + left join projects on projects.id=ci_commits.project_id +where + -- outputs.data->'migrated' is null + "output_dir_override" is not null + -- and outputs.created_date < now() - '14 days'::interval + -- and outputs.deleted=false + and ("output_dir_override" not like '/algo%') + -- and ("output_dir_override" like '/stage/algo_data/ci/CDE-Users/HW_ALG/%/KITT_ISP/output%') + -- and ("output_dir_override" like '/stage/algo_data/ci/CDE-Users/HW_ALG/%/KITT_ISP/output%' or "output_dir_override" like '/algo/KITT_ISP%') + -- and projects.data->>'legacy' is null + and (projects.id like '%HW%' or projects.id like '%dvs%' or projects.id like '%tof%') +group by projects.id; +--limit 10; + + +-- 2 264 674 at 23:30 +-- 2 050 619 at 09:08 + + + + + + +select to_char(sum((data->>'storage')::text::numeric/1000000000), '9.9') as storage from outputs where ("output_dir_override" like '/stage/algo_data/ci/CDE-Users/HW_ALG/%/CIS/output%') limit 10; + +-- wtf... +select count(*) from outputs where "output_dir_override" is NULL limit 10; +select * from outputs where "output_dir_override" is NULL and deleted=false order by created_date desc limit 100; + + +Usage: + migrate.py PROJECT [--dry-run] + e.g. + migrate.py CDE-Users/HW_ALG/CIS --dry-run + + +Note: +- Best not run this on the production server, just in case +""" +import os +import re +import sys +import time +import random +import shutil +import traceback +from pathlib import Path +from add_output_data_storage import get_storage + +import click +import requests +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text + +from backend.models import Project, CiCommit, Batch, Output +from backend.database import db_session, Session +from backend.fs_utils import as_user, rm_empty_parents + +from migration_utils import get_username +from migration_utils import rm_files_not_listed_in_manifests + +# TODO +# - [TODO CHECK tmux alginfra1] remove old deleted stuff with delete_remaining_data_from_deleted_runs.py +# - [TODO] ./backend/backend/scripts/gen_parallel_migration.py --all 15 +# - [TODO] rg Missing migration +# - [TODO] add missing users in migration_utils.py +# - [TODO] ./backend/backend/scripts/gen_parallel_migration.py --all 15 +# - [TODO] add missing users in migration_utils.py +# - [TODO] check in SQL all is done! +# - remove deleted=False filter in the delete, check if there is data, if yes remove completely the folder and parents... +# /algo/CIS/outputs/nimrodn/CDE-Users/HW_ALG/ed/1f2abce82c7bb2/CIS/output/linux/RV1/tv/tv_RV1_512x256_REMOSAIC_FULL_V1 + + +# Some output directories depend on the current user. +# We'll switch user a lot, so we don't want to cache the current user. +os.environ['QABOARD_NO_CACHE_USER'] = "YES" + +# At SIRC we cannot be root on the shared storage, so +# we need to su the proper users in order to migrate their runs. +# This flag controls the extra work to get it right +# at_sirc = False +at_sirc = True + + +project = os.environ["MIGRATION_PROJECT"] if "MIGRATION_PROJECT" in os.environ else sys.argv[1] +project_name = project.split('/')[-1] +if "ALG_GEN" in project: + project_name = "CIS" +if "tof/" in project: + project_name = "TOF" +dryrun = '--dry-run' in sys.argv + +# Progress will be printed every batch/100 +batch_size = 10_000 +start = time.time() + +# We parallelize the migration, each worker handles a given idx modulo parts_nbr +parts_nbr = int(os.environ.get('MIGRATION_JOBS', 0)) +parts_idx = int(os.environ.get('MIGRATION_INDEX', 0)) +if parts_nbr: + click.secho(f"[Migration Job {parts_idx+1}/{parts_nbr}]", bold=True) + +# Everything is simpler if all folders/files are writable by everyone +# We don't have any trust issue, and everything is backed-up, so no need to bother +# Otherwise down the line it's yet more trouble +os.umask(0) + + +quota_server = os.environ.get('QUOTA_SERVER', "http://qa:3001") +def fetch_quota(user, project): + r = requests.get(f"{quota_server}/user/{user}/storage/project/{project_name}?proxies=cache-buster") + try: + return r.json() + except Exception as e: + print(r.text) + raise e + +def add_storage(user, project, storage): + return requests.get(f"{quota_server}/user/{user}/storage/project/{project_name}/add?usage={storage}").json() + + +def move_files(before_dir: Path, after_dir: Path, output: Output): + # print(before_dir, after_dir) + if before_dir.exists(): # needs_migration + try: + # /before/path/a + # /after/path/b + after_dir.parent.mkdir(parents=True, exist_ok=True) # /after/path + if before_dir.name != after_dir.name: + after_dir = after_dir.parent / before_dir.name + if not after_dir.exists(): + shutil.move(str(before_dir), str(after_dir.parent)) + else: + for path in before_dir.iterdir(): + if not (after_dir / path.relative_to(before_dir) ).exists(): + shutil.move(str(path), str(after_dir)) + rm_empty_parents(before_dir) + # # TODO: we should delete the dirs for very old outputs... + # if output.deleted: + # rm_files_not_listed_in_manifests(after_dir) + except Exception as e: + click.secho(f" ERROR: {e}", bold=True, fg='red') + traceback.print_exc(file=sys.stdout) + exit(1) + + +users = {'sircdevops': '111778:10'} +users_with_max_quota = set() + +def migrate_output(output): + if not output.output_dir_override: + print("MISSING output.output_dir_override") + print("- output.id", output.id) + print("- output.batch", output.batch) + print("- output.ci_commit", output.batch.ci_commit) + print("- output.project", output.batch.ci_commit.project) + # for those... just delete we alreadt cannot reach them + return + + before_dir = output.output_dir + # we unset those to make sure we get the default value + output.output_dir_override = None + output.batch.batch_dir_override = None + output.batch.ci_commit.commit_dir_override = None + # get the committer name -> login (here we wish we had saved the email too...) + if at_sirc: + username = get_username(output.batch.ci_commit) + if not username: + return + os.environ['LOGNAME'] = username + after_dir = output.output_dir + + if 'products/HM2P/output/' in str(after_dir): + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci/CDE-Users/HW_ALG', '/algo/HM2P')) + if '/HEX/' in str(after_dir): + after_dir = Path(str(after_dir).replace('/HEX/', '/HP2/')) + match = re.search("PSP_2x/tests/products/([0-9A-Za-z]+)/output", str(after_dir)) + if match: + product = match.groups(0)[0] + if product == 'ASP51': + product = "ASPv5" + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci/CDE-Users/HW_ALG', f'/algo/{product}')) + if '/DVS/tests/products/' in str(after_dir): + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci/CDE-Users/HW_ALG', '/algo/DVS')) + if '/tests/common/scripts/SCD/output': + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci/CDE-Users/HW_ALG', '/algo/CIS')) + if '/tests/blocks/AAG/output' in str(after_dir) or '/tests/blocks/GBPC/workspace/output' in str(after_dir) or '/tests/blocks/VISION_SCD/output' in str(after_dir): + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci/CDE-Users/HW_ALG', '/algo/CIS')) + if '/mnt/qaboard/igorf' in str(after_dir): + after_dir = Path(str(after_dir).replace('/mnt/qaboard/igorf', '/algo/CIS/outputs/igorf/CDE-Users/HW_ALG')) + username = 'igorf' + if '/home/arthurf/ci' in str(after_dir): + after_dir = Path(str(after_dir).replace('/home/arthurf/ci', '/algo/DVS')) + if '/home/yotama/ci' in str(after_dir): + after_dir = Path(str(after_dir).replace('/home/yotama/ci', '/algo/DVS')) + if '/stage/algo_data/ci/dvs' in str(after_dir): + after_dir = Path(str(after_dir).replace('/stage/algo_data/ci', '/algo/DVS')) + if '/stage/algo_data/ToF/Git_CI_output' in str(after_dir): + after_dir = Path(str(after_dir).replace('/stage/algo_data/ToF/Git_CI_output', '/algo/TOF/archive')) + + # click.secho(str(output)) + click.secho(f"{output.id} {'[deleted]' if output.deleted else ''}") + click.secho(f" ♻ {before_dir}", dim=True) + if '/algo/' not in str(after_dir): + print(after_dir) + exit(0) + + needs_migration = str(before_dir) != str(after_dir) + if not needs_migration: + click.secho(f" Looks OK!", dim=True) + + if 'storage' not in output.data: + print("missing storage...") + try: + exists = as_user(users["sircdevops"], lambda: output.output_dir.exists()) + if not exists: + storage = 0 + else: + owner = as_user(users["sircdevops"], lambda: output.output_dir.owner()) + # print(f"> {owner}") + # owner = output.output_dir.owner() + # print("output_dir owner:", owner) + storage = as_user(owner, get_storage, output) + except Exception as e: + print(e) + exit(0) + try: + storage = as_user(users["sircdevops"], get_storage, output) + except: + click.secho(f" .. ERROR permission issue...", dim=True) + return + print(storage) + output.data['storage'] = storage + + storage = float(output.data['storage']) / 1024 + # print(quota, storage) + + click.secho(f" ✔ {after_dir}") + if dryrun: + return + + if not needs_migration: + return + + # ldapsearch -t -L -H ldap://REDACTED_LDAP_HOST -b 'REDACTED_LDAP_BASE' -D "cn=Ldap Query,ou=IT,ou=SIRC Users,REDACTED_LDAP_BASE" -x -w REDACTED_LDAP_PASSWORD -s sub "(memberOf=CN=Sensor_Algorithms,OU=Groups,OU=SIRC Users,DC=transchip,DC=com)" | grep 'sAMAccountName:' + fillers = ["dima","oded","guy","itail","arielo","haim","igal","galb","yahavs","erand","arthurf","royy","shahafd","rivkae","amichaya","shais","taeerw","royp","matand","davidn","nimrodn","eitanl","matanh","mandyr","talb","eliavm","noar","barakd","itamarp","yoavpi","shaharj","talf","elady","dannyz","yardenr","mayav","bena","eilamg","nitsanr","ronenk","org","adirm","yoramf","ilyar","naomis","ronyg","assafb","alon","adamo","rafir","vladimird","buzzm","noal","lenag","chenr","nadavo","amitkad","orens","omera","sivanm","liranh","raziela"] + if 'TOF' in str(after_dir): + fillers = ["galb", "tofq", "matand", "shais", "taeerw", "royy", "ronyg", "mayav", "elad", "idang"] + def new_owner_info(): + random.shuffle(fillers) + for user in [username, *fillers]: + if user in users_with_max_quota: + continue + try: + quota = fetch_quota(user, project_name) + except: + click.secho(f"[WARNING] could not get quota for {user}", fg='yellow') + continue + print(user, "?", quota['used']/1024/1024) + if quota['used'] > quota['limit'] * 0.79: + click.secho(f" 😭 {user} full quota", dim=True) + users_with_max_quota.add(user) + continue + if storage and quota['used'] + storage > quota['limit'] * 0.8: + click.secho(f" 😭 {user} not enough quota: {storage/1024} on {quota['used']/1024/1024}/{quota['limit']/1024/1024}", dim=True) + continue + return user, quota + + try: + print(f" . Belongs to {username}") + username, quota = new_owner_info() + print(f" . Using {username}") + # exit(0) + except Exception as e: + click.secho(f" 😭😭😭😭 [{e}] skipping, no one has enough quota... ", dim=True) + exit(0) + db_session.refresh(output.batch.ci_commit) + db_session.refresh(output.batch) + try: + as_user(username, move_files, before_dir, after_dir, output) + except Exception as e: + try: # TODO: try to find as which user to retry... + as_user(users["sircdevops"], move_files, before_dir, after_dir, output) + except Exception as e: + print(f" ERROR: {e}") + traceback.print_exc(file=sys.stdout) + exit(1) + if not output.deleted: + add_storage(username, project, storage) + + + output.output_dir_override = str(after_dir) + output.data['migrated'] = True + flag_modified(output, "data") + db_session.add(output) + db_session.commit() + click.secho(" 🆗", fg='green') + # exit(0) + + +def migrate(min_id): + # it's an aweful join... + all_outputs = (db_session + .query(Output, Batch, CiCommit) + .join(Batch.outputs)#, isouter=True) + .join(CiCommit)#, isouter=True) + .filter(CiCommit.project_id==project) + ) + if min_id: + all_outputs = all_outputs.filter(Output.id >= min_id) + if parts_nbr: + all_outputs = all_outputs.filter(Output.id % parts_nbr == parts_idx) + # ("output_dir_override" like '/stage/algo_data/ci/CDE-Users/HW_ALG%') + outputs = (all_outputs + # .filter(text("(outputs.data->'migrated') is null")) + .filter(text("outputs.output_dir_override is not null")) + .filter(text("outputs.output_dir_override not like '/algo%'")) + # .filter(text("outputs.created_date < now() - '15 days'::interval")) + # .filter(text("output_dir_override like '/stage/algo_data/ci/CDE-Users/HW_ALG/%/CIS/output%'")) + # .filter(Output.is_pending==False) + # .order_by(Output.created_date.asc()) + .enable_eagerloads(False) + ) + # click.secho(f'- outputs total: {all_outputs.count()}', bold=True, fg='blue') + output_total = outputs.count() + click.secho(f'- outputs to migrate: {output_total}', bold=True, fg='blue') + should_continue = output_total > batch_size + start_batch = time.time() + updated = 0 + now = time.time() + + if not output_total: + for result in all_outputs.limit(1): + o, batch, commit = result + print(o) + print(o.output_dir) + return updated, None, should_continue + + for idx, result in enumerate(outputs.limit(batch_size)): + o, batch, commit = result + # print(o, batch, commit) + if o.data is None: + o.data = {} + ## legacy outputs from the previous CI + # if '/' in o.batch.ci_commit.hexsha: + # continue + + migrate_output(o) + if idx and idx % batch_size/100 == 0: + print(o) + print(o.batch.ci_commit) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + return updated, o.id, should_continue + + +def main(): + # Optionnally, you can give an id to start from... + # it helps if there are many non-migrated runs that fail because of whatever and + # you don't want to wait until the migration fails to migrate them again! + last_id = None #1245250 # None + + should_continue = True + while should_continue: + click.secho('Migrating...', bold=True, fg='blue') + nb_updated, last_id, should_continue = migrate(last_id) + click.secho(f"nb_updated={nb_updated}, last_id={last_id}, should_continue={should_continue}", fg='blue') + click.secho('DONE', fg='green') + +main() diff --git a/backend/backend/scripts/migrate_legacy_commits.py b/backend/backend/scripts/migrate_legacy_commits.py new file mode 100644 index 000000000..6fd5260a4 --- /dev/null +++ b/backend/backend/scripts/migrate_legacy_commits.py @@ -0,0 +1,162 @@ +import re +import datetime + +import click +from click import secho +from sqlalchemy import func, and_, asc, or_ + +from backend.database import db_session, Session +from backend.models import Project, CiCommit, Batch, Output + +# select +# projects.id as project_id, +# ci_commits.message, ci_commits.hexsha, ci_commits.project_id as ci_commits_project_id +# from ci_commits +# full outer join projects on ci_commits.project_id = projects.id +# where project_id is NULL; + +# select +# -- projects.id as project_id, +# -- ci_commits.message, ci_commits.hexsha, ci_commits.project_id as ci_commits_project_id +# DISTINCT ci_commits.project_id +# from ci_commits +# left join projects on ci_commits.project_id = projects.id +# where projects.id is NULL; + +projects = [ + # "4ABReconst", + # "ASPv2_GammaTest", + # "ArielOForAlon", + # "DVS/AF", + # "DVS/AntiFlicker_Gen3", + # "DVS/Depth", + # "DVS/Framer", + # "DVS_disparity", + # "DespeckleConfigurations", + # "DisparityChain", + # "FastChecks/DVS_disparity", + # "FastDepth", + # "Inpaint", + # "Kalman_filter", + # "MotionDetection", + # "PSP/WDR_DRC", + # "PSPv21Huawei_HDRPSPv21_BW", + # "Queue", + # "SW_TNR", + # "SebastienHandPoseDetectClean", + # "TNR", + "igorf/HW_ALG_poc3", + "igorf/HW_ALG_poc3/CIS", + "igorf/HW_ALG_poc3/CIS/tests/Amir/GW1_Amir", + "igorf/HW_ALG_poc3/CIS/tests/playground/Amir/GW1_Amir", + "igorf/HW_ALG_poc3/CIS/tests/products/2X5", + "igorf/HW_ALG_poc3/CIS/tests/products/GW1", + "igorf/HW_ALG_poc3/CIS/tests/products/HM1", + "igorf/HW_ALG_poc3/KITT_ISP", + "igorf/HW_ALG_poc3/KITT_ISP/tests/products/KITT_v1p0", + "igorf/HW_ALG_poc3/PSP_2x", + "igorf/HW_ALG_poc3/projects/CIS", + "igorf/HW_ALG_poc3/projects/CIS/tests/products/2X5", + "igorf/HW_ALG_poc3/scripts", + + "TNRtmp", + "VINS/DataSet_02", + "arthurf/cis", + "arthurf/simulations/products/CIS", + "chromatix_tail", +] +# projects = [ +# "CDE-Users/HW_ALG/FIMC_50/tests/products", +# "CDE-Users/HW_ALG/FIMC_50", +# "CDE-Users/HW_ALG/CIS/tests/scripts/NonaXTC", +# "arthurf/simulations", +# "CDE-Users/HW_ALG/KITT_ISP/tests/products/KITT_v2p1", +# "CDE-Users/HW_ALG/ALG_GEN/tests/blocks/YRGB_COMBINE/workspace", +# "CDE-Users/HW_ALG/CIS/tests/scripts/stitch_correction", +# "CDE-Users/HW_ALG/CIS/tests2/tests/products", +# "CIS_ISP_Algorithms/drc-autotune", +# "CDE-Users/HW_ALG/ALG_GEN/tests/blocks/STREAM_PACKER/workspace", +# "CDE-Users/HW_ALG/ALG_GEN/tests/blocks/format_adapter_16", +# "CDE-Users/HW_ALG/ALG_GEN/tests/blocks/CBINNSTITCH/workspace", +# "CDE-Users/HW_ALG/FIMC_50/tests/products/FIMC_v5p1", +# ] +for project_id in projects: + project = Project.get_or_create(db_session, id=project_id) + if not project.data: + project.data = {} # "git": {}, "qatools_config": {}, "qatools_metrics": {}} + db_session.add(project) + db_session.commit() +exit(0) + + +# .order_by(CiCommit.authored_datetime.desc()) +for c in db_session.query(CiCommit).all(): + if c.project is None: + print(c.project_id, c.hexsha) + # c.project_id = 'LSC/Calibration' +exit(0) + + + +commits = ( + db_session.query(CiCommit) + .filter(CiCommit.project_id == 'LSC\\Calibration') +) +print(commits.count()) +# exit(0) +for c in commits.all(): + c.project_id = 'LSC/Calibration' + db_session.add(c) + db_session.commit() +exit(0) + + + +# OLD PROJECTS +outputs = ( + db_session.query(Output) + .filter(Output.platform == 'CDE') +) +for o in outputs.all(): + ci_commit = o.batch.ci_commit + if ci_commit.project is None: + ci_commit.project = Project.get_or_create(db_session, id=ci_commit.project_id) + ci_commit.project.data = {"legacy": True, "git": {}, "qatools_config": {}, "qatools_metrics": {}} + print(ci_commit) + print(ci_commit.project) + db_session.add(ci_commit) + db_session.commit() +exit(0) +# print(outputs.count()) +# output = outputs.first() +# print(output) +# print(output.batch) +# # print(output.batch.ci_commit) +# print(output.batch.ci_commit.project_id) +# # print(output.batch.ci_commit.project) +# exit(0) + + +# now = datetime.datetime.utcnow() +commits = ( + db_session.query(CiCommit) + .filter(CiCommit.project_id != None) + # .order_by(CiCommit.authored_datetime.desc()) +) +print(commits.count()) +exit(0) + +for commit in commits.all(): + print(commit.hexsha) + try: + project_id, hexsha = commit.hexsha.split('/', maxsplit=1) + except: + pass + if not hexsha: + continue + print(project_id, hexsha) + project = Project.get_or_create(project_id) + print(project) + commit.project = project + db_session.add(commit) + db_session.commit() \ No newline at end of file diff --git a/backend/backend/scripts/migration_utils.py b/backend/backend/scripts/migration_utils.py new file mode 100644 index 000000000..84b0bbba2 --- /dev/null +++ b/backend/backend/scripts/migration_utils.py @@ -0,0 +1,87 @@ +import os +import pwd +import shutil +import pickle +from pathlib import Path + +import requests +import click + + + + + + +def rm_files_not_listed_in_manifests(deleted_output_dir: Path): + """Deletes files from output directories that are not listed in output-manifest files.""" + for path in deleted_output_dir.iterdir(): + if path.name in ('log.txt', 'manifest.inputs.json', 'manifest.outputs.json'): + continue + if path.is_file(): + path.unlink() + else: + shutil.rmtree(path) + + + + +def valid(username): + return Path(f"/home/{username}").exists() + + +# Some emails from git cannot be mapped to user names +# You can add more to the list...b +usernames = { + "chenrimoch@samsung.com": "chenr", + "royyam@samsung.com": "royy", + "j.y.shin@samsung.com": "sircdevops", + "heyabcd.yang@samsung.com": "sircdevops", + "heyabcd.yang@samsungds.net": "sircdevops", + "omeralon@gmail.com": "alon", + "avi.zanko@samsung.com": "aviza", + "yotamater@mail.tau.ac.il": "yotama", + "yotamater@mail.tau.ac.il": "yotama", + "$amsonite3": "ayalg", + "$amsonite2": "ayalg", + "ayal.green#samsung.com": "ayalg", + "=": "ayalg", +} + +def get_username(ci_commit): + try: + committer_email = ci_commit.project.repo.commit(ci_commit.hexsha).committer.email + except Exception as e: + click.secho(str(e), fg='red') + try: + owner = ci_commit.artifacts_dir.owner() + except: + owner = "sircdevops" + print(f"??? {owner}") + # return None if owner in ['sircdevops', 'ispq'] else owner + return owner + if committer_email in usernames: + return usernames[committer_email] + + try: + username, _ = committer_email.split('@') + except: + raise ValueError((committer_email, ci_commit.project.repo.commit(ci_commit.hexsha).committer)) + if not valid(username): + r = requests.post("http://itweb/tel/mail2user.php", {"mail": committer_email}) + username_from_it = r.text.strip() + if username_from_it: + username = username_from_it + else: + try: # try the default user naming convention at SIRC + first, second = username.split('.') + username = f"{first}{second[0]}" + except: + pass + if not valid(username): + click.secho(f"Missing username for {committer_email}", fg='red') + return None + click.secho(f"❓ {ci_commit.committer_name} -> {committer_email} -> {username}", fg='yellow') + usernames[committer_email] = username + return username + else: + return username \ No newline at end of file diff --git a/backend/backend/scripts/queries.sql b/backend/backend/scripts/queries.sql new file mode 100644 index 000000000..1bb5103dc --- /dev/null +++ b/backend/backend/scripts/queries.sql @@ -0,0 +1,63 @@ +drop view storage; +create view storage as + SELECT outputs.id, + ((outputs.data ->> 'storage'::text)::double precision) / 1000::double precision / 1000::double precision / 1000::double precision AS total_storage_gb, + (outputs.data->>'user')::text as username, + outputs.created_date, + outputs.deleted, + batches.id AS batch_id, + batches.label AS batch_label, + ci_commits.hexsha, + ci_commits.branch, + ci_commits.committer_name, + projects.id AS project + FROM outputs + JOIN batches ON batches.id = outputs.batch_id + JOIN ci_commits ON ci_commits.id = batches.ci_commit_id + JOIN projects ON projects.id::text = ci_commits.project_id::text + WHERE NOT (outputs.data ->> 'storage'::text) IS NULL; + +-- WIP per batch +select + batches.id as id, + (sum(outputs.data->>'storage')::bigint) / 1000 / 1000 / 1000 as total_storage_gb, + count(*) as nb_outputs, + ci_commits.hexsha, batches.label, projects.id, ci_commits.branch +from outputs + inner join batches on batches.id = outputs.batch_id + inner join ci_commits on ci_commits.id = batches.ci_commit_id + inner join projects on projects.id = ci_commits.project_id +where + outputs.created_date > current_date - 31 +group by (outputs.batch_id, ci_commits.hexsha, ci_commits.branch, batches.label, batches.id, projects.id) +ORDER BY storage_GB DESC NULLS LAST; + + +-- per project +select + (sum((outputs.data->>'storage')::bigint) / 1000 / 1000 / 1000) as storage_GB, + count(*) as nb_outputs, + projects.id +from outputs + inner join batches on batches.id = outputs.batch_id + inner join ci_commits on ci_commits.id = batches.ci_commit_id + inner join projects on projects.id = ci_commits.project_id +where + outputs.created_date > current_date - 31 +group by (projects.id) +ORDER BY storage_GB DESC NULLS LAST; + + +-- per branch +select + (sum((outputs.data->>'storage')::bigint) / 1000 / 1000 / 1000) as storage_GB, + count(*) as nb_outputs, + projects.id, ci_commits.branch +from outputs + inner join batches on batches.id = outputs.batch_id + inner join ci_commits on ci_commits.id = batches.ci_commit_id + inner join projects on projects.id = ci_commits.project_id +where + outputs.created_date > current_date - 31 +group by (ci_commits.branch, projects.id) +ORDER BY storage_GB DESC NULLS LAST; diff --git a/backend/backend/scripts/quota.py b/backend/backend/scripts/quota.py new file mode 100644 index 000000000..b3a1ca9bc --- /dev/null +++ b/backend/backend/scripts/quota.py @@ -0,0 +1,172 @@ +import sys +import ssl +import base64 + +sys.path.append("/home/arthurf/netapp/netapp") +from NaServer import * + + + +ssl._create_default_https_context = ssl._create_unverified_context + +s = NaServer("npmng.transchip.com", 1, 100) +s.set_server_type("FILER") +s.set_transport_type("HTTPS") +s.set_port(443) +s.set_style("LOGIN") +s.set_admin_user(base64.b64decode("YWRtaW4="),base64.b64decode("MmJIcWNjQFNJUkM=")) +print(s) + + +def quota(username): + resultLength = -1 + outputType = 'raw' + + api = NaElement("quota-report-iter") + api.child_add_string('max-records', '20000') + + print(api) + xo = s.invoke_elem(api) + print(xo) + print(xo.sprintf()) + if (xo.results_status() == "failed"): + print ("Error:\n") + print (xo.sprintf()) + sys.exit(1) + + print("Collecting quota information, please wait...") + + for each in xo.sprintf().split('\n'): + + if '' in each: + diskLimitList = re.findall(r'\>(.*?)\<', each) + diskLimitKB = ''.join(diskLimitList) + + elif '' in each: + diskUsedList = re.findall(r'\>(.*?)\<', each) + diskUsed = ''.join(diskUsedList) + + elif '' in each: + fileLimit = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + filesUsed = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaTarget = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaType = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaUserId = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaUserName = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaUserType = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + quotaDiskLimit = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + threshold = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + treeList = re.findall(r'\>(.*?)\<', each) + tree = ''.join(treeList) + + elif '' in each: + volumeList = re.findall(r'\>(.*?)\<', each) + volume = ''.join(volumeList) + + elif '' in each: + vserver = re.findall(r'\>(.*?)\<', each) + + elif '' in each: + + try: + quotaUserName + except NameError: + quotaUserName = '' + + if (username in quotaTarget or username in volume or username in quotaUserName): + # Calculate quota usage in % + try: + diskUtilization = 100 * (float(diskUsed) / float(diskLimitKB)) + # Convert to GB + diskLimit = float(int(diskLimitKB)/1024/1024) + # Calculate FREE disk space (GB) + diskFree = (float(diskLimitKB) - float(diskUsed))/1024/1024 + + + except ValueError: + pass + + try: + outputType + except: + showQuota() + else: + if outputType == 'raw': + showRawData() + elif outputType == 'web': # Show in web format + showWebQuota() + elif (outputType != 'raw') or (outputType != 'web'): + showQuota() + + + + +def showQuota(): + # Cerate & populate dictionary + thisList = {} + if round(diskUtilization, 1) >= int(resultLength): + thisList['Ut.%'] = round(diskUtilization, 1) + # If not ampty add into the Dict. + if volume != '': + thisList['Volume'] = str(volume) + else: + thisList['-'] = '' + if tree != '': + thisList['Project'] = str(tree) + else: + thisList['-'] = '' + if diskLimit != '': + thisList['Limit'] = str(diskLimit) + else: + diskLimit['-'] = '' + if diskUsed != '': + thisList['Used'] = str(diskUsed) + else: + diskLimit['-'] = '' + print(thisList) + +def showRawData(): + # Cerate & populate dictionary + thisList = {} + if round(diskUtilization, 1) >= int(resultLength): + thisList['Ut.%'] = round(diskUtilization, 1) + # If not ampty add into the Dict. + if volume != '': + thisList['Volume'] = str(volume) + else: + thisList['-'] = '' + + if tree != '': + thisList['Project'] = str(tree) + else: + thisList['-'] = '' + + if diskLimit != '': + thisList['Limit'] = str(diskLimit) + else: + diskLimit['-'] = '' + + if diskUsed != '': + thisList['Used'] = str(diskUsed) + else: + diskLimit['-'] = '' + + print(thisList) diff --git a/qaboard-backend/slamvizapp/scripts/remove_duplicates.py b/backend/backend/scripts/remove_duplicates.py similarity index 82% rename from qaboard-backend/slamvizapp/scripts/remove_duplicates.py rename to backend/backend/scripts/remove_duplicates.py index f21ff8b2d..99b94d2f4 100755 --- a/qaboard-backend/slamvizapp/scripts/remove_duplicates.py +++ b/backend/backend/scripts/remove_duplicates.py @@ -1,26 +1,26 @@ #!/usr/bin/env python """ This scrip fixed a race condition that caused duplicated batches - /opt/anaconda3/bin/python /home/arthurf/qaboard/qaboard-backend/slamvizapp/remove_duplicates.py --dryrun + /opt/anaconda3/bin/python /home/arthurf/qaboard/backend/backend/remove_duplicates.py --dryrun """ import click from click import secho from sqlalchemy import func, and_, asc, or_ from sqlalchemy.sql import text -from slamvizapp.database import db_session, Session -from slamvizapp.models import Project, CiCommit, Batch, Output +from backend.database import db_session, Session +from backend.models import Project, CiCommit, Batch, Output # Testing # ssh -# sudo cp /home/arthurf/dvs/slamvizapp/slamvizapp/clean.py /slamvizapp/slamvizapp/ -# slamvizapp_clean --dryrun +# sudo cp /home/arthurf/dvs/backend/backend/clean.py /backend/backend/ +# backend_clean --dryrun # Find duplicates: # ssh arthurf-vdi ; screen -r # SELECT ci_commit_id, label, count(*) as qty FROM batches GROUP BY ci_commit_id, label HAVING count(*)> 1; -SELECT database, path, count(*) as qty FROM test_inputs GROUP BY database, path HAVING count(*)> 1; +# SELECT database, path, count(*) as qty FROM test_inputs GROUP BY database, path HAVING count(*)> 1; # TOCO: # 1. find duplicates # 2. Merge diff --git a/qaboard-backend/slamvizapp/scripts/remove_duplicates_commits.py b/backend/backend/scripts/remove_duplicates_commits.py similarity index 91% rename from qaboard-backend/slamvizapp/scripts/remove_duplicates_commits.py rename to backend/backend/scripts/remove_duplicates_commits.py index fb8842d65..7731a810b 100755 --- a/qaboard-backend/slamvizapp/scripts/remove_duplicates_commits.py +++ b/backend/backend/scripts/remove_duplicates_commits.py @@ -1,7 +1,7 @@ #!/usr/bin/env python """ This scrip fixed a race condition that caused duplicated batches - /opt/anaconda3/bin/python /home/arthurf/qaboard/qaboard-backend/slamvizapp/remove_duplicates.py --dryrun + /opt/anaconda3/bin/python /home/arthurf/qaboard/backend/backend/remove_duplicates.py --dryrun """ import click from click import secho @@ -12,15 +12,15 @@ from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound -from slamvizapp.database import db_session, Session -from slamvizapp.models import Project, CiCommit, Batch, Output +from backend.database import db_session, Session +from backend.models import Project, CiCommit, Batch, Output from qatools.config import merge # Testing # ssh -# sudo cp /home/arthurf/dvs/slamvizapp/slamvizapp/clean.py /slamvizapp/slamvizapp/ -# slamvizapp_clean --dryrun +# sudo cp /home/arthurf/dvs/backend/backend/clean.py /backend/backend/ +# backend_clean --dryrun # Find duplicates: diff --git a/backend/backend/scripts/restore_artifacts_v1.py b/backend/backend/scripts/restore_artifacts_v1.py new file mode 100644 index 000000000..b9f6acbe6 --- /dev/null +++ b/backend/backend/scripts/restore_artifacts_v1.py @@ -0,0 +1,31 @@ +""" +If there is no more space on the shared storage, we often end up with missing artifacts. +The script will restore them... +""" +import os + +repo_dir = "/home/arthurf/CDE-Users/HW_ALG" +project = "CIS" +milestones = [ + ('HM1', '2a94c7e9'), + ('HM1', '6682ed2a'), + ('HM1', 'fbe9233c'), + ('HM1', '107f2531'), + ('HM1', '041a1e18'), + ('HM1', 'fc337ff4'), + ('HM1', 'b185f7d0'), + ('HM1', '3d129470'), + ('HM1', '70826d69'), + ('HM1', '747a162d'), + ('HM1', '934e18c7'), + ('HM1', '747a162d'), + ('HM1', '747a162d'), +] + +for product, commit in milestones: + print(product, commit) + os.chdir(f'{repo_dir}/{project}/tests/products/{product}') + os.system(f"git checkout {commit}") + os.system("git checkout develop qatools.yaml") + os.system("qa save-artifacts") + os.system("git reset --hard") diff --git a/backend/backend/scripts/save_all_directories.py b/backend/backend/scripts/save_all_directories.py new file mode 100644 index 000000000..a2cfdef4b --- /dev/null +++ b/backend/backend/scripts/save_all_directories.py @@ -0,0 +1,188 @@ +""" +python add_storage_info.py + +# python ./backend/scripts/save_all_directories.py +""" +import json +import time +import datetime + +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy import text +from qaboard.utils import total_storage, save_outputs_manifest + +from backend.models import Output, Batch, CiCommit +from backend.database import db_session, Session + +db_session.autoflush = False + + +# Update all past outputs with dir over + +start = time.time() + + +def migrate(): + batch = [] + batch_size = 500 + # batch_size = 1_000 + # batch_size = 70_000 + print(f'TOTAL {db_session.query(Output).count()}') + output_total = db_session.query(Output).filter(text("output_dir_override is null")).count() + start_batch = time.time() + print(f'without overrides: {output_total}') + + outputs = (db_session.query(Output) + .filter(text("output_dir_override is null")) + .order_by(Output.created_date.desc()) + .limit(100000) + # .yield_per(batch_size) + # .enable_eagerloads(False) + ) + updated = 0 + now = time.time() + for idx, o in enumerate(outputs): + # print(f"{o.output_dir_override} => {o.output_dir}") + # o.output_dir_override = str(o.output_dir) + try: + output_dir = o.output_dir + except Exception as e: + if not( "poc" in o.batch.ci_commit.project.id or "arthur" in o.batch.ci_commit.project.id) : + print(f"WTF {o.batch.ci_commit} in {o.batch.ci_commit.project}") + print(e) + continue + updated += 1 + batch.append({ + "id": o.id, + "output_dir_override": str(output_dir), + }) + if idx and idx % batch_size == 0: + print(o) + now = time.time() + print(f"{idx/output_total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((output_total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + batch = [] + # break + + print(f"DONE, now committing configurations [elapsed time: {now - start:.1f}s]") + db_session.bulk_update_mappings(Output, batch) + db_session.flush() + db_session.commit() + return updated + +print('Adding output directories') +# while migrate(): +# print('Updating...') + + + +def migrate_batch(): + batch = [] + batch_size = 500 + print(f'TOTAL {db_session.query(Batch).count()}') + total = db_session.query(Batch).filter(text("batch_dir_override is null")).count() + start_batch = time.time() + print(f'without overrides: {total}') + + batches = (db_session.query(Batch) + .filter(text("batch_dir_override is null")) + .order_by(Batch.created_date.desc()) + .limit(20000) + # .yield_per(batch_size) + # .enable_eagerloads(False) + ) + updated = 0 + now = time.time() + for idx, b in enumerate(batches): + try: + batch_dir = b.batch_dir + except Exception as e: + if not( "poc" in b.ci_commit.project.id or "arthur" in b.ci_commit.project.id) : + print(f"WTF {b.ci_commit} in {b.ci_commit.project}") + print(e) + continue + # print(f"{b.batch_dir_override} => {batch_dir}") + # exit(0) + updated += 1 + batch.append({ + "id": b.id, + "batch_dir_override": str(batch_dir), + }) + if idx and idx % batch_size == 0: + print(b) + now = time.time() + print(f"{idx/total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + db_session.bulk_update_mappings(Batch, batch) + db_session.flush() + batch = [] + # break + + print(f"DONE, now committing configurations [elapsed time: {now - start:.1f}s]") + db_session.bulk_update_mappings(Batch, batch) + db_session.flush() + db_session.commit() + return updated + +print('Adding batch directories') +while migrate_batch(): + print('Updating...') + + + + + + + +def migrate_commits(): + batch = [] + batch_size = 500 + total = db_session.query(CiCommit).filter(text("commit_dir_override is null")).count() + start_batch = time.time() + print(f'without overrides: {total}') + + commits = (db_session.query(CiCommit) + .filter(text("commit_dir_override is null")) + .order_by(CiCommit.authored_datetime.desc()) + .limit(20000) + ) + updated = 0 + now = time.time() + for idx, c in enumerate(commits): + try: + artifacts_dir = c.artifacts_dir + except Exception as e: + if not( "poc" in c.project.id or "arthur" in c.project.id) : + print(f"WTF {c} in {c.project}") + print(e) + continue + # print(f"{c.commit_dir_override} => {artifacts_dir}") + # continue + # exit(0) + + updated += 1 + batch.append({ + "id": c.id, + "commit_dir_override": str(artifacts_dir), + }) + if idx and idx % batch_size == 0: + print(c) + now = time.time() + print(f"{idx/total:.1%} [{batch_size/(now - start_batch):.1f}/s] [est. total left {(now - start_batch) * ((total-idx)/batch_size) / 3600:.2f}h] [elapsed time: {now - start:.1f}s]") + start_batch = now + db_session.bulk_update_mappings(Batch, batch) + db_session.flush() + batch = [] + # break + + print(f"DONE, now committing configurations [elapsed time: {now - start:.1f}s]") + db_session.bulk_update_mappings(CiCommit, batch) + db_session.flush() + db_session.commit() + return updated + +print('Adding commits directories') +while migrate_commits(): + print('Updating...') diff --git a/backend/backend/scripts/user_storage_server.py b/backend/backend/scripts/user_storage_server.py new file mode 100644 index 000000000..bf75146ef --- /dev/null +++ b/backend/backend/scripts/user_storage_server.py @@ -0,0 +1,129 @@ +""" +When running a migration we need to know user quotas, and update them. +Since we need multiple server to make the migration server, we need to sync them somehow... +The IT API is very slow.. + +Test URLs +- http://qatools01:3001/user/arthurf/storage +- http://qatools01:3001/user/arthurf/storage/project/HP2 +- http://qatools01:3001/user/arthurf/storage/project/HP2/add?usage=2 +- http://qatools01:3001/user/arthurf/storage + +""" +import sys +import json +from pathlib import Path +from copy import deepcopy +from ast import literal_eval + +import requests +from flask import Flask, request, jsonify +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) + +missing_quota_info = {"volume": None, "used": 0, "limit": 100*1024*1024} + +# NOTE: we assume we run with 1 thread only... +# otherwise you need a sync: simplest is always writing to disk and reading from disk.. +# or work hard with pythn, or use e.g. redis... + +# TODO: +# - we really should return a _list_ of {volume, project, quota} +# since multiple volumes can refer to a project, and things are mounted at various locations.. +# We could parse mount and get the info... +# - the API to add storage should provide a mount point only and we should figure it out... +# - Then on the frontend it also require a bit of re-work, +# we could query by project, group by volume/mount-point... + + + +def fetch_user_quota(username): + r = requests.get(f"http://itweb01/quota/index.php?username={username}&limit=-1&type=raw") + usage = {} + # The response is not an array, but 1 line per record... + for line in r.text.splitlines(): + # The type is printed between each record... + if line == 'raw': + continue + info = literal_eval(line) + # info = json.loads(line) # if only we had JSON + if 'Project' not in info: # we only care about volumes with Projects + continue + usage[info['Project']] = { + "volume": info['Volume'], + "used": float(info['Used']), + "limit": float(info['Limit']) * 1024 * 1024, + } + return usage + + +user_quota_path = Path('user-quotas.json') +if user_quota_path.exists(): + if '--no-cache' in sys.argv: + user_quota_path.unlink() + with user_quota_path.open() as f: + users_quotas = json.load(f) +else: + users_quotas = { + # "arthurf": { + # "some-volume": {"used": 10, "limit": 1024} + # } + } + + +def write_users_quotas(): + with user_quota_path.open('w') as f: + json.dump(users_quotas, f) + + +def user_quota(username): + if username not in users_quotas: + quotas = fetch_user_quota(username) + users_quotas[username] = quotas + write_users_quotas() + return users_quotas[username] + + +@app.route('/user//storage') +def all_quotas(user): + return jsonify(users_quotas) + +@app.route('/user//storage') +def all_user_storage(user): + return jsonify(user_quota(user)) + + +@app.route('/user//storage/project/') +def user_storage(user, project): + project_quota = user_quota(user).get(project, deepcopy(missing_quota_info)) + print("[get]", project_quota) + return jsonify(project_quota) + + +@app.route('/user//storage/project//add') +def add_user_storage(user, project): + project_quota = user_quota(user).get(project, deepcopy(missing_quota_info)) + project_quota['used'] += float(request.args['usage']) + write_users_quotas() + print("[after-add]", project_quota) + return jsonify(project_quota) + +@app.route('/project/') +def project_storage(project): + return jsonify({ + user: quotas[project] for user, quotas in users_quotas.items() if project in quotas + }) + + +if __name__ == '__main__': + # we want to be sure we don't have sync issues between threads... + # without involving _anything_ complicated + app.run( + host='0.0.0.0', + port=3001, + debug=None, + reloader_type='watchdog', + threaded=False, + ) diff --git a/backend/backend/utils.py b/backend/backend/utils.py new file mode 100644 index 000000000..940ee4b1f --- /dev/null +++ b/backend/backend/utils.py @@ -0,0 +1,170 @@ +""" +Small utility tools. +""" +import os +import yaml +import datetime +import requests +from hashlib import md5 +from pathlib import Path +from functools import cache +from urllib.parse import urlparse + +from .hybrid_cache import hybrid_cache + + +@hybrid_cache(ttl=12*60*60) # 12h +def get_users_per_name(search_filter): + """Retrievies users from Gitlab""" + if 'GITLAB_ACCESS_TOKEN' not in os.environ: + return {} + + headers = {'Private-Token': os.environ['GITLAB_ACCESS_TOKEN']} + gitlab_api = "http://gitlab-srv.transchip.com/api/v4" + users_db = {} # tries to matche a name/fullname/firstname/id to a gitlab user + + # gitlab paginates each 100 users + page = 1 + users_on_page = {} + while page==1 or users_on_page: + url = f'{gitlab_api}/users/?{search_filter}' + print(f"GET {url}", page) + r = requests.get( + url, + headers=headers, + params={'per_page':1000, 'page': page}, + proxies={} + ) + users_on_page = r.json() + print(f"{len(users_on_page)} users") + for u in users_on_page: + # need gitlab admin rights + if 'email' in u: + users_db[u['email']] = u + email_base = u['email'].split('@')[0] + users_db[email_base] = u + users_db[email_base.lower()] = u + users_db[email_base.lower().replace('.', '')] = u + if 'username' in u: + users_db['username'] = u + users_db[u['name'].lower()] = u + users_db[u['username'].lower()] = u + try: + first_name, family_name = u['name'].lower().split(' ') + user_id = first_name[0] + family_name[:5] + users_db[user_id] = u + users_db[f'{first_name}.{family_name}'] = u + if first_name not in users_db: + users_db[first_name] = u + else: + pass + # print(f'warning: {u}') + except: + pass + page = page + 1 + return users_db + + + + + +def gravatar_url(name): + name_hash = md5(name.encode('utf8')).hexdigest() + return f'http://gravatar.com/avatar/{name_hash}' + + +@cache +def get_avatar_url(name): + users_per_name = get_users_per_name("") + if not users_per_name or not name: + return '' + + name = name.lower() + + # try to get info from gitlab (TODO: it's really ugly) + user = None + if name in users_per_name: + user = users_per_name[name] + elif name.replace('.', '') in users_per_name: + user = users_per_name[name.replace('.', '')] + elif name.replace(' ', '') in users_per_name: + user = users_per_name[name.replace(' ', '')] + elif name.replace(' ', '.') in users_per_name: + user = users_per_name[name.replace(' ', '.')] + + if user: + if "gravatar" in user['avatar_url'] and 'username' in user: + # only SIRC users have avatars... + identities = user.get('identities', []) + if all(['ou=guests' not in i['extern_uid'] for i in identities]): + return f"https://dag.sirc.co.il:8081/{user['username']}.jpg" + return user['avatar_url'] + else: + return gravatar_url(name) + +def detect_hosting_type(url): + """Detect whether a URL points to a GitHub or GitLab instance.""" + if not url: + return "gitlab" # backward compat default + parsed = urlparse(url) + hostname = parsed.hostname or '' + if "github" in hostname: + return "github" + return "gitlab" + + +def _github_api_base(web_url): + """Derive the GitHub API base URL from a repository web URL.""" + parsed = urlparse(web_url) + if parsed.hostname == 'github.com': + return 'https://api.github.com' + # GitHub Enterprise: https://github.example.com/api/v3 + return f'{parsed.scheme}://{parsed.hostname}/api/v3' + + +@hybrid_cache(ttl=12*60*60) # 12h +def get_github_avatar_url(name, web_url=''): + """Retrieve avatar URL for a user from GitHub API.""" + github_token = os.environ.get('GITHUB_ACCESS_TOKEN', '') + api_base = _github_api_base(web_url) if web_url else 'https://api.github.com' + + headers = {} + if github_token: + headers['Authorization'] = f'Bearer {github_token}' + + # Try searching by name + try: + r = requests.get( + f'{api_base}/search/users', + params={'q': f'{name} in:name'}, + headers=headers, + timeout=5, + proxies={}, + ) + if r.ok: + items = r.json().get('items', []) + if items: + return items[0].get('avatar_url', '') + except Exception as e: + print(f'[WARNING] GitHub avatar lookup failed for {name}: {e}') + + # Fall back to gravatar + return gravatar_url(name) + + +# Wrapp function calls in profiled(my_call()) to profile code +import cProfile, pstats, io +import contextlib +import sys + +@contextlib.contextmanager +def profiled(): + pr = cProfile.Profile() + pr.enable() + yield + pr.disable() + s = io.StringIO() + ps = pstats.Stats(pr, stream=s).sort_stats('cumulative') # cumulative tottime + ps.print_stats(35) + ps.print_callers(35) + print(s.getvalue(), file=sys.stderr) diff --git a/backend/init.sh b/backend/init.sh new file mode 100755 index 000000000..1a7439e51 --- /dev/null +++ b/backend/init.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -ex + +# at first startup, solves issues when running uwsgi as another user +# chown $UWSGI_UID:$UWSGI_GID /var/qaboard +chmod 777 /var/qaboard + +# Apply migrations if needed +cd /qaboard/backend/backend +alembic upgrade head || alembic downgrade head || alembic stamp head + + +# At SIRC we need to be able to turn into any user to delete their output files +if [ -z ${UWSGI_UID+x} ]; then + echo "not-needed" +else + echo "$UWSGI_UID ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers +fi + + +# Start the server +cd /qaboard/backend +uwsgi --listen $UWSGI_LISTEN_QUEUE_SIZE --ini /qaboard/backend/uwsgi.ini diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 000000000..19b6fd0d9 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,90 @@ +[project] +name = "backend" +version = "1.0.1" +description = "Backend for QA-Board" +authors = [{ name = "Arthur Flam", email = "arthur.flam@samsung.com" }] +requires-python = ">=3.11" +license = "Apache-2.0" +dependencies = [ + "gitpython", + "click", + "flask", + "flask_cors", + "flask_login", + "python-ldap", + "sqlalchemy", + "sqlalchemy_utils", + "alembic", + "psycopg2", + "ujson", + "pandas", + "numpy", + "scikit-image", + "uwsgi", + "uwsgitop", + "scikit-optimize", + "psutil", + "certifi", + "requests", + "celery", + "sentry-sdk[flask]", + "python3-saml", + "polyleven", + "rich", + "simplejson", + "redis", + "qaboard", + # see https://github.com/xmlsec/python-xmlsec/issues/320 + "lxml", + "xmlsec", + "pillow", + "pytz>=2026.1.post1", +] + +[tool.setuptools.packages.find] +include = ["backend"] + +[tool.uv] +package = true +# see https://github.com/xmlsec/python-xmlsec/issues/320 +# but not a good enough workaround.. +no-binary-package = ["uwsgi", "lxml", "xmlsec", "psycopg2"] + +# Unfortunately uv tries to resolve even extras we dont use +# Leading to ssh issues all over the place. So we can't just +# [project.optional-dependencies] +# sirc = [ +# "qaboard-site-sirc", +# "cde @ git+ssh://git@gitlab-srv/cde/cde-python", +# ] +# dsk = [ +# "qaboard-site-dsk", +# 'cde @ git+ssh://git@github.sec.samsung.net/CDE/cde-python', +# ] + +[tool.uv.sources] +qaboard = { path = "../" } +# qaboard-site-sirc = { path = "../deployments/sirc/cli" } +# qaboard-site-dsk = { path = "../deployments/dsk/cli" } + +# [tool.uv.pip] +# no-binary = ["uwsgi", "lxml", "xmlsec"] + +# uv sync --extra qaboard +# [project.optional-dependencies] +# qaboard = ["qaboard"] + + +[project.urls] +repository = "https://github.com/Samsung/qaboard" +homepage = "https://samsung.github.io/qaboard" + + +[project.scripts] +qaboard_clean = "backend.clean:clean" +qaboard_clean_untracked_hwalg_artifacts = "backend.clean:clean_untracked_hwalg_artifacts" +qaboard_clean_untracked_hwalg_outputs = "backend.clean:clean_untracked_hwalg_outputs" +qaboard_init_database = "backend.scripts.init_database:init_database" + +[dependency-groups] +dev = ["pytest"] diff --git a/backend/restore_artifacts.py b/backend/restore_artifacts.py new file mode 100644 index 000000000..90e9a28b8 --- /dev/null +++ b/backend/restore_artifacts.py @@ -0,0 +1,28 @@ +import os +import requests + +# the script assumes you'll work in a git working directory exactly at +repo_path = "/home/ispq" # under "/CDE-Users/HW_ALG" + +r = requests.get('http://qa/api/v1/projects') +projects = r.json() +for project, data in projects.items(): + if 'product' not in project: + continue + if 'HM1' in project: + continue + print(project) + milestones = data.get('data').get('milestones', {}) + for m in milestones.values(): + commit = m['commit'] + print('>', commit) + # continue + os.chdir(f'{repo_path}/{project}') + os.system(f"git checkout {commit}") + # exit(0) + # if not workppace + os.system("git checkout develop qatools.yaml") + os.system("qa save-artifacts") + os.system("git reset --hard") + os.system("git clean -fd") + diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 000000000..086a8b8ca --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1900 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backend" +version = "1.0.1" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "celery" }, + { name = "certifi" }, + { name = "click" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gitpython" }, + { name = "lxml" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "polyleven" }, + { name = "psutil" }, + { name = "psycopg2" }, + { name = "python-ldap" }, + { name = "python3-saml" }, + { name = "pytz" }, + { name = "qaboard" }, + { name = "redis" }, + { name = "requests" }, + { name = "rich" }, + { name = "scikit-image" }, + { name = "scikit-optimize" }, + { name = "sentry-sdk", extra = ["flask"] }, + { name = "simplejson" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, + { name = "ujson" }, + { name = "uwsgi" }, + { name = "uwsgitop" }, + { name = "xmlsec" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic" }, + { name = "celery" }, + { name = "certifi" }, + { name = "click" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gitpython" }, + { name = "lxml" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "polyleven" }, + { name = "psutil" }, + { name = "psycopg2" }, + { name = "python-ldap" }, + { name = "python3-saml" }, + { name = "pytz", specifier = ">=2026.1.post1" }, + { name = "qaboard", directory = "../" }, + { name = "redis" }, + { name = "requests" }, + { name = "rich" }, + { name = "scikit-image" }, + { name = "scikit-optimize" }, + { name = "sentry-sdk", extras = ["flask"] }, + { name = "simplejson" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, + { name = "ujson" }, + { name = "uwsgi" }, + { name = "uwsgitop" }, + { name = "xmlsec" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "celery" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polyleven" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/c7/e0b3bbe72e0003e5d02726e0d406ea47d523a2aec9c41d831817a8e0bce1/polyleven-0.11.0.tar.gz", hash = "sha256:d74d348387cf340051711c0dd6af993b4c264daa78470098de16f4a2b725785c", size = 6407, upload-time = "2026-02-09T09:41:49.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/7a/27ea9a78b617ddb14c2f5d2416df2fbf07fa5e52685f2968686a0308c8af/polyleven-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a28860fe33a7f907bc5f86e55a0b9faea80047d1677fa23b4d6c631ccf91ef2f", size = 7420, upload-time = "2026-02-09T09:40:44.505Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/fea309d41502aa5a344a6d4d6e5b8bdabb1df1e28f1af52bb53180f6c956/polyleven-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47a3fb5b8cb60f647d2832d38b7d87cda27da8622b27c1292bceb9a04954c189", size = 7514, upload-time = "2026-02-09T09:40:46.44Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/c7d3bb6c66050304c3fe3cae1a716f62fea947ac3f14d02ef71e24422f76/polyleven-0.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:209fa669ca23ac453a7e9fbf07695350d5cbe61d71a6226b861757ccab28e664", size = 20887, upload-time = "2026-02-09T09:40:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/77/1b/aeaf38075c7e0225fd7ba89db3bd11c0e65b50907242d82d57c4804941d9/polyleven-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3bfce4689b6aaacf7c5296b8ed11ada07ccf046a01097ba1681e10f9caabbf6f", size = 21376, upload-time = "2026-02-09T09:40:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/bd/96/10f01f8ab883a51ef7bed610933ba88b7cf5b0a0e3fd059c94f1db8414d4/polyleven-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:83e59c8590a06ea6a959a3c55e6d28544b8d11a51aac2a318c1b74f92575dd28", size = 20436, upload-time = "2026-02-09T09:40:50.059Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4e/5cedf4cfde32eddab388435463a0f8c2e322449c61ef4765db62ca7c0d13/polyleven-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2eb8f6778f3073dce041805a09f0753cc441b0219253d7b933aad234a954f30a", size = 20685, upload-time = "2026-02-09T09:40:51.03Z" }, + { url = "https://files.pythonhosted.org/packages/a0/40/d88e50b60d0a731d9fb7e71268a91fcf8e290eaa5c043715d8f6ad158fe2/polyleven-0.11.0-cp311-cp311-win32.whl", hash = "sha256:248b9f645d8c6e337091498ed5c7d4a796d9d51df98458be25b1d76d962954e2", size = 11613, upload-time = "2026-02-09T09:40:52.468Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/a52aeeb5200ea4c6d1642cd9e86827feee619e56792d1795465532a9d6f8/polyleven-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:5c9ccb2f327d49a7566b0192e0d426f7772b38e247dc4e809c0b1cdf23e2ecc2", size = 10814, upload-time = "2026-02-09T09:40:53.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e1/857fa37a1d4cca74cb2a144cd2962d265fd2d705f971c146d6ee6cdab546/polyleven-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:9b97b9260730deda4cdd5878fd6ce128b970497da0fbefeeacc0b1ed4c59ebb7", size = 9420, upload-time = "2026-02-09T09:40:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/a9b7aa80589d54bde7d894a9fb118a766833def3ded11739e70b1f19d951/polyleven-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a33728df4708c9370f5e65fdb8de7e15f01d5ae8530eedf507d182fe63afb0e", size = 7437, upload-time = "2026-02-09T09:40:55.459Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5e/71ee3cb252fd6abdf19dd40c11f87ad3adffa06cc0b8098b98ec355573a4/polyleven-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58c11ce44466f6d833fd90f77ccd0c44accce41c9e80dea4c2817d5c124a61d5", size = 7510, upload-time = "2026-02-09T09:40:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1b/4c41947cfb2f7c4348d60d53d45418e47c305e33b1ce78218b84d636fca8/polyleven-0.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce2e782f8fae812c7ad960c4fd17a58ada183b89ae220cacfe6b3234179872f4", size = 20982, upload-time = "2026-02-09T09:40:57.152Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/07e4f90873e4c0c764f6081ad44c17308d7d18976284d80bca47c98e4282/polyleven-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a472fccba89ffb44b10481760c7351c79855b0ff654ec9d28966bfb111a71748", size = 21476, upload-time = "2026-02-09T09:41:00.3Z" }, + { url = "https://files.pythonhosted.org/packages/67/26/31871030852e62e705d060c1dafaddd2f9a4b3664a8a6866ad7521e7e07f/polyleven-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:758c5fcb9d8556720fb51c1de5f3a7b39bb8dce9510a1f40e5f287951b901010", size = 20497, upload-time = "2026-02-09T09:41:01.252Z" }, + { url = "https://files.pythonhosted.org/packages/71/8a/8e1b60f5fc905c0437a063414eee58fcc775b8ae6229e85439b6c477b331/polyleven-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:367faf0e1898c79624f46a894ebe5b69bf1782318ec3e3331676ce5b24352882", size = 20768, upload-time = "2026-02-09T09:41:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/00/7f9bab45279828b9d70a0674d92fc78e858dcbfa79b1e0669f93b039249e/polyleven-0.11.0-cp312-cp312-win32.whl", hash = "sha256:71bbb17919548d4e162444c918b1acf864f84150197087c757975606cbb99e43", size = 11625, upload-time = "2026-02-09T09:41:03.189Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/47a019878909f80526567347379ba73aff114c5ee6d6c6efb7a0b6d4573b/polyleven-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:e7b6c8cfa13114bc2b17b51503a4db0cbc358c3c96197d6d7283bd686c0fd8fb", size = 10834, upload-time = "2026-02-09T09:41:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/3e/40/4e80a66231052693328fc866a932e07b636cf8bb7ddf0eb54aa475f792bf/polyleven-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:561f028c9535223c78cd58f6b546dd15ce69a6e268e651ae1377644845fae639", size = 9424, upload-time = "2026-02-09T09:41:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/ce/16/5aec69609adc373f10087eb69b0b9d177ae721632715a86348b429030514/polyleven-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cb8ed97b536f9aada3ad45169ee7768c426498bf3fa608a4eabd055dfef795e", size = 7425, upload-time = "2026-02-09T09:41:06.542Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5b/0542c723aa83833a5090114bc4e5a8e60293873fe60ee8221a5888d87370/polyleven-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f975ab8cb81fd8eb5a647a3cefb0bb80bc307920a9307f66ab4019d88370ed2", size = 7505, upload-time = "2026-02-09T09:41:07.445Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8d/c317217734a5bd2011f1128c1a9056477a5148d8d95527fcab2fe3955876/polyleven-0.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16986fd58911d6075b5f63ea001197141145b7a6df48bc4ce4530e79227e74a2", size = 21035, upload-time = "2026-02-09T09:41:08.32Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/8a3e6e4a68dbd9de564fd3d16eee90e3f807a4380fd7192f40af4be47175/polyleven-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6a814629cc0468f9800b1333414a3be08fda9c5ce6b63e97154a9d21732e590", size = 21509, upload-time = "2026-02-09T09:41:09.285Z" }, + { url = "https://files.pythonhosted.org/packages/6b/da/4097998bea845f0b3a67112200aa08c19d4da0a17d761b35484d695c21e2/polyleven-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88a35ec93ec3d81a7347fd49db314a914798a144dca3d22946d18bba9b597dec", size = 20536, upload-time = "2026-02-09T09:41:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/a1/71/67b7679ede99589ec749290d938693b87cdb6bb327b062c46d2129a5e6ec/polyleven-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:50bb7d68b790194d552ee1256a02e205486b27eb22ab333eeb0003e0271c4846", size = 20775, upload-time = "2026-02-09T09:41:11.692Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3e/6f7fad4fee748ba365cb3e1ba2e061a74e18d987eb554ead4757127df2ab/polyleven-0.11.0-cp313-cp313-win32.whl", hash = "sha256:ce264f6a9daa3265299d8ffcb180d8256517a8d9235613a3b267172da0bc1e06", size = 11629, upload-time = "2026-02-09T09:41:12.652Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cc/4877913dec8fb4f968a070c894254db5811b62128d3a69b05bcd1305b5c3/polyleven-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4648732c8ad3955c8d7b1aa015d92936a150475aaa97ce704fe0c8e7fa7e0c4f", size = 10841, upload-time = "2026-02-09T09:41:13.682Z" }, + { url = "https://files.pythonhosted.org/packages/59/e2/039cc477ce73d6184e12cf6341ac200bc9f4c5428254c399015ec30392e1/polyleven-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:166f6c9b161c6af92ff201c734d6437bc7ef74a32dab306c5d47a0bdb7a82d9f", size = 9424, upload-time = "2026-02-09T09:41:14.545Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/a02d74f965127adb6a8fbd5030e2c98335ef2f8e7452b12a882883b2053a/polyleven-0.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3c18b8e44e5d04f1ffa7d41eb68da553833ab8663b7cfb1a505d85676db5c797", size = 7482, upload-time = "2026-02-09T09:41:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/fe/74/dfa9e9891cd85e679f230c5e740cba11b0bb11bd9fb298657ccf048ff70e/polyleven-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ab547adc0ac72a2852d37337a4a839d4e2f713940b0e8a944d45c528e5e6538", size = 7508, upload-time = "2026-02-09T09:41:16.365Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ef/399ae8d21f7b348514b7ad3bd7b9d530bf195fb0a8ec63cf7af7d17a4071/polyleven-0.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5808f62874187dfd4e30de5dd5f42a660562ec95a87cc64d5455ba0f4be8f175", size = 21056, upload-time = "2026-02-09T09:41:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/21/60/7eb97286a6171dd794a0e5b261175e8bfeb99a2b566bd9b8848ebc97f6df/polyleven-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9deb75346b4177d5e69496791e6156f705d9059961ce8f9520a0dc96532f10f2", size = 21535, upload-time = "2026-02-09T09:41:18.137Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/6fa59257c2138e33a858f10236a2a6b381b87f61251c1df468be7c666338/polyleven-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ef28c4c6cdc71a32f0478772d2f07b2cd412fe7950182033b1c36c8a481b0834", size = 20560, upload-time = "2026-02-09T09:41:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2d/85be9c91d05cb0127586640108f3110f6a3a98c9478f84713d4771c49761/polyleven-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94832ff5d04022ba6038c2ca0c9ea6906330cde3a3b1761739d772647d01da33", size = 20814, upload-time = "2026-02-09T09:41:20.001Z" }, + { url = "https://files.pythonhosted.org/packages/da/91/5a99ae6cf16ff55a94c5686871ed20b816ad1690f823494c76dc3ce0f54b/polyleven-0.11.0-cp314-cp314-win32.whl", hash = "sha256:e6182ea6142904ea50cf82e2955d922156b5fcf9a8279925f312961f16710a58", size = 11966, upload-time = "2026-02-09T09:41:20.946Z" }, + { url = "https://files.pythonhosted.org/packages/48/ec/9c6fcdeb1dd436523f8e2275407f588d6a66a524d7a793f554957373769c/polyleven-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:bf82bb8601582da8f2248293c1e6f4cce2025c79fd64fccddf67dd8538655b55", size = 11100, upload-time = "2026-02-09T09:41:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/42/7f/1e59881a56a4963b4546c7b558ab7979daddff586001f18b80f1f66cece9/polyleven-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:45487a1e4a8415e4ed45e6720b2a3ad9d240336f7afa136a625b8f802a1880c2", size = 9624, upload-time = "2026-02-09T09:41:22.749Z" }, + { url = "https://files.pythonhosted.org/packages/47/5a/5eaa75427f17d4cdf8e2139988a3ec6b841b6e077ebc1fccb754c1f8b55e/polyleven-0.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c518ced3e7c05de4efbd12fd7b61d6d574eb170f431e0415689d9f143fe552ee", size = 7490, upload-time = "2026-02-09T09:41:23.677Z" }, + { url = "https://files.pythonhosted.org/packages/50/47/5dd5fa13d315e0d5dc3e41bbaa16306ea56e74929ad29df54d5c24a84dcc/polyleven-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa49732cdecd985241db9f78d5fdba7170ba6375d2bf9ad040b05127dc96b877", size = 7514, upload-time = "2026-02-09T09:41:24.55Z" }, + { url = "https://files.pythonhosted.org/packages/75/aa/838f1bc632144f4f5820b9dbd31e0c64de41a7b0970b5cbe6fc02746090f/polyleven-0.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2aada9dd04e84389d90790f359447447a499d6d86807697d80732ed45547a43", size = 21123, upload-time = "2026-02-09T09:41:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/d6f32263b863dfffeed9a67e80b53476cd0089f202b0510a80eb07f7425b/polyleven-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94311ee39e2db957415eacb36b96ae26dcc427c260465324de45fb8c870d4661", size = 21627, upload-time = "2026-02-09T09:41:27.219Z" }, + { url = "https://files.pythonhosted.org/packages/ae/68/4dee05a4217a3eb1f85cbc915f5fa269d79b86d2a8384be68bcd21de37cc/polyleven-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:45cfb234fece0c9df73276788fa529a25f91abf97dd0d9aed4f1b713b6d530e3", size = 20635, upload-time = "2026-02-09T09:41:28.137Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c2/8486bdaebf47e6b764e8be227a7d2898463f2b4d91443ecdeee9ebeca6bc/polyleven-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aaed455f498172769fd88f83c27bb8f43e0583d7b27d6b343154d471ec2145e", size = 20870, upload-time = "2026-02-09T09:41:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/b827188b55108bd816110a6f60b78aee0db045a98bf7b1f2e7bfb60f4039/polyleven-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2a59849c327279902e8b396666f6998234aa82aacc47abc103d93babaad46203", size = 11917, upload-time = "2026-02-09T09:41:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/c909bde1d1db7ead33329b941b0050c93cab9b811e44b49d04adb8c5f0f8/polyleven-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ba2dcf3aff2909bbf3bdd9c1749f8de207f023fbb2c0b1d681c6bf3e78ceef1", size = 11073, upload-time = "2026-02-09T09:41:31.371Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/51f7a0fab2d65c2b6908872f26bb03bb7e2357d195f2a59aec1a27489106/polyleven-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:05207bb66da15a2dc5c530e2f5cb5f0588d0a7e79b3bd542965f9e06e3fb14fe", size = 9601, upload-time = "2026-02-09T09:41:32.235Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg2" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/9d12bc8677c24dad342ec777529bce705b3e785fa05d85122b5502b9ab55/psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3", size = 379598, upload-time = "2025-10-10T11:14:46.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/fe/d6dce306fd7b61e312757ba4d068617f562824b9c6d3e4a39fc578ea2814/psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb", size = 2713723, upload-time = "2025-10-10T11:10:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bf/635fbe5dd10ed200afbbfbe98f8602829252ca1cce81cc48fb25ed8dadc0/psycopg2-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:e03e4a6dbe87ff81540b434f2e5dc2bddad10296db5eea7bdc995bf5f4162938", size = 2713969, upload-time = "2025-10-10T11:10:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/88/5a/18c8cb13fc6908dc41a483d2c14d927a7a3f29883748747e8cb625da6587/psycopg2-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:8dc379166b5b7d5ea66dcebf433011dfc51a7bb8a5fc12367fa05668e5fc53c8", size = 2714048, upload-time = "2025-10-10T11:10:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, +] + +[[package]] +name = "pyaml" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fb/2b9590512a9d7763620d87171c7531d5295678ce96e57393614b91da8998/pyaml-26.2.1.tar.gz", hash = "sha256:489dd82997235d4cfcf76a6287fce2f075487d77a6567c271e8d790583690c68", size = 30653, upload-time = "2026-02-06T13:49:30.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/f3/1f8651f23101e6fae41d0d504414c9722b0140bf0fc6acf87ac52e18aa41/pyaml-26.2.1-py3-none-any.whl", hash = "sha256:6261c2f0a2f33245286c794ad6ec234be33a73d2b05427079fd343e2812a87cf", size = 27211, upload-time = "2026-02-06T13:49:29.652Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-ldap" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" } + +[[package]] +name = "python3-saml" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "lxml" }, + { name = "xmlsec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/98/6e0268c3a9893af3d4c5cf670183e0314cd6b5cb034a612d6a7cc5060df8/python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133", size = 83468, upload-time = "2023-10-09T10:37:43.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/14/49d9828443b58bd5cc80a454c91b0f867fbf36a24975d501945e6cb9e32f/python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d", size = 76155, upload-time = "2023-10-09T10:40:34.001Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qaboard" +version = "1.0.3" +source = { directory = "../" } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "scikit-learn" }, + { name = "scikit-optimize" }, + { name = "sentry-sdk" }, + { name = "simplejson" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=7.0" }, + { name = "flake8", marker = "extra == 'dev'" }, + { name = "green", marker = "extra == 'dev'" }, + { name = "joblib" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "scikit-learn" }, + { name = "scikit-optimize" }, + { name = "sentry-sdk" }, + { name = "simplejson" }, + { name = "types-pyyaml", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "types-simplejson", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "redis" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081, upload-time = "2026-03-06T18:18:16.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379, upload-time = "2026-03-06T18:18:14.583Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, + { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scikit-optimize" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyaml" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/95/1b433b9eb9eb653fb97fd525552fd027886e3812d7d20d843994263340aa/scikit_optimize-0.10.2.tar.gz", hash = "sha256:00a3d91bf9015e292b6e7aaefe7e6cb95e8d25ce19adafd2cd88849e1a0b0da0", size = 86202, upload-time = "2024-06-04T19:12:56.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/cd/15c9ebea645cc9860aa71fe0474f4be981f10ed8e19e1fb0ef1027d4966e/scikit_optimize-0.10.2-py2.py3-none-any.whl", hash = "sha256:45bc7e879b086133984721f2f6735a86c085073f6c481c2ec665b5c67b44d723", size = 107794, upload-time = "2024-06-04T19:12:54.592Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, +] + +[package.optional-dependencies] +flask = [ + { name = "blinker" }, + { name = "flask" }, + { name = "markupsafe" }, +] + +[[package]] +name = "simplejson" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633, upload-time = "2025-09-26T16:27:45.028Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309, upload-time = "2025-09-26T16:27:46.142Z" }, + { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308, upload-time = "2025-09-26T16:27:47.542Z" }, + { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733, upload-time = "2025-09-26T16:27:48.673Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397, upload-time = "2025-09-26T16:27:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654, upload-time = "2025-09-26T16:27:51.168Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913, upload-time = "2025-09-26T16:27:52.331Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568, upload-time = "2025-09-26T16:27:53.41Z" }, + { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239, upload-time = "2025-09-26T16:27:54.502Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497, upload-time = "2025-09-26T16:27:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069, upload-time = "2025-09-26T16:27:57.039Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158, upload-time = "2025-09-26T16:27:58.265Z" }, + { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911, upload-time = "2025-09-26T16:27:59.292Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, + { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, + { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "sqlalchemy-utils" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/7d/eb9565b6a49426552a5bf5c57e7c239c506dc0e4e5315aec6d1e8241dc7c/sqlalchemy_utils-0.42.1.tar.gz", hash = "sha256:881f9cd9e5044dc8f827bccb0425ce2e55490ce44fc0bb848c55cc8ee44cc02e", size = 130789, upload-time = "2025-12-13T03:14:13.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "ujson" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ea/80346b826349d60ca4d612a47cdf3533694e49b45e9d1c07071bb867a184/ujson-5.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7c46cb0fe5e7056b9acb748a4c35aa1b428025853032540bb7e41f46767321f", size = 55248, upload-time = "2025-08-20T11:55:19.033Z" }, + { url = "https://files.pythonhosted.org/packages/57/df/b53e747562c89515e18156513cc7c8ced2e5e3fd6c654acaa8752ffd7cd9/ujson-5.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8951bb7a505ab2a700e26f691bdfacf395bc7e3111e3416d325b513eea03a58", size = 53156, upload-time = "2025-08-20T11:55:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/41/b8/ab67ec8c01b8a3721fd13e5cb9d85ab2a6066a3a5e9148d661a6870d6293/ujson-5.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952c0be400229940248c0f5356514123d428cba1946af6fa2bbd7503395fef26", size = 57657, upload-time = "2025-08-20T11:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/fb84f27cd80a2c7e2d3c6012367aecade0da936790429801803fa8d4bffc/ujson-5.11.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:94fcae844f1e302f6f8095c5d1c45a2f0bfb928cccf9f1b99e3ace634b980a2a", size = 59779, upload-time = "2025-08-20T11:55:22.772Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/48706f7c1e917ecb97ddcfb7b1d756040b86ed38290e28579d63bd3fcc48/ujson-5.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0ec1646db172beb8d3df4c32a9d78015e671d2000af548252769e33079d9a6", size = 57284, upload-time = "2025-08-20T11:55:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ce/48877c6eb4afddfd6bd1db6be34456538c07ca2d6ed233d3f6c6efc2efe8/ujson-5.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:da473b23e3a54448b008d33f742bcd6d5fb2a897e42d1fc6e7bf306ea5d18b1b", size = 1036395, upload-time = "2025-08-20T11:55:25.725Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7a/2c20dc97ad70cd7c31ad0596ba8e2cf8794d77191ba4d1e0bded69865477/ujson-5.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa6b3d4f1c0d3f82930f4cbd7fe46d905a4a9205a7c13279789c1263faf06dba", size = 1195731, upload-time = "2025-08-20T11:55:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/15/f5/ca454f2f6a2c840394b6f162fff2801450803f4ff56c7af8ce37640b8a2a/ujson-5.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4843f3ab4fe1cc596bb7e02228ef4c25d35b4bb0809d6a260852a4bfcab37ba3", size = 1088710, upload-time = "2025-08-20T11:55:29.426Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d3/9ba310e07969bc9906eb7548731e33a0f448b122ad9705fed699c9b29345/ujson-5.11.0-cp311-cp311-win32.whl", hash = "sha256:e979fbc469a7f77f04ec2f4e853ba00c441bf2b06720aa259f0f720561335e34", size = 39648, upload-time = "2025-08-20T11:55:31.194Z" }, + { url = "https://files.pythonhosted.org/packages/57/f7/da05b4a8819f1360be9e71fb20182f0bb3ec611a36c3f213f4d20709e099/ujson-5.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:683f57f0dd3acdd7d9aff1de0528d603aafcb0e6d126e3dc7ce8b020a28f5d01", size = 43717, upload-time = "2025-08-20T11:55:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/f3f9ac0f24f00a623a48d97dc3814df5c2dc368cfb00031aa4141527a24b/ujson-5.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:7855ccea3f8dad5e66d8445d754fc1cf80265a4272b5f8059ebc7ec29b8d0835", size = 38402, upload-time = "2025-08-20T11:55:33.641Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" }, + { url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" }, + { url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" }, + { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" }, + { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" }, + { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" }, + { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" }, + { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" }, + { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/30275aa2933430d8c0c4ead951cc4fdb922f575a349aa0b48a6f35449e97/ujson-5.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abae0fb58cc820092a0e9e8ba0051ac4583958495bfa5262a12f628249e3b362", size = 51206, upload-time = "2025-08-20T11:56:48.797Z" }, + { url = "https://files.pythonhosted.org/packages/c3/15/42b3924258eac2551f8f33fa4e35da20a06a53857ccf3d4deb5e5d7c0b6c/ujson-5.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fac6c0649d6b7c3682a0a6e18d3de6857977378dce8d419f57a0b20e3d775b39", size = 48907, upload-time = "2025-08-20T11:56:50.136Z" }, + { url = "https://files.pythonhosted.org/packages/94/7e/0519ff7955aba581d1fe1fb1ca0e452471250455d182f686db5ac9e46119/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b42c115c7c6012506e8168315150d1e3f76e7ba0f4f95616f4ee599a1372bbc", size = 50319, upload-time = "2025-08-20T11:56:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/209d90506b7d6c5873f82c5a226d7aad1a1da153364e9ebf61eff0740c33/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:86baf341d90b566d61a394869ce77188cc8668f76d7bb2c311d77a00f4bdf844", size = 56584, upload-time = "2025-08-20T11:56:52.89Z" }, + { url = "https://files.pythonhosted.org/packages/e9/97/bd939bb76943cb0e1d2b692d7e68629f51c711ef60425fa5bb6968037ecd/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4598bf3965fc1a936bd84034312bcbe00ba87880ef1ee33e33c1e88f2c398b49", size = 51588, upload-time = "2025-08-20T11:56:54.054Z" }, + { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835, upload-time = "2025-08-20T11:56:55.237Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uwsgi" +version = "2.0.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/49/2f57640e889ba509fd1fae10cccec1b58972a07c2724486efba94c5ea448/uwsgi-2.0.31.tar.gz", hash = "sha256:e8f8b350ccc106ff93a65247b9136f529c14bf96b936ac5b264c6ff9d0c76257", size = 822796, upload-time = "2025-10-11T19:17:28.794Z" } + +[[package]] +name = "uwsgitop" +version = "0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/3b/02f373bc88978e8f1f33f5afe625ad28def59b6cfbf93424030754dbd5a5/uwsgitop-0.12.tar.gz", hash = "sha256:4f9330951f0fb9633226de36cf0c28c04dcf323efab608834aa81f638b6019b2", size = 6574, upload-time = "2024-04-01T13:43:52.735Z" } + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + +[[package]] +name = "xmlsec" +version = "1.3.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/14/538b75379e6ab8f688f14d8663e2ab138d9c778bac4999d155b5f33c71c1/xmlsec-1.3.17.tar.gz", hash = "sha256:f3fac9ae679f66585925cc00c5f6839ae36c1d03157619571dee18acc05b9c01", size = 115637, upload-time = "2025-11-11T16:20:46.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/e4/970614d892749da00df253c370230fd24143028268923a1c35651fb3f962/xmlsec-1.3.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4a7ee007c6b55f7621330aee8330ef2dafa4225fce554064571ca826beafe7e", size = 3450577, upload-time = "2025-11-11T16:19:34.159Z" }, + { url = "https://files.pythonhosted.org/packages/50/4a/2f48ad48fecbd49dbbc6f2a5b540cd65277089fd5b8b5d8c7e816c3625c2/xmlsec-1.3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef656421d01851618d0fe5518e57469159c14a48e05125f7bd3225631952f9", size = 3846698, upload-time = "2025-11-11T16:19:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/0130e0b711f7443d0abdec403ea5128392cd5b241bb53f4ec41d144d94db/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80fff2251d0e73714435b5860ce200990dffe85466dd91d08d75c4d64ee9967d", size = 4423233, upload-time = "2025-11-11T16:19:37.129Z" }, + { url = "https://files.pythonhosted.org/packages/00/f7/a4e588d61f602f25a51b6004be9a162e36e746fa1cbeb12248794a96766b/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2bf6bbf04f8a912483d268b4c2727d400d1806d054624da13bee4b9f6fa28a", size = 4163716, upload-time = "2025-11-11T16:19:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a2/f8c019445134dfc59afb5874d1fc4fe212ec2dc45a8c33806a15b5c0c119/xmlsec-1.3.17-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a603584ceee175036e1bccdbe65d551c0fff67343fd506bfa6cec52bc64d9a75", size = 3875404, upload-time = "2025-11-11T16:19:40.008Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/90c0e26bb9f95799c64874ebee0b43eaf7e5b5ba912bcd87ed4cc46ea514/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26cc3d81437b51839946d2e93d09371dfd73ed2831dc7e37eff0fb52fc33747c", size = 4460640, upload-time = "2025-11-11T16:19:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/be/7b85b0ff4281779293d93a8bbef70a6b72ba60d8a80d15653bd4967d0c07/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d862f023f56a49c06576be41dfaf213c9ac77e7a344e7f204278c365bb36d00e", size = 4209625, upload-time = "2025-11-11T16:19:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6d/028472e523c2f667a4634881b65acfa939bc4902ed37e1e9fe1d55d45ec0/xmlsec-1.3.17-cp311-cp311-win_amd64.whl", hash = "sha256:9877303e8c72d7aa2467d1af12e56d67b8fb50d324eda5848e0ec5ee2176aac5", size = 2445935, upload-time = "2025-11-11T16:19:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f0/01/d36fd82b837167546951e7e088dbd2f0dacf553157d256b2a25802d28a95/xmlsec-1.3.17-cp311-cp311-win_arm64.whl", hash = "sha256:b3f306f5aef47336b8299d8dbee31fa0b2eba4579f9f41396070f7a97d0dcd49", size = 2261485, upload-time = "2025-11-11T16:19:46.212Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a5/d91216f7dbb85cb65cb7249fcc894f5389a8a4843857aff678646cab77fa/xmlsec-1.3.17-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df4a8d7fef3ffe90e572400d47392ea480120e339c292f802830ed09d449e622", size = 3450960, upload-time = "2025-11-11T16:19:47.794Z" }, + { url = "https://files.pythonhosted.org/packages/b7/38/c37bd4e164259e0b271fe4d17d054f31c7287a1e4c47d24ef77d723b3493/xmlsec-1.3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed63cbd87dd69ebcf3a9f82d87b67818c9a7d656325dd4fb34d6c4dfbaa84017", size = 3846774, upload-time = "2025-11-11T16:19:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ff/83430c5df33c6ad402728a681998c5b2872c090b556a558d02f8cf1d2f24/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c3008b32a15d24b6c9da39bf6ede8dc3122570a640a73795d763aea55a2193e", size = 4425910, upload-time = "2025-11-11T16:19:50.95Z" }, + { url = "https://files.pythonhosted.org/packages/02/41/bb94c7a97ea613b3860f6152bb7efcf5be524d135592e094ecc64ff79228/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0b9a1dcda547e0340eefa6f4a04b87dbd9e40cd514487f347934f94fd559ab", size = 4169038, upload-time = "2025-11-11T16:19:52.217Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/852ba0805df27b7bd1e88e9524d9573b076c3a126e936b1f18c6f22fb968/xmlsec-1.3.17-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a53c14d4bc40b0f0fcc6d7908b88f3cbbcf36e25c392f796d88aee7dee5beea", size = 3876430, upload-time = "2025-11-11T16:19:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/08fec6adc65f6911b49b4fa71e920c8f6434f44fdc427c71360e6dd9e9ce/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5346616e1fe1015f7800698c15225c7902f45db199e217af2039a21989aff7e9", size = 4464419, upload-time = "2025-11-11T16:19:54.777Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/84789ba3929715806deae88f10bc31e1ff904aa735059ee3855c104a142d/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64c1184d51c8a67e3d1eb3ac477e307a07e2b40fd03cd0c8084b147ea0f342db", size = 4215080, upload-time = "2025-11-11T16:19:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/57b5054187cd2b42e5310dc1f6d209fced456f93dae25345a422b3a290ef/xmlsec-1.3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d360d4adfb53d3adeca398c225cb7e2a73a2246414455937082a1fa19bd8572b", size = 2445872, upload-time = "2025-11-11T16:19:57.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/7b/f64c95df054dd793ae1925f04248abd359b1c26cc2320d67407e7fd26e4d/xmlsec-1.3.17-cp312-cp312-win_arm64.whl", hash = "sha256:eee89c268a35f8a08a8e9abef6f466b97577e94f5cac8bf32c25e97cd5020097", size = 2261464, upload-time = "2025-11-11T16:19:58.937Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/d0c03351bbf776f2272d602272ca9d759d48f0f4e90707987098abb48e14/xmlsec-1.3.17-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:672e41dc7962da4ce84b67aa1c3a008338e3b88332f5484b9911b91cee0997ed", size = 3450899, upload-time = "2025-11-11T16:20:00.29Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/00db758c40d42ae2d43603552262b1027c02bbac934be26425e820c63c0f/xmlsec-1.3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72fc6d336dd68d62822c6536ff4b2453fda94ea652eddb4a958ac97b16ac7001", size = 3846790, upload-time = "2025-11-11T16:20:01.515Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/00cd12243f5f8cccec23e0d9946379861b954bf98c52d3f68b9eb565ba76/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae88c3aaab5704adfdbce913b3a18db1eb96c49c970657cc01c0d1c420ffdec3", size = 4427662, upload-time = "2025-11-11T16:20:02.931Z" }, + { url = "https://files.pythonhosted.org/packages/77/64/d198a8109c11124b01abbd34167dd951896b12392ccfc3f12c40eb3f0c35/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79b471fdd1d3a92b80907828eaa809f6e34023583488b1b8dc3f951529e7a2f8", size = 4170229, upload-time = "2025-11-11T16:20:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/3e061f10d0d921102a55b4c0442c8c5af4e01e175ea1584774eeef2e50aa/xmlsec-1.3.17-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:040f28a7aacfdb467df46d423e4af05569e9376bc8c7f6416b0761e16a0e3d0b", size = 3877622, upload-time = "2025-11-11T16:20:05.593Z" }, + { url = "https://files.pythonhosted.org/packages/37/1a/b8a71915bf1d59944d815c92e77a06e9c2dc4dc855a44a3127c86b0dd7f2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67717fe5151df68987a1387cba11ba28ce19b3bb9a2d10d650277cd910e510e7", size = 4464934, upload-time = "2025-11-11T16:20:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/4b9057c6049137256bb972d114d2858fc8b24e72c97e05e26a00d2db8ed2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9bb6faa4ae0268204cfa6b0c0de1c9121eb606eea8c66c7d7ce62e89a17f9efa", size = 4215150, upload-time = "2025-11-11T16:20:10.008Z" }, + { url = "https://files.pythonhosted.org/packages/95/6b/a2e8bc2f94b90c2904007663c8162423fadd3cd98b7ca1632b66dcdc31cb/xmlsec-1.3.17-cp313-cp313-win_amd64.whl", hash = "sha256:66fe5aaccf68fb85fe0b64277e3f594d6b01ddefb98ef1ceb0a666652d6ec580", size = 2445890, upload-time = "2025-11-11T16:20:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/27210baa675eb9e5d80ed43e80d865be8fbf6148ea464d2b4d4ad1ba9f01/xmlsec-1.3.17-cp313-cp313-win_arm64.whl", hash = "sha256:5319d0bdaf9e597a0ba8dfb3840c4ae57e51f462e7620953f32b07df6267f2ba", size = 2261424, upload-time = "2025-11-11T16:20:12.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/2c/0169a383769d563f6582d5b3a2ccf7f612f4bf98cbd417a27287443b63c5/xmlsec-1.3.17-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d0e69291f90b28e9442d8e0e69d3e06cede8a3c44e856413fd284de81ce2888", size = 3450932, upload-time = "2025-11-11T16:20:14.334Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/be65923c5aa3097f422af3d917ffda15590ab0f4c9a5a5d78d520ae7fc9a/xmlsec-1.3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5616ad5016794b0dd41d03eef5b721e31bb306353226b25fc88fedb7d4f7c37e", size = 3847248, upload-time = "2025-11-11T16:20:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/1b/58/24e047e6a5f0c266e949c7c03c2770163038e7abd322c95bfbae021f9477/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4be73fbde421d6188300e02ad92d2d5435c708a35ede8124ebdf6b00330d7cb", size = 4428590, upload-time = "2025-11-11T16:20:18.012Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/e5212147d227da638311287045c90a47bb560b0552cc7daca0919a870220/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3961102a6ba8250670814bd1086139fb918e03bf146ef85dc8b6084a9b027d1", size = 4169645, upload-time = "2025-11-11T16:20:19.646Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/ed1f6d18f7c10dc61f791aade218b2271b4fc3092dd499036bc391a32945/xmlsec-1.3.17-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:728058a1623a620811a3cdf2dd4894b5d9413ede20c8ddddf98fdea5eafe9529", size = 3878531, upload-time = "2025-11-11T16:20:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/09050fd1dc109ebe5bfefd0eab0829cab4fae51b3a244949e31dccf144e1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:593264c192d1836162d75478c8b1cb5874f3b69dcc5bdfac642a0933abefa93a", size = 4464490, upload-time = "2025-11-11T16:20:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2e/52e9ef2b5c8ef2470e1e3ae3ef89f7ac45eecd267c7b3bab8a7ad7d68af1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d1fc1fbe2e8585a3f468cf4154d0ec36cd95a15e68429ad8cc8ccd7c04e84ae", size = 4214358, upload-time = "2025-11-11T16:20:24.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cd/5e9061027a203fd083b6058c2948ee1a16bd909d3a0e331e054362ca550e/xmlsec-1.3.17-cp314-cp314-win_amd64.whl", hash = "sha256:e2bf1d07c4f97afeb957f626b8c3ebb8cef300efa0cb95599e936c69a66a1b17", size = 2513252, upload-time = "2025-11-11T16:20:25.738Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/b2f4b9092434b854bcae0d901c10a7e96d2a12d03cc35dbf7a7b2c91502b/xmlsec-1.3.17-cp314-cp314-win_arm64.whl", hash = "sha256:3a6ced8c7744e896cb5a9fd0156d204df3143a62bae11be91cab8e9743d40eec", size = 2328451, upload-time = "2025-11-11T16:20:27.247Z" }, +] diff --git a/backend/uwsgi.ini b/backend/uwsgi.ini new file mode 100755 index 000000000..592fbe7dc --- /dev/null +++ b/backend/uwsgi.ini @@ -0,0 +1,118 @@ +# https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html +# https://pythonise.com/series/learning-flask/python-flask-uwsgi-introduction +# https://uwsgi-docs.readthedocs.io/en/latest/ConfigLogic.html +# https://www.techatbloomberg.com/blog/configuring-uwsgi-production-deployment/ + +[uwsgi] +; FIXME: why do we need this? it complained about "listen" from the CLI not being OK... +; it means: Fail to start if unknown config parameter found +; strict = true + +protocol = uwsgi + +master = true +module = backend:app +need-app = true ; Fail to start if application cannot load + +processes = 1 +if-env = UWSGI_PROCESSES +processes = $(UWSGI_PROCESSES) +threads = 4 # Each process gets multiple threads to handle concurrency +endif = + +; the root user has this setup already +; but we might be runner as a different user... +env = LANG=C.utf8 +env = LC_ALL=C.UTF-8 +env = LC_LANG=C.UTF-8 +env = PYTHONIOENCODING=UTF-8 + +; https://uwsgi-docs.readthedocs.io/en/latest/Cheaper.html#:~:text=To%20enable%20cheaper%20mode%20add,(%20workers%20or%20processes%20option). +if-env = UWSGI_CHEAPER_ALGO_BUSYNESS +cheaper-algo = busyness +processes = $(UWSGI_PROCESSES) ; Maximum number of workers allowed +cheaper = $(UWSGI_CHEAPER) ; Minimum number of workers allowed +cheaper-initial = $(UWSGI_CHEAPER_INITIAL) ; Workers created at startup +cheaper-overload = $(UWSGI_CHEAPER_OVERLOAD) ; Length of a cycle in seconds +cheaper-step = 16 ; How many workers to spawn at a time +cheaper-busyness-multiplier = 30 ; How many cycles to wait before killing workers +cheaper-busyness-min = 20 ; Below this threshold, kill workers (if stable for multiplier cycles) +cheaper-busyness-max = 70 ; Above thiss threshold, spawn new workers +cheaper-busyness-backlog-alert = 16 ; Spawn emergency workers if more than this many requests are waiting in the queue +cheaper-busyness-backlog-step = 2 ; How many emergegency workers to create if there are too many requests in the queue + +# if-env = UWSGI_CHEAPER_RSS_LIMIT_SOFT +# soft limit will prevent cheaper from spawning new workers if workers total rss memory is equal or higher (values are in bytes) +cheaper-rss-limit-soft = $(UWSGI_CHEAPER_RSS_LIMIT_SOFT) +# endif = + +# if-env = UWSGI_CHEAPER_RSS_LIMIT_HARD +# hard limit will force cheaper to cheap single worker if workers total rss memory is equal or higher (values are in bytes) +cheaper-rss-limit-hard = $(UWSGI_CHEAPER_RSS_LIMIT_HARD) +# endif = + + +endif = + +if-env = UWSGI_UID +uid = %(_) +endif = +if-env = UWSGI_GID +gid = uucp +endif = + +# **Queue & Load Management** +listen = 1024 # Increase socket backlog queue +so-keepalive = true # Enable TCP keepalive +socket-timeout = 180 # Timeout if a request takes too long +post-buffering = 8192 # Buffer large POST requests (reduce worker blocking) +http-timeout = 180 # Kill slow HTTP connections +ignore-sigpipe = true # Avoid SIGPIPE errors from client disconnects +# we need to increase it a bit from the 4096 default +# https://stackoverflow.com/questions/15878176/uwsgi-invalid-request-block-size +buffer-size = 32768 # Allow larger request headers + + +# use a port +socket = :3000 +chmod-socket = 666 +# removes the socket when the process stops +vacuum = true +# well-behaved when running with an init system like systemd +die-on-term = true ; shutdown when reciving SIGTERM + +# if ever need threads +enable-threads = true +;thread-stacksize = 512 + +# https://github.com/unbit/uwsgi/issues/1978 +; py-call-osafterfork = true ; allows workers to receive signals. + + +if-env = UWSGI_STATS +memory-report = true +stats = /tmp/stats.sock +# we could also do... +# stats = 127.0.0.1:3001 +endif = + +single-interpreter = true + +# timeout to kill requests +harakiri = 300 + +; disable-logging = true +; log-4xx = true +; log-5xx = true + +# Worker Recycling +max-requests = 1000 ; Restart workers after this many requests +max-requests-delta = 100 ; avoid reload of workers simultaneously +max-worker-lifetime = 3600 ; Restart workers after this many seconds +max-worker-lifetime-delta = 15 ; avoid respawn of workers at the same time +reload-on-rss = 4096 ; Restart workers after this much resident memory +worker-reload-mercy = 30 ; how long to wait before forcefully killing workers +thunder-lock = true ; Prevents multiple workers from grabbing the same request + +# Process Labeling +auto-procname = true diff --git a/backend/wait-for-it.sh b/backend/wait-for-it.sh new file mode 100755 index 000000000..ccc0d739f --- /dev/null +++ b/backend/wait-for-it.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# From https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh +# The MIT License (MIT) +# Copyright (c) 2016 Giles Hall +# +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi \ No newline at end of file diff --git a/deployments/.env.sirc.example b/deployments/.env.sirc.example new file mode 100644 index 000000000..aa625e6bf --- /dev/null +++ b/deployments/.env.sirc.example @@ -0,0 +1,20 @@ +# SIRC deployment secrets +# Copy to .env in the repo root (or deployments/.env) and fill in values. +# These are referenced by deployments/sirc.yml via ${VAR} interpolation. + +# LDAP +QABOARD_LDAP_HOST= +QABOARD_LDAP_USER_BASE= +QABOARD_LDAP_BIND_DN= +QABOARD_LDAP_PASSWORD= + +# Sentry (backend) +SENTRY_DSN= + +# Sentry (frontend build args) +REACT_APP_SENTRY_DSN= +SENTRY_AUTH_TOKEN= + +# PostHog +REACT_APP_POSTHOG_HOST= +REACT_APP_POSTHOG_TOKEN= diff --git a/qaboard-webapp/src/index.css b/deployments/.gitkeep similarity index 100% rename from qaboard-webapp/src/index.css rename to deployments/.gitkeep diff --git a/deployments/dsk/cli/pyproject.toml b/deployments/dsk/cli/pyproject.toml new file mode 100644 index 000000000..d88770ea8 --- /dev/null +++ b/deployments/dsk/cli/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "qaboard-site-dsk" +version = "1.0.0" +description = "QABoard site defaults for Samsung DSK" +requires-python = ">=3.7" +dependencies = [ + "cde @ git+ssh://git@github.sec.samsung.net/CDE/cde-python", +] + +[project.entry-points."qaboard.site"] +dsk = "qaboard_site_dsk:defaults" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/deployments/dsk/cli/qaboard_site_dsk/__init__.py b/deployments/dsk/cli/qaboard_site_dsk/__init__.py new file mode 100644 index 000000000..2931ecd26 --- /dev/null +++ b/deployments/dsk/cli/qaboard_site_dsk/__init__.py @@ -0,0 +1,5 @@ +"""QABoard site defaults for Samsung DSK (Device Solution Korea).""" + +defaults = { + "QABOARD_URL": "https://qaboard.samsungds.net", +} diff --git a/deployments/dsk/dsk.yml b/deployments/dsk/dsk.yml new file mode 100644 index 000000000..bcfb3fede --- /dev/null +++ b/deployments/dsk/dsk.yml @@ -0,0 +1,75 @@ +# DSK (Device Solution Korea) compose overlay +# Usage: docker compose -f docker-compose.yml -f production.yml -f deployments/dsk/dsk.yml up + +# Set these in .env or override below with your DSK proxy/cert values: +# PROXY_URL, CA_CERT_URL, NO_PROXY +x-dsk-build-args: &dsk-build-args + PROXY_URL: ${PROXY_URL:-} + CA_CERT_URL: ${CA_CERT_URL:-} + GIT_SERVER: "github.sec.samsung.net" + CDE_PACKAGE: "cde @ git+ssh://git@github.sec.samsung.net/CDE/cde-python" + NO_PROXY: ${NO_PROXY:-} + +services: + backend: + build: + args: + <<: *dsk-build-args + QABOARD_EXTRA: "dsk" + env_file: + - .env + - deployments/dsk/.env + environment: + - QABOARD_LOGIN_TYPE=${QABOARD_LOGIN_TYPE:-SAML} + - QABOARD_TUNING_RUNNER=celery + - QABOARD_DEFAULT_USER=qaboard + - QABOARD_HOST=${QABOARD_HOST:-12.36.168.155} + - QABOARD_PROTOCOL=http + - CELERY_BROKER_URL=pyamqp://guest:guest@rabbitmq:5672// + - 'QABOARD_IMAGE_SERVERS={"default": "/iiif", "raw,hex,cde": "/iiif/cde"}' + + frontend: + build: + args: + <<: *dsk-build-args + environment: + - REACT_APP_QABOARD_LOGIN_TYPE=${QABOARD_LOGIN_TYPE:-SAML} + - REACT_APP_QABOARD_LOGIN_REQUIRED=${QABOARD_LOGIN_REQUIRED:-} + + proxy: + build: + args: + <<: *dsk-build-args + volumes: + - ./deployments/dsk/nginx/qaboard.conf:/tmp/deployment/nginx/conf.d/qaboard.conf:ro + + flower: + build: + args: + <<: *dsk-build-args + + cron-backup-db: + environment: + - AS_USER_NAME=qaboard + - AS_USER_UID=1001 + - AS_USER_GID=1001 + volumes: + - /var/qaboard/data/backups:/backup + + + # iiif-cde: + # # TODO: sync with DSK the new image + # image: gitlab-srv:4567/swi/iipsrv + # restart: always + # # command: 'tail -f /dev/null' # keeps the container up for debugging, but does nothing... + # # volumes: + # # - TBD + # user: "${USER_ID}:${USER_GID}" + # environment: + # - LOGFILE=/tmp/socket/log + # - VERBOSITY=10 + # - MAX_CVT=-1 + # - PNG_QUALITY=9 + # - CORS=* + # - FILESYSTEM_PREFIX=/var/www/localhost/images + # - HOME=/home/${USER} diff --git a/deployments/dsk/nginx/dsk.conf b/deployments/dsk/nginx/dsk.conf new file mode 100644 index 000000000..308a31d2c --- /dev/null +++ b/deployments/dsk/nginx/dsk.conf @@ -0,0 +1,99 @@ +# DSK-specific nginx configuration +# Mounted by deployments/dsk/dsk.yml into /etc/nginx/conf.d/dsk.conf + +server { + server_name qaboard; + listen 443 ssl default_server; + + ssl_certificate /etc/nginx/ssl/server.crt; + ssl_certificate_key /etc/nginx/ssl/server.key; + + absolute_redirect off; + + location /docs { + alias /docs/; + try_files $uri $uri/index.html $uri.html /index.html; + } + + location / { + root /builds; + try_files $uri $uri/index.html $uri.html /index.html; + } + + location ^~ /api { + include uwsgi_params; + uwsgi_pass backend; + uwsgi_read_timeout 600; + include cors; + + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Path /; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + } + + location ^~ /webhook { + include uwsgi_params; + uwsgi_pass backend; + include cors; + } + + # DSK file serving + location ^~ /s/algo/ { + alias /algo/; + autoindex on; + access_log off; + include cors; + gzip_static on; + gunzip on; + } + + location ^~ /s/mnt/ { + alias /mnt/; + autoindex on; + access_log off; + include cors; + } + + # WebDAV + location /davs/algo/ { + alias /algo/; + include /etc/nginx/webdav.conf; + } + + # IIIF Cantaloupe + location /iiif/2 { + proxy_pass http://cantaloupe:8182/iiif/2; + } + + # IIIF CDE + location /iiif/cde { + proxy_pass http://iiif-cde:9000; + } + + # Metabase + # location /metabase/ { + # proxy_pass http://metabase:3000/; + # proxy_connect_timeout 600; + # proxy_send_timeout 600; + # proxy_read_timeout 600; + # send_timeout 600; + # } + + location ~ ^/metabase/(https?.*) { + return 302 $1; + } + + # Flower + location /flower/ { + proxy_pass http://flower:8888/flower/; + proxy_set_header Host $host; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/deployments/example.yml b/deployments/example.yml new file mode 100644 index 000000000..3f3355525 --- /dev/null +++ b/deployments/example.yml @@ -0,0 +1,40 @@ +# Example site overlay for QABoard +# Copy this file and customize for your deployment. +# +# Usage: docker compose -f docker-compose.yml -f production.yml -f deployments/mysite.yml up + +services: + backend: + environment: + # Authentication: LOCAL (default), LDAP, or SAML + # - QABOARD_LOGIN_TYPE=LDAP + # - QABOARD_LDAP_HOST=ldap.example.com + # - QABOARD_LDAP_USER_BASE=dc=example,dc=com + + # Tuning job dispatch: local (default), celery, or lsf + # - QABOARD_TUNING_RUNNER=lsf + # - QA_RUNNERS_LSF_BRIDGE=ssh user@lsf-host 'bsub_su {user} -I {bsub_command}' + + # Image servers (JSON map of file extensions to IIIF endpoints) + # - QABOARD_IMAGE_SERVERS={"default": "/iiif"} + + # Observability (set to enable) + # - SENTRY_DSN=https://...@sentry.example.com/1 + + # Output path user (for multi-user environments) + # - QABOARD_DEFAULT_USER=qaboard + [] + + # Uncomment to customize frontend: + # frontend: + # environment: + # - REACT_APP_QABOARD_LOGIN_TYPE=LDAP + + # Add custom nginx config: + # proxy: + # volumes: + # - ./deployments/nginx/mysite.conf:/etc/nginx/conf.d/mysite.conf:ro + + # Disable cantaloupe if using a different image server: + # cantaloupe: + # profiles: [disabled] diff --git a/deployments/sirc/.envrc b/deployments/sirc/.envrc new file mode 100755 index 000000000..33ee7f9e1 --- /dev/null +++ b/deployments/sirc/.envrc @@ -0,0 +1,20 @@ +#!/bin/bash + +# npm/node +export PATH=/home/arthurf/opt/node/node-v22.14.0-linux-x64/bin:$PATH +# We assume the user ran `npm config set strict-ssl false` +export NODE_TLS_REJECT_UNAUTHORIZED=0 + +# convenience utilities +export PATH=/home/arthurf/opt/bin:$PATH +export LD_LIBRARY_PATH=/home/arthurf/opt/lib64:/home/arthurf/opt/lib:$LD_LIBRARY_PATH +export PATH=/home/ispq/miniconda3/envs/py311/bin:$PATH + +# Proxy configuration +export http_proxy=http://webproxy:8080 +export https_proxy=$http_proxy +export HTTP_PROXY=$http_proxy +export HTTPS_PROXY=$http_proxy +export ftp_proxy=$http_proxy +export no_proxy=localhost,gitlab-srv,gitlab-srv.transchip.com,qa +export NO_PROXY=$no_proxy diff --git a/deployments/sirc/cli/pyproject.toml b/deployments/sirc/cli/pyproject.toml new file mode 100644 index 000000000..5871537b2 --- /dev/null +++ b/deployments/sirc/cli/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "qaboard-site-sirc" +version = "1.0.0" +description = "QABoard site defaults for Samsung SIRC" +requires-python = ">=3.7" +dependencies = [ + "cde @ git+ssh://git@gitlab-srv/cde/cde-python", +] + +[project.entry-points."qaboard.site"] +sirc = "qaboard_site_sirc:defaults" + +[project.entry-points."qaboard.hooks"] +fix_permissions = "qaboard_site_sirc.hooks:fix_permissions" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/deployments/sirc/cli/qaboard_site_sirc/__init__.py b/deployments/sirc/cli/qaboard_site_sirc/__init__.py new file mode 100644 index 000000000..eaa3be531 --- /dev/null +++ b/deployments/sirc/cli/qaboard_site_sirc/__init__.py @@ -0,0 +1,35 @@ +"""QABoard site defaults for Samsung SIRC.""" +import os +import json + +defaults = { + "QABOARD_URL": "https://qa", + "QABOARD_API_PREFIX": "http://qa:5000", + # We want to allow users to use the Gitlab API (limited scope: CI statuses) without having to login + # to stay backward compatible and not have credentials in any repo + "QA_SECRETS": '/home/ispq/.secrets.yaml' if os.name != 'nt' else '//mars/raid/users/ispq/.secrets.yaml', + "QABOARD_PATH_MAPPINGS": json.dumps([ + ["\\\\netapp\\algo_data", "/stage/algo_data"], + ["\\\\netapp2\\algo_data", "/stage/algo_data"], + ["\\\\netapp\\algo-datasets", "/stage/algo-datasets"], + ["\\\\f2\\algo_archive", "/stage/algo_archive"], + ["\\\\mars\\stage\\jenkins_ws", "/stage/jenkins_ws"], + ["\\\\mars\\stage\\algo_jenkins_ws", "/stage/algo_jenkins_ws"], + ["\\\\mars\\raid\\data\\DATASYNC", "/raid/data/DATASYNC"], + ["\\\\netapp\\algo_ws", "/algo/ws"], + ["\\\\netapp\\vol23_algo", "/algo"], + ["\\\\netapp\\vol24_algo", "/algo"], + ["\\\\mars\\algo", "/algo"], + ["\\\\mars\\raid\\algo", "/algo"], + ["\\\\mars\\raid", "/raid"], + ["\\\\mars\\stage\\algo_db", "/stage/algo_db"], + ["\\\\netapp\\raid\\users", "/home"], + ["\\\\netapp\\QA-Data", "/stage/qa_data"], + ["\\\\f2\\algo-datasets", "/stage/algo-datasets"], + ["\\\\mars\\data", "/data"], + ["\\\\netapp\\Joint", "/net/netapp/vol/home_nt/Joint"], + ["\\\\mars\\sim", "/sim"], + ["\\\\mars\\stage", "/stage"], + ["\\\\netapp\\vol19_data", "/net/netapp/vol/vol19_data"], + ]), +} diff --git a/deployments/sirc/cli/qaboard_site_sirc/hooks.py b/deployments/sirc/cli/qaboard_site_sirc/hooks.py new file mode 100644 index 000000000..76d1bdd84 --- /dev/null +++ b/deployments/sirc/cli/qaboard_site_sirc/hooks.py @@ -0,0 +1,45 @@ +"""SIRC-specific hooks for QABoard.""" +import os +import shlex + +import click + + +def fix_permissions(path): + """SIRC-specific: SSH to VDI and chmod shared NFS paths.""" + from getpass import getuser + from qaboard.compat import windows_to_linux_path + + click.secho("... Fixing linux file permissions", err=True) + try: + # Windows does not set file permissions correctly on the shared storage, + # it does not respect umask 0: files are not world-writable. + # Trying to each_file.chmod(0o777) does not work either + # The only option is to make the call from linux. + # We could save a list of paths and chmod them with their parent directories... + # but to make things faster to code, we just "ssh linux chmod everything" + # We can assume SSH to be present on Windows10 + + # Check if Git for Windows SSH exists and use it instead of PATH ssh + # It helps as ACLs prevent network path from being used as keys with the builtin ssh + git_ssh_path = r"C:\Program Files\Git\usr\bin\ssh.exe" + if os.name == 'nt' and os.path.exists(git_ssh_path): + ssh_cmd = git_ssh_path + else: + ssh_cmd = "ssh" + + user = getuser() + ssh = f"{shlex.quote(ssh_cmd)} -i \\\\netapp\\raid\\users\\{user}\\.ssh\\id_rsa -oStrictHostKeyChecking=no" + hostname = f"{user}-vdi" if user != "sircdevops" else "qa" + + def windowsize(path): + return windows_to_linux_path(path).as_posix() + + # usually we use this function for artifact folders, but if the parent dir + # was also created it will have permissions too restrictive too, + # and it will break other commits! + chmod = f'{ssh} {user}@{hostname} \'chmod -R 777 "{windowsize(path)}"; chmod 777 "{windowsize(path.parent)}"\'' + click.secho(chmod, err=True) + os.system(chmod) + except Exception as e: + click.secho(f'WARNING: {e}', err=True) diff --git a/deployments/sirc/fluentd/fluentd.Dockerfile b/deployments/sirc/fluentd/fluentd.Dockerfile new file mode 100644 index 000000000..de40acf97 --- /dev/null +++ b/deployments/sirc/fluentd/fluentd.Dockerfile @@ -0,0 +1,36 @@ +# fluentd/Dockerfile + +FROM fluent/fluentd:v1.16.6-debian-1.0 + +USER root + +# Proxy/cert configuration — no-ops when args are empty (open-source builds) +ARG PROXY_URL="" +ARG CA_CERT_URL="" +ARG NO_PROXY="" + +RUN if [ -n "$PROXY_URL" ]; then \ + echo "Acquire::http::Proxy \"$PROXY_URL\";" >> /etc/apt/apt.conf && \ + echo 'Acquire::https::Verify-Peer "false";' >> /etc/apt/apt.conf; \ + fi +ENV HTTP_PROXY=${PROXY_URL} http_proxy=${PROXY_URL} \ + HTTPS_PROXY=${PROXY_URL} https_proxy=${PROXY_URL} \ + NO_PROXY=${NO_PROXY} + +RUN apt-get update -qq && \ + apt-get install -y ca-certificates wget && \ + if [ -n "$CA_CERT_URL" ]; then \ + wget "$CA_CERT_URL" -P /usr/local/share/ca-certificates/ && \ + update-ca-certificates; \ + fi +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + +RUN apt-get install -y strace curl wget + +# RUN ["gem", "install", "fluent-plugin-elasticsearch", "--no-document", "--version", "5.4.3"] +# RUN ["gem", "install", "fluent-plugin-concat", "--no-document", "--version", "2.5.0"] +RUN gem install fluent-plugin-elasticsearch:5.4.3 \ + fluent-plugin-concat:2.5.0 \ + fluent-plugin-grafana-loki --no-document + +USER fluent diff --git a/deployments/sirc/fluentd/fluentd.conf b/deployments/sirc/fluentd/fluentd.conf new file mode 100644 index 000000000..2631777dc --- /dev/null +++ b/deployments/sirc/fluentd/fluentd.conf @@ -0,0 +1,41 @@ +######################## +# # +# Author: Itayk # +# # +######################## + +# Define system configurations for fluentd + + # This doesnt work, default umask is set to 0022. Made a changes to the image to run as user 111778(sircdevops). + file_permission 777 + + +# Define a source to get lgos from, the fluentd logging driver forwards container logs. + + @type forward + port 24224 + bind 0.0.0.0 + + +# Match labels to outputs + + @type copy + + + @type elasticsearch + scheme http + host elk-srv.transchip.com + port 9200 + logstash_format true + logstash_prefix qaboard + logstash_dateformat %m%Y + include_tag_key true + type_name access_log + user elastic + password changeme + # template_name + # pipeline + + + + diff --git a/deployments/sirc/metabase.yml b/deployments/sirc/metabase.yml new file mode 100644 index 000000000..015302aa7 --- /dev/null +++ b/deployments/sirc/metabase.yml @@ -0,0 +1,96 @@ +# Metabase is hosted on docker-prod-srv-1 +# TODO: Move this elsewhere! +version: "3.5" + +volumes: + metabase-db: {} + metabase-data: {} + +services: + metabase-db: + image: postgres:16 + restart: always + ports: + - 5434:5432 + environment: + POSTGRES_USER: metabase + POSTGRES_DB: metabase + POSTGRES_PASSWORD: metabase + PGDATA: /var/lib/postgresql/data + volumes: + - metabase-db:/var/lib/postgresql/data + + metabase: + build: + # Extends the usual "image: metabase/metabase" with proxy/ssl config + dockerfile: services/metabase.Dockerfile + context: . + restart: always + ports: + - 3005:3000 + volumes: + - metabase-data:/metabase-data + - /raid/tools:/raid/tools + - /home/ispq:/home/ispq + depends_on: + - metabase-db + # https://www.metabase.com/docs/latest/operations-guide/environment-variables.html + environment: + # Database + MB_DB_TYPE: postgres + MB_DB_HOST: metabase-db + MB_DB_PORT: 5432 + MB_DB_DBNAME: metabase + MB_DB_USER: metabase + MB_DB_PASS: metabase + + # General configuration + MB_SITE_URL: https://qa/metabase + MB_ADMIN_EMAIL: arthur.flam@samsung.com + MB_ANON_TRACKING_ENABLED: "false" + MB_ENABLE_EMBEDDING: "true" + MB_ENABLE_PUBLIC_SHARING: "true" + MB_QUERY_CACHING_MIN_TTL: 1 + JAVA_TIMEZONE: Asia/Jerusalem + + # Email + MB_EMAIL_FROM_ADDRESS: auto_delivery@samsung.com + MB_EMAIL_SMTP_HOST: dag.sirc.co.il + # MB_EMAIL_SMTP_PASSWORD + # MB_EMAIL_SMTP_USERNAME + + # LDAP + MB_LDAP_ENABLED: "true" + MB_LDAP_USER_BASE: "OU=Sirc Users,DC=transchip,DC=com" + MB_LDAP_BIND_DN: ldapquery@transchip.com + MB_LDAP_PASSWORD: REDACTED_LDAP_PASSWORD + MB_LDAP_HOST: transchip.com + MB_LDAP_USER_FILTER: "(&(objectClass=user)(|(sAMAccountName={login})))" + + healthcheck: + test: curl --fail -I http://localhost:3000/api/health || exit 1 + interval: 15s + timeout: 5s + retries: 5 + + backup: + image: postgres:16 + # sircdevops + user: 111778:10 + depends_on: + - metabase-db + volumes: + - /raid/tools/devops/metabase/backups:/backups + environment: + POSTGRES_DB: metabase + POSTGRES_USER: metabase + POSTGRES_PASSWORD: metabase + POSTGRES_HOST: metabase-db + BACKUP_DAYS: 7 # Keep only the last 7 backups + entrypoint: > + /bin/sh -c " + while true; do + PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h metabase-db -U $$POSTGRES_USER -d $$POSTGRES_DB -F c -f /backups/metabase-$(date +%Y-%m-%d).dump && echo 'Backup OK!'; + find /backups -type f -name 'metabase-*.dump' -mtime +$$BACKUP_DAYS -delete; + sleep 86400; + done" diff --git a/deployments/sirc/nginx/dvd.conf b/deployments/sirc/nginx/dvd.conf new file mode 100644 index 000000000..db8aa96eb --- /dev/null +++ b/deployments/sirc/nginx/dvd.conf @@ -0,0 +1,11 @@ +server { + listen 5003; + server_name dvd; + + access_log /tmp/dvd-access.log; + error_log /tmp/dvd-error.log; + + location / { + proxy_pass http://qatools01:5044/; + } +} diff --git a/deployments/sirc/nginx/publish.conf b/deployments/sirc/nginx/publish.conf new file mode 100644 index 000000000..43ea32048 --- /dev/null +++ b/deployments/sirc/nginx/publish.conf @@ -0,0 +1,15 @@ +server { + listen 5003; + server_name publish; + + listen 443 ssl; + ssl_certificate_key /ssl/publish.key; + ssl_certificate /ssl/publish.pem; + + access_log /tmp/publish-access.log; + error_log /tmp/publish-error.log; + + location / { + proxy_pass http://qatools01:5045/; + } +} diff --git a/deployments/sirc/nginx/qaboard.conf b/deployments/sirc/nginx/qaboard.conf new file mode 100644 index 000000000..1d0aeb838 --- /dev/null +++ b/deployments/sirc/nginx/qaboard.conf @@ -0,0 +1,128 @@ +# SIRC-specific nginx configuration +# Mounted by deployments/sirc/sirc.yml into /etc/nginx/conf.d/sirc.conf +upstream backend { + server backend:3000; +} + +server { + server_name qa qatools01 proxy; + listen 443 ssl default_server; + listen 5000; + listen 5002; + listen 5003; + listen 8080; + + ssl_certificate /ssl/cert.cer; + ssl_certificate_key /ssl/cert.key; + + absolute_redirect off; + + location /docs { + alias /docs/; + try_files $uri $uri/index.html $uri.html /index.html; + } + + location /docs/cis_utils/ { + alias /algo/ws/ispq/docs/cis_utils/build/html; + try_files $uri $uri/index.html $uri.html /index.html; + } + + location / { + root /builds; + try_files $uri $uri/index.html $uri.html /index.html; + } + + location ^~ /api { + include uwsgi_params; + uwsgi_pass backend; + uwsgi_read_timeout 300; + include cors; + + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Path /; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + } + + location ^~ /webhook { + include uwsgi_params; + uwsgi_pass backend; + include cors; + } + + # SIRC-specific file serving locations + location ^~ /s/algo/ { + alias /algo/; + autoindex on; + access_log off; + include cors; + gzip_static on; + gunzip on; + } + + location ^~ /s/stage/ { + alias /stage/; + autoindex on; + access_log off; + include cors; + gzip_static on; + gunzip on; + } + + location ^~ /s/sim/ { + alias /sim/; + autoindex on; + access_log off; + include cors; + } + + location ^~ /s/data/ { + alias /data/; + autoindex on; + access_log off; + include cors; + } + + location ^~ /s/home/ { + alias /home/; + autoindex on; + access_log off; + include cors; + } + + location ^~ /s/home/ispq { + deny all; + } + + # WebDAV + # http://nginx.org/en/docs/http/ngx_http_dav_module.html + location /davs/algo/ { + alias /algo/; + include /etc/nginx/webdav.conf; + } + + # Metabase + location /metabase/ { + proxy_pass http://docker-prod-srv-1:3005/; + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; + } + + location ~ ^/metabase/(https?.*) { + return 302 $1; + } + + # pgAdmin + location /pgadmin4/ { + proxy_set_header X-Script-Name /pgadmin4; + proxy_set_header Host $host; + proxy_set_header X-Scheme $scheme; + proxy_pass http://pgadmin:80/; + proxy_redirect off; + } +} diff --git a/deployments/sirc/prod.yml b/deployments/sirc/prod.yml new file mode 100644 index 000000000..641921bb5 --- /dev/null +++ b/deployments/sirc/prod.yml @@ -0,0 +1,41 @@ +# SIRC (Samsung Israel R&D Center) compose overlay +# Usage: docker compose -f docker-compose.yml -f production.yml -f deployments/sirc/sirc.yml -f deployments/sirc/prod.yml up + +x-sirc-logging: &sirc-logging + logging: + driver: "fluentd" + options: + tag: qaboard.db + fluentd-address: "0.0.0.0:24224" + +services: + fluentd: + image: "gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:fluentd" + restart: always + build: + context: deployments/fluentd + dockerfile: fluentd.Dockerfile + container_name: fluentd + ports: + - "24224:24224" + volumes: + - /etc/localtime:/etc/localtime:ro + - ./services/fluentd/fluentd.conf:/fluentd/etc/fluentd.conf + environment: + - FLUENTD_CONF=fluentd.conf + + db: + <<: *sirc-logging + + backend: + <<: *sirc-logging + + cron-backup-db: + volumes: + - /home/ispq/qaboard_data_prod/database_backups:/backups + environment: + AS_USER_NAME: ispq + AS_USER_GROUP: uucp + AS_USER_UID: "1411" + AS_USER_GID: "10" + <<: *sirc-logging diff --git a/deployments/sirc/sirc.yml b/deployments/sirc/sirc.yml new file mode 100644 index 000000000..260a528e6 --- /dev/null +++ b/deployments/sirc/sirc.yml @@ -0,0 +1,161 @@ +# SIRC (Samsung Israel R&D Center) compose overlay +# Usage: docker compose -f docker-compose.yml -f production.yml -f deployments/sirc/sirc.yml up + +x-sirc-build-args: &sirc-build-args + PROXY_URL: "http://webproxy.transchip.com:8080" + CA_CERT_URL: "http://itweb/downloads/sirc-ca.crt" + GIT_SERVER: "gitlab-srv" + CDE_PACKAGE: "cde @ git+ssh://git@gitlab-srv/cde/cde-python" + NO_PROXY: "gitlab-srv,gitlab-srv.transchip.com,localhost,aospt-dt,sentry,sentry.transchip.com" + + +services: + db: + # postgres needs a file owned by postgres, and we cannot chown on our shared storage + # the default entrypoint changes user from root to postgres and inits the database, + # but only if the command starts with "postgres"... so we need to wrap it + entrypoint: /init.sh + volumes: + - ./services/db/init.sh:/init.sh + - ./services/db/postgres.conf:/postgres.host.conf + - /home/ispq/qaboard_data_prod/database_backups:/backups + ports: + - ${QABOARD_EXPOSED_DB_PORT:-5432}:5432 + + backend: + image: "gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:backend" + build: + ssh: [default] + args: + <<: *sirc-build-args + GIT_SSL_VERIFY: "false" + QABOARD_EXTRA: "sirc" + env_file: + - .env + - deployments/sirc/.env + environment: + - GITLAB_HOST=http://gitlab-srv + - UWSGI_UID=ispq + - UWSGI_GID=uucp + - QABOARD_DATA_SHARED_DIR=/home/ispq/qaboard/shared + - QABOARD_DATA_GIT_DIR=/algo/ws/ispq/qaboard_prod_data_git + - QA_RUNNERS_LSF_BRIDGE=ssh ispq@ispq-vdi 'bsub_su {user} -I {bsub_command}' + - QABOARD_LOGIN_TYPE=LDAP + - QABOARD_DISABLE_SIGNUP=True + - QABOARD_LOGIN_RESTRICTED=True + - QABOARD_LOGIN_RESTRICTED_YAML=/etc/ldap/users_restrict.yml + # - QABOARD_LDAP_USER_BASE=${QABOARD_LDAP_USER_BASE} + # - QABOARD_LDAP_BIND_DN=${QABOARD_LDAP_BIND_DN} + # - QABOARD_LDAP_PASSWORD=${QABOARD_LDAP_PASSWORD} + # - QABOARD_LDAP_HOST=${QABOARD_LDAP_HOST} + - QABOARD_LDAP_USER_FILTER=(&(objectClass=user)(sAMAccountName={login})) + # - SENTRY_DSN=${SENTRY_DSN:-} + - QABOARD_TUNING_RUNNER=lsf + - QABOARD_DEFAULT_USER=ispq + - QA_LSF_SSH_TARGET=ispq@ispq-vdi + - 'QABOARD_IMAGE_SERVERS={"default": "/iiif", "raw,hex,cde": "/iiif/cde"}' + + volumes: + - ./services/backend/passwd:/etc/passwd + - ./services/backend/ldap:/etc/ldap + - /raid:/raid + - /stage:/stage + # - /home:/home + # - /home:/raid/users + - /stage/algo_data:/stage/algo_data + - /stage/qa_data:/stage/qa_data + - /stage/algo-datasets:/stage/algo-datasets + - /stage/algo_archive:/stage/algo_archive + - /sim:/sim:shared + - /algo:/algo:shared + - /data:/data:shared + - /net:/net:shared + - /algo/ws:/algo/ws:shared + + proxy: + build: + args: + <<: *sirc-build-args + ports: + - ${QABOARD_PORT_HTTPS:-443}:443 + - ${QABOARD_PROXY_PORT_HTTP:-80}:5003 + # backward compat, from LSF accessing port 80 doesn't always work... + - ${QABOARD_EXTRA_SIRC_PORT_1:-5000}:5000 + - ${QABOARD_EXTRA_SIRC_PORT_2:-5002}:5002 + environment: + - NGINX_USER=ispq + volumes: + - ./deployments/sirc/nginx/qaboard.conf:/tmp/deployment/nginx/conf.d/qaboard.conf:ro + - ./deployments/sirc/nginx/dvd.conf:/tmp/deployment/nginx/conf.d/dvd.conf:ro + - ./deployments/sirc/nginx/publish.conf:/tmp/deployment/nginx/conf.d/publish.conf:ro + - /home/ispq/qaboard_prod/services/nginx/ssl/qa/qa.key:/ssl/cert.key + - /home/ispq/qaboard_prod/services/nginx/ssl/qa/cert.cer:/ssl/cert.cer + - /home/ispq/qaboard_prod/services/nginx/ssl/publish/publish.cer:/ssl/publish.cer + - /home/ispq/qaboard_prod/services/nginx/ssl/publish/publish.pem:/ssl/publish.pem + - /home/ispq/qaboard_prod/services/nginx/ssl/publish/publish.key:/ssl/publish.key + - ./services/nginx/passwd:/etc/passwd + - /home:/home + - /raid:/raid + - /stage:/stage + - /home:/raid/users + - /stage/algo_data:/stage/algo_data + - /stage/algo_archive:/stage/algo_archive + - /stage/qa_data:/stage/qa_data + - /stage/algo-datasets:/stage/algo-datasets + - /algo:/algo:shared + - /sim:/sim:shared + - /data:/data:shared + + cantaloupe: + # The service is now hosted outside of QA-Board + entrypoint: ["true"] + restart: no + command: [] + + image: "gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:cantaloupe" + user: "1411:10" # FIXME: ==ispq, so remove after a 1st rebuild.. + # user: ispq + build: + args: + <<: *sirc-build-args + user: ispq + uid: 1411 + group: uucp + gid: 10 + volumes: + - /stage/algo_data:/repository/stage/algo_data + - /algo/CIS/outputs:/repository/algo/CIS/outputs + - /algo/CIS_artifacts:/repository/algo/CIS_artifacts + - /algo/ws/sircdevops/qaboard_data/cantaloupe:/var/cache/cantaloupe + - /sim:/repository/sim:shared + - /data:/data:shared + environment: + CANTALOUPE_MEM_START: 4g + CANTALOUPE_MEM_MAX: 8g + + frontend: + image: "gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:frontend" + build: + args: + <<: *sirc-build-args + NODE_TLS_REJECT_UNAUTHORIZED: "0" + # REACT_APP_SENTRY_DSN: ${REACT_APP_SENTRY_DSN:-} + REACT_APP_QABOARD_LOGIN_TYPE: "LDAP" + # SENTRY_ORG: ${SENTRY_ORG:-sentry} + # SENTRY_PROJECT: ${SENTRY_PROJECT:-qaboard-frontend} + # SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-} + # REACT_APP_POSTHOG_HOST: ${REACT_APP_POSTHOG_HOST:-} + # REACT_APP_POSTHOG_TOKEN: ${REACT_APP_POSTHOG_TOKEN:-} + + website: + image: "gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:website" + build: + args: + <<: *sirc-build-args + NODE_TLS_REJECT_UNAUTHORIZED: "0" + + flower: + build: + ssh: [default] + args: + <<: *sirc-build-args diff --git a/development.yml b/development.yml new file mode 100644 index 000000000..a6f0bf342 --- /dev/null +++ b/development.yml @@ -0,0 +1,104 @@ +version: "3.5" + +services: + # don't show logs from those services + pgadmin: + logging: + driver: none + db: + logging: + driver: none + redis: + logging: + driver: none + cantaloupe: + logging: + driver: none + proxy: + ports: + - 5001:5001 + + backend: + volumes: + - ./qaboard:/qaboard/qaboard + - ./backend:/qaboard/backend + - ${HOME}:${HOME} + - ./pyproject.toml:/qaboard/pyproject.toml + environment: + - FLASK_APP=backend + - FLASK_ENV=development + - FLASK_DEBUG=1 + # in dev we run as the user so we can't use the system python owned by root + - UV_PROJECT_ENVIRONMENT= + - PYTHONNOUSERSITE=1 + # - QABOARD_DB_ECHO=true + - UWSGI_STATS=true + - QABOARD_DATA_DIR=/qaboard/backend/data + # Connect to the production database + - QABOARD_DB_HOST=qa + - SSH_AUTH_SOCK=/ssh-agent + working_dir: /qaboard/backend + # With what's just below, everything works except being able to delete files owned by other users + # We wish we could do this, then remove sudo from the dockerfile, and use this command... + # command: flask run --host 0.0.0.0 --with-threads --port 5152 + # user: "11611:10" + # cap_add: + # - SETUID + # - SETGID + # But non-root users don't gain capabilities... + # uv add lxml xmlsec uwsgi --no-binary-package uwsgi --no-binary-package lxml --no-binary-package xmlsec --refresh-package lxml --refresh-package xmlsec --refresh-package uwsgi --no-cache --reinstall + # flask run --with-threads --host 0.0.0.0 --port 5152 + command: > + bash -c " + echo '${USER} ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers + su ${USER} -c 'uv run flask run --host 0.0.0.0 --with-threads --port 5152' + " + ports: + - "${QABOARD_DEV_BACKEND_PORT:-5152}:5152" + # logging: + # driver: json-file + + frontend: + build: + target: dev + volumes: + - ./webapp:/app + working_dir: /app + environment: + - HOME=/app + - NPM_CONFIG_USERCONFIG=/app + # Should we comment it out by default? + - HOST=0.0.0.0 + - DANGEROUSLY_DISABLE_HOST_CHECK=true + - CHOKIDAR_USEPOLLING=true + # - REACT_EDITOR=code + # Relay API requests to your development server + - REACT_APP_QABOARD_API_HOST=http://backend:5152 + - REACT_APP_QABOARD_HOST=http://proxy:5151 + ### At SIRC #### + - HTTP_PROXY= + - http_proxy= + - REACT_APP_QABOARD_HOST=http://proxy + user: "11611:10" + ports: + - "${QABOARD_DEV_FRONTEND_PORT:-3000}:3000" + depends_on: + - proxy + - backend + logging: + driver: json-file + + website: + command: yarn start + volumes: + - ./website:/website + environment: + # to access the server from outside the docker environment + - HOST=0.0.0.0 + - DANGEROUSLY_DISABLE_HOST_CHECK=true + # with SIRC's filesystem that doesn't send update events + - CHOKIDAR_USEPOLLING=true + # at SIRC to deal with broken proxies/certs + - NODE_TLS_REJECT_UNAUTHORIZED=0 + ports: + - 6051:3000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..f5baebfbd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,215 @@ +# https://docs.docker.com/compose/ +# https://docs.docker.com/compose/compose-file +version: "3.5" + +# Production: +# # https://docs.docker.com/compose/production/ +# # adds to all services "restart: always" +# docker compose -f docker-compose.yml -f production.yml up -d +# Development: +# # Mounts the local code for easy debugging with reloading +# docker compose -f docker-compose.yml -f development.yml up +# Site-specific overlay: +# docker compose -f docker-compose.yml -f production.yml -f deployments/mysite.yml up -d + +# Container names will be prefixed with "qaboard-" + +# Usage: +# - The application is served on port 5151 by default +# - Your responsibility to setup another reverse proxy on top for SSL + +# General docker compose usage: +# docker compose up # start the whole stack +# docker compose up -d # ...as a daemon +# docker compose stop # stop the stack's containers +# docker compose down --volumes # [dangerous] remove the data too +# docker compose build web # build a single service +# docker compose logs -f web # logs! -t/--timestamps --tail=10 +# docker compose run web env # "docker-run" commands from a service +# docker compose up --no-deps -d web # start a single service + + +volumes: + data: {} # git clones (and custom batches.yaml for now...) + db: {} + redis_data: {} + rabbitmq-data: {} + cache_cantaloupe: {} # cache for the iiif image server + pgadmin: {} + # all builds still available for smooth upgrades + frontend_builds: {} + website_builds: {} + +services: + # For all configuration options: + # https://hub.docker.com/_/postgres + # https://stackoverflow.com/questions/30848670/how-to-customize-the-configuration-file-of-the-official-postgresql-docker-image + db: + image: postgres:12-alpine + expose: + - "5432" + volumes: + - db:/var/lib/postgresql/data + - ./services/db:/opt + environment: + - POSTGRES_USER=qaboard + - POSTGRES_PASSWORD=password + - POSTGRES_DB=qaboard + + backend: + image: "arthurflam/qaboard:backend" + build: + context: . + dockerfile: backend/Dockerfile + # Wait for the database to be ready - https://docs.docker.com/compose/startup-order/ + command: ["./wait-for-it.sh", "${QABOARD_DB_HOST-db}:${QABOARD_DB_PORT-5432}", "--", "/qaboard/backend/init.sh"] + depends_on: + - db + - redis + expose: + - "3000" + volumes: + - data:/var/qaboard + environment: + - SECRET_KEY + - UWSGI_PROCESSES=2 + - UWSGI_LISTEN_QUEUE_SIZE=100 + - QABOARD_DB_HOST + - QABOARD_DB_PORT + - REDIS_HOST=redis + - REDIS_PORT=6379 + - GITLAB_ACCESS_TOKEN + - QABOARD_LOGIN_TYPE=${QABOARD_LOGIN_TYPE:-LOCAL} # LOCAL/LDAP/SAML + - QABOARD_LOGIN_RESTRICTED=${QABOARD_LOGIN_RESTRICTED:-} # true/false + - QABOARD_LOGIN_RESTRICTED_YAML=${QABOARD_LOGIN_RESTRICTED_YAML:-} # path + - CELERY_BROKER_URL=pyamqp://guest:guest@rabbitmq:${QABOARD_PORT_RABBITMQ:-5672}// + + frontend: + image: "arthurflam/qaboard:frontend" + build: + context: webapp + shm_size: 6gb + volumes: + - frontend_builds:/builds + # by default we assume you run the QA-Board on localhost, but will want to change this + environment: + - REACT_APP_QABOARD_HOST=http://localhost:5151 + + + # Message broker used by the celery task scheduler + # https://hub.docker.com/_/rabbitmq + rabbitmq: + image: rabbitmq:3-management + # To store the data at a non-random location + hostname: rabbitmq-qaboard + volumes: + - rabbitmq-data:/var/lib/rabbitmq + ports: + - ${QABOARD_PORT_RABBITMQ:-5672}:5672 + - ${QABOARD_PORT_RABBITMQ_MNGT:-15672}:15672 + + + # https://flower.readthedocs.io/en/latest/config.html#options + flower: + build: + context: . + dockerfile: services/flower.Dockerfile + environment: + - CELERY_BROKER_URL=pyamqp://guest@rabbitmq// + - CELERY_BROKER_API=http://guest:guest@rabbitmq:15672/api/ + - FLOWER_PORT=8888 + - FLOWER_URL_PREFIX=flower + command: celery -A qaboard.runners.celery_app flower + ports: + - "8888:8888" + depends_on: + - rabbitmq + + website: + image: "arthurflam/qaboard:website" + build: + context: website + shm_size: 4gb + # by default we assume you run the QA-Board on localhost, but will want to change this + args: + - QABOARD_URL=${QABOARD_URL:-http://localhost:5151} + volumes: + - website_builds:/builds + + + # nginx as reverse-proxy + # https://hub.docker.com/_/nginx + proxy: + image: docker.pkg.github.com/samsung/qaboard/proxy + build: + context: services/nginx + # # https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration + command: >- + /bin/bash -c " + set -e; + rm -rf /etc/nginx; + cp -r /tmp/etc/nginx /etc; + if [ -d /tmp/deployment/nginx/conf.d/ ]; then + cp -r /tmp/deployment/nginx/conf.d/* /etc/nginx/conf.d/; + fi; + envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf; + exec $${NGINX_BIN:-nginx} -g 'daemon off;' + " + environment: + - NGINX_USER=nginx + ports: + - ${QABOARD_PORT_HTTP:-5151}:80 + volumes: + - ./services/nginx:/tmp/etc/nginx + - frontend_builds:/builds:ro + - website_builds:/docs:ro + depends_on: + - backend + - cantaloupe + - flower + + # IIIF image server to serve images as tiles + cantaloupe: + image: "arthurflam/qaboard:cantaloupe" + build: + context: ./services/cantaloupe + expose: + - "8182" + volumes: + - cache_cantaloupe:/var/cache/cantaloupe + - /srv/cantaloupe + # INSECURE/FIXME: obviously it's not a great default, but not clear how to do better + - /:/repository + environment: + CANTALOUPE_MEM_START: 1g + CANTALOUPE_MEM_MAX: 2g + command: sh -c 'java -Dcantaloupe.config=/etc/cantaloupe.properties -Dcom.sun.media.jai.disableMediaLib=true -Xms$${CANTALOUPE_MEM_START} -Xmx$${CANTALOUPE_MEM_MAX} -jar /usr/local/cantaloupe/cantaloupe-$${VERSION}.jar' + + + # For convenience, we also bundle pgadmin + # Login at localhost:5050, and use "password" as password + # Note: pgadmin ships with utilities at e.g. /usr/local/pgsql-12 + # More configuration options, e.g. reverse proxying with nginx: + # https://www.pgadmin.org/docs/pgadmin4/development/container_deployment.html + pgadmin: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-user@domain.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-SuperSecret} + # if you want to host it under a subdirectory, add a location.proxy_pass to the nginx conf + # SCRIPT_NAME: /pgadmin + volumes: + - pgadmin:/root/.pgadmin + # https://www.pgadmin.org/docs/pgadmin4/development/import_export_servers.html + - ./services/pgadmin/servers.json:/pgadmin4/servers.json + - ./services/pgadmin/passfile:/pgadmin4/passfile + ports: + - "${PGADMIN_PORT:-5050}:80" + + + redis: + image: redis:7 + restart: always + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis_data:/data diff --git a/docs/github-support-roadmap.md b/docs/github-support-roadmap.md new file mode 100644 index 000000000..9ee63fb27 --- /dev/null +++ b/docs/github-support-roadmap.md @@ -0,0 +1,36 @@ +# GitHub Support Roadmap + +## Implemented +- GitHub webhook endpoint (`/webhook/github`) for push events +- GitHub repo cloning with `x-access-token` authentication +- GitHub avatar resolution via GitHub API with Redis caching +- GitHub Enterprise support (auto-detected from repository URL) +- Frontend avatar proxying for all hosting types + +## Deferred Items + +### CLI GitHub Integration +- Create `qaboard/github.py` parallel to `gitlab.py` +- Commit status reporting to GitHub (pending/success/failure checks) +- CI pipeline checks for GitHub Actions + +### GitHub Actions Integration +- Equivalent of `/api/v1/gitlab/job` and `/api/v1/gitlab/job/play` for GitHub Actions workflow dispatch +- Workflow run status tracking + +### Webhook Signature Verification +- Validate `X-Hub-Signature-256` header using `GITHUB_WEBHOOK_SECRET` env var +- Reject unsigned or incorrectly signed payloads + +### GitHub App Authentication +- Use GitHub App installation tokens instead of PATs +- Better org-level access control +- Automatic token rotation + +### Default GitHub Actions Integration Badges +- Frontend `default_github_integrations` with Actions badge URLs +- Display workflow status badges in project views + +### Multi-Token Support +- Per-instance token configuration for orgs with multiple GitHub Enterprise instances +- Token routing based on repository URL host diff --git a/drafts/tutorial-tweaking-the-optimization-loop.md b/drafts/tutorial-tweaking-the-optimization-loop.md new file mode 100644 index 000000000..55b250a60 --- /dev/null +++ b/drafts/tutorial-tweaking-the-optimization-loop.md @@ -0,0 +1,45 @@ + +## Working on the optimization loop +1. it's easier to reproduce a real tuning + https://qa/CDE-Users/HW_ALG/CIS/tests/products/HM3/commit/1e357ad1fffbfaf2a172f01646417e1dce25650f?batch=auto_tuning_try_4&reference=1e357ad1fffbfaf2a172f01646417e1dce25650f&selected_views=logs&selected_metric=objective&aggregation=media + +2. get the script that start the tuning + https://qa/s/stage/algo_data/ci/CDE-Users/HW_ALG/1e/357ad1fffbfaf2/CIS/tests/products/HM3/output/auto-tuning-try-4/qa_batch.sh + (there used to be a direct link in the UI...) + + +3. Go to your VDI, and open a bash shell on LSF: + bsub -Is bash + +4. Clone: + git clone git@gitlab-srv/common-infrastructure/qaboard +5. Install the qaboard CLI locally: + pip install --editable '.[optimize]' + +6. Go to HW_ALG + git checkout 1e357ad1fffbfaf2a172f01646417e1dce25650f + git submodule update --remote + cd CIS + make + cd tests/products/HM3 + +7. Make sure you use your own "qa", not the one from the CI, e.g. + export PATH=/home/arthurf/anaconda3/bin:$PATH + +7. qa --share --label tuning-test optimize --batches-file "/algo/CIS_artifacts/CDE-Users/HW_ALG/1e/357ad1fffbfaf2/CIS/tests/products/HM3/tests_nxtc.yaml" --batches-file "/algo/CIS_artifacts/CDE-Users/HW_ALG/1e/357ad1fffbfaf2/CIS/tests/products/HM3/tests.yaml" --batches-file "/home/ispq/qaboard_data_prod/shared/CDE-Users/HW_ALG/CIS/tests/products/HM3/extra-batches.yml" --config-file '/stage/algo_data/ci/CDE-Users/HW_ALG/1e/357ad1fffbfaf2/CIS/tests/products/HM3/output/auto-tuning-try-4/optim-config.yaml' --batch HM3_Nona_ISP_ONLY + +8. Edit qaboard/optimize.py#L42-L44 + + +It’s possible to parallelize the runs. We could run 20 at a time on LSF…. +https://scikit-optimize.github.io/stable/auto_examples/parallel-optimization.html + +Concretely, in our code we could +- Read from the user-supplied config how parallel we can be… +- Replace this + https://github.com/Samsung/qaboard/blob/master/qaboard/optimize.py#L42-L44 +with something that works exactly like the 1st example here: + https://scikit-optimize.github.io/stable/auto_examples/parallel-optimization.html#example +If you want to try it, I can help you run the optimization loop locally: +- Edit & Run the tuning CLI command (copy-paste it from the logs from the web application) + diff --git a/fastentrypoints.py b/fastentrypoints.py deleted file mode 100644 index 7444ed666..000000000 --- a/fastentrypoints.py +++ /dev/null @@ -1,108 +0,0 @@ -# noqa: D300,D400 -# Copyright (c) 2016, Aaron Christianson -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -''' -Monkey patch setuptools to write faster console_scripts with this format: - import sys - from mymodule import entry_function - sys.exit(entry_function()) -This is better. -(c) 2016, Aaron Christianson -http://github.com/ninjaaron/fast-entry_points -''' -from setuptools.command import easy_install -import re -TEMPLATE = r''' -# -*- coding: utf-8 -*- -# EASY-INSTALL-ENTRY-SCRIPT: '{3}','{4}','{5}' -__requires__ = '{3}' -import re -import sys -from {0} import {1} -if __name__ == '__main__': - sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) - sys.exit({2}()) -'''.lstrip() - - -@classmethod -def get_args(cls, dist, header=None): # noqa: D205,D400 - """ - Yield write_script() argument tuples for a distribution's - console_scripts and gui_scripts entry points. - """ - if header is None: - # pylint: disable=E1101 - header = cls.get_header() - spec = str(dist.as_requirement()) - for type_ in 'console', 'gui': - group = type_ + '_scripts' - for name, ep in dist.get_entry_map(group).items(): - # ensure_safe_name - if re.search(r'[\\/]', name): - raise ValueError("Path separators not allowed in script names") - script_text = TEMPLATE.format( - ep.module_name, ep.attrs[0], '.'.join(ep.attrs), - spec, group, name) - # pylint: disable=E1101 - args = cls._get_script_args(type_, name, header, script_text) - for res in args: - yield res - - -# pylint: disable=E1101 -easy_install.ScriptWriter.get_args = get_args - - -def main(): - import os - import re - import shutil - import sys - dests = sys.argv[1:] or ['.'] - filename = re.sub(r'\.pyc$', '.py', __file__) - - for dst in dests: - shutil.copy(filename, dst) - manifest_path = os.path.join(dst, 'MANIFEST.in') - setup_path = os.path.join(dst, 'setup.py') - - # Insert the include statement to MANIFEST.in if not present - with open(manifest_path, 'a+') as manifest: - manifest.seek(0) - manifest_content = manifest.read() - if 'include fastentrypoints.py' not in manifest_content: - manifest.write(('\n' if manifest_content else '') + - 'include fastentrypoints.py') - - # Insert the import statement to setup.py if not present - with open(setup_path, 'a+') as setup: - setup.seek(0) - setup_content = setup.read() - if 'import fastentrypoints' not in setup_content: - setup.seek(0) - setup.truncate() - setup.write('import fastentrypoints\n' + setup_content) diff --git a/production.yml b/production.yml new file mode 100644 index 000000000..2849dfcdf --- /dev/null +++ b/production.yml @@ -0,0 +1,78 @@ +version: "3.5" + +services: + frontend: + build: + target: production + db: + shm_size: 1g + restart: always + # On-prem, it can be helpful to expose the database and use various analytics products + # ports: + # - 5432:5432 + pgadmin: + restart: always + redis: + restart: always + backend: + restart: always + environment: + - FLASK_ENV=production + # To use values beyond 128, read + # * https://stackoverflow.com/a/36452474/5993501 + # * https://uwsgi-docs.readthedocs.io/en/latest/articles/TheArtOfGracefulReloading.html#the-listen-queue + # * https://stackoverflow.com/questions/43243483/docker-container-increase-listen-queue-size-beyond-128 + # * https://serverfault.com/questions/271380/how-can-i-increase-the-value-of-somaxconn + - UWSGI_LISTEN_QUEUE_SIZE=1024 + - UWSGI_CHEAPER_ALGO_BUSYNESS=true + - UWSGI_CHEAPER_INITIAL=16 # initial number of processes + - UWSGI_PROCESSES=16 # max + - UWSGI_CHEAPER=8 # minimum + - UWSGI_CHEAPER_OVERLOAD=15 + # values in bytes + - UWSGI_CHEAPER_RSS_LIMIT_SOFT=17179869184 #16GB + - UWSGI_CHEAPER_RSS_LIMIT_HARD= + - UWSGI_STATS=true + # leave an unbound port open, useful for debugging + ports: + - ${QABOARD_PORT_DEBUG:-5152}:3001 + # https://github.com/compose-spec/compose-spec/blob/master/spec.md#sysctls + sysctls: + - net.core.somaxconn=1024 + + proxy: + restart: always + cantaloupe: + restart: always + rabbitmq: + restart: always + flower: + restart: always + + # Adapted from https://devopsheaven.com/cron/docker/alpine/linux/2017/10/30/run-cron-docker-alpine.html + # To trigger a backup manually call: docker compose -f docker-compose.yml -f production.yml up -d cron-backup-db + # docker compose run cron-backup-db /etc/periodic/daily/backup + cron-backup-db: + image: postgres:12-alpine + restart: always + depends_on: + - db + environment: + # https://www.postgresql.org/docs/9.3/libpq-envars.html + PGHOST: ${PGHOST:-db} + PGDATABASE: qaboard + PGUSER: qaboard + PGPASSWORD: password + # Uncomment and set to run backups as a specific user (useful with NFS) + # AS_USER_NAME: qaboard + # AS_USER_GROUP: qaboard + # AS_USER_UID: "1000" + # AS_USER_GID: "1000" + # https://busybox.net/downloads/BusyBox.html + # -f: foreground + # -d: log to stderr, 0 is the most verbose, default 8 + command: crond -f -d 0 + volumes: + - ./services/db/backup:/etc/periodic/daily/backup:ro + # Set the backup destination in your site overlay, e.g.: + # - /path/to/backups:/backups diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..20a77f51d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,82 @@ + +[project] +name = "qaboard" +version = "1.0.3" +description = "Visualize and compare algorithm results. Optimize parameters. Share results and track progress." +readme = "README.md" +authors = [ + {name = "Arthur Flam", email = "arthur.flam@samsung.com"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Quality Assurance", +] +requires-python = ">=3.7" +dependencies = [ + "click>=7.0", + "joblib", + "pyyaml", + "requests", + "rich", + "scikit-learn", + "scikit-optimize", + "sentry-sdk", + "simplejson", + "psutil", +] +license = {text = "Apache-2.0"} + +[project.urls] +Homepage = "https://github.com/Samsung/qaboard" + +[project.optional-dependencies] +dev = [ + "flake8", + "green", + "mypy", + "types-PyYAML", + "types-requests", + "types-setuptools", + "types-simplejson", +] +# Unfortunately pip does not understand tool.uv.sources +# and uv does not understand syntax with "qaboard-site-sirc @ file:deployements/sirc/cli" +# and if we use an ssh syntax in uv it will try to resolve even if it is part of an unused extra +# so we are forced to really split into 2 packages +# sirc = [ +# "qaboard-site-sirc", +# ] +# dsk = [ +# "qaboard-site-dsk", +# ] + +[project.scripts] +qa = "qaboard.qa:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = ["qaboard/**/*", "README.md"] + +[tool.hatch.build.targets.wheel] +packages = [ + "qaboard", + # for backward compat only + "qatools", +] + +# See above +# [tool.uv.sources] +# qaboard-site-sirc = { path = "deployments/sirc/cli" } +# qaboard-site-dsk = { path = "deployments/dsk/cli" } + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/qaboard-backend/README.md b/qaboard-backend/README.md deleted file mode 100755 index 620e6aab1..000000000 --- a/qaboard-backend/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# QA-Board Backend -Exposes an HTTP API used to read/write all the metadata on QA-Board's runs. - -> The python package implementing the API is named `slamvizapp`, let's find some time to rename it `qaboard_backend`... Likewise, the database is named `slamvizapp`... - -## How to build -First get the code -```bash -cd -git clone git@gitlab-srv:common-infrastructure/qaboard.git -cd qaboard -``` - -Then build: - -```bash -export DOCKER_IMAGE=qaboard -export CI_ENVIRONMENT_SLUG=staging -docker build --tag $DOCKER_IMAGE-$CI_ENVIRONMENT_SLUG . -``` - -As explained in the [Dockerfile](Dockerfile), you also have to build the frontend separately. [Follow the instructions](../qaboard-webapp/). - -## How to run the backend -- Your user must have an SSH keys setup to connect to your gitlab instance. -- You must have set the *$GITLAB_ACCESS_TOKEN* environment variable ([get it here](http://gitlab-srv/profile/personal_access_tokens)) - -> **FIXME**: you also need to provide SSL keys in *deployment/ssl/...*. -> As-is, the nginx server tries to look for SSL keys and will fail. If you don't have such keys remove -> `ssl_certificate_key_*` settings from *deployment/nginx/nginx.conf/qaboard.conf*. -> -> **TODO**: It really should handled by a reverse proxy, not by us... - -To connect to a Jenkins server, you can optionnally define *JENKINS_USER_NAME*, *JENKINS_USER_TOKEN*, *JENKINS_USER_CRUMB*. - -> In the future we plan to introduce a proper "secret" store, per-instance and per project. - -Then you're almost all set: -```bash -# By (bad, fixme) default the container is run with "--restart always" in the background. -# For interactive debugging, -export CI_DEBUG=ON - -# This mounts $HOME/qaboard where the container looks for its code, -# and enables easier developmen -export QABOARD_DEBUG_WITH_MOUNTS=TRUE - -# Wraps `docker run`. Adapt the script to your needs... -./qaboard-backend/deployment/start-docker.sh -# => now serving http://localhost:[9000/9001] -# FYI, using `CI_ENVIRONMENT_SLUG=staging` changes port mapping slightly... -``` - -For development, you may want to restore a database backup. As a quick solution you can (DANGEROUS!) connect to the SIRC application server: -```bash -QABOARD_DB_HOST=qa -``` - -**Troubleshooting:** -- If you have issues like `too many levels of symbolic links`, try again until success... -- It's not sure the database is initialized correctly when starting from 0... - -## Running the image servers -Refer to the instructions under [cantaloupe/](cantaloupe/). To support CDE images, your will also need [CDEImage](http://gitlab-srv/swi/CDEImage) diff --git a/qaboard-backend/deployment/create-backup.sh b/qaboard-backend/deployment/create-backup.sh deleted file mode 100755 index fbb2e0f15..000000000 --- a/qaboard-backend/deployment/create-backup.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -set -e - -# ssh ispq@ispq-vdi -# crontab -e -# 0 5 * * * ssh qa /home/arthurf/qaboard/deployment/create-backup.sh - -# It should be guessed by postgreSQL anyway -export PGPASSFILE=$HOME/.pgpass - -BACKUP_DIR=/home/ispq/qaboard/database_backups -backup=$BACKUP_DIR/$(date --rfc-3339=date).dump -connect="--username=ci --no-password -h localhost --dbname=slamvizapp" - -pg_dump $connect -Fc > $backup - - diff --git a/qaboard-backend/deployment/host.nginx.conf b/qaboard-backend/deployment/host.nginx.conf deleted file mode 100755 index 75ea38485..000000000 --- a/qaboard-backend/deployment/host.nginx.conf +++ /dev/null @@ -1,145 +0,0 @@ -// webdav -// sudo apt-get install nginx nginx-full - - -server { - listen 5000; - listen [::]:5000 ipv6only=on; - server_name qa, dvs, gpu09-dt, planet31, planet33, localhost, 106.199.20.32; - - # Redirect human users to the new hostname, with HTTPS - location / { - return 302 http://qa$request_uri; - } - - # Until all servers trust our certificate authority, we forward API HTTP requests to the server's HTTP endpoint. - location ~ ^/api/(.*) { - proxy_pass http://127.0.0.1:5001; - } - # Until all servers trust our certificate authority, we forward API HTTP requests to the server's HTTP endpoint. - location ~ ^/webhook/(.*) { - proxy_pass http://127.0.0.1:5001; - } - location ~ ^/davs/(.*) { - proxy_pass http://127.0.0.1:5001; - } - -} - -# Redirect HTTP connections to HTTPS -server { - listen 80; - listen [::]:80 ipv6only=on; - server_name qa, dvs, gpu09-dt, planet31, planet33, localhost, 106.199.20.32; - return 302 https://qa$request_uri; -} - - -# For the cantaloupe server we terminate here the TLS connection -# and forward HTTP-only traffic to the server -server { - listen 8183 ssl http2; - listen [::]:8183 ssl http2; - server_name qa, localhost, 106.199.20.32; - - ssl_certificate_key /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.key; - ssl_certificate /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.pem; - - location / { - # add_header 'Access-Control-Allow-Origin' '*' always; - # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - # add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; - - if ($request_method = POST) { - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; - add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; - } - if ($request_method = GET) { - # add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; - add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" '*' always; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; - return 204; - } - # if ($request_uri ~* "/(.*)") { - # proxy_pass http://127.0.0.1:8182/$1; - # } - proxy_pass http://127.0.0.1:8182$request_uri; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host:$server_port; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Path /; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_redirect http://127.0.0.1:8182/ /; - - - if ($request_method = POST) { - # add_header 'Access-Control-Allow-Origin' '*' always; - # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - # add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-W$ - # add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Rang$ - } - if ($request_method = GET) { - # add_header 'Access-Control-Allow-Origin' '*' always; - # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; - # add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-W$ - # add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Rang$ - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" '*' always; - # add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - # add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-W$ - return 204; - } - - - } -} - -server { - listen 8186 ssl http2; - listen [::]:8186 ssl http2; - server_name qa, dvs, gpu09-dt, planet31, planet33, localhost, 106.199.20.32; - - ssl_certificate_key /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.key; - ssl_certificate /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.pem; - - location / { - if ($request_uri ~* "/(.*)") { - proxy_pass http://127.0.0.1:8185/$1; - } - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host:$server_port; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header X-Forwarded-Path /; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_redirect http://127.0.0.1:8185/ /; - - - - - } -} - -server { - listen 80; - listen [::]:80; - server_name qa-docs; - - ssl_certificate_key /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.key; - ssl_certificate /home/arthurf/qaboard/qaboard-backend/deployment/nginx/ssl/dvs/dvs.pem; - - location / { - root /home/arthurf/qaboard/website/build; - try_files $uri $uri/index.html $uri.html /index.html; - } -} diff --git a/qaboard-backend/deployment/init.sh b/qaboard-backend/deployment/init.sh deleted file mode 100755 index 55b24b1fd..000000000 --- a/qaboard-backend/deployment/init.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# TODO: https://github.com/Yelp/dumb-init -# TODO: https://docs.docker.com/compose/overview/ -set -evx -export LC_ALL=C.UTF-8 -export LANG=C.UTF-8 - -echo 'starting' -sudo nginx & - -echo '...starting the database' -# initdb -D /usr/local/pgsql/data -# pg_createcluster -sudo /etc/init.d/postgresql start & -sleep 6 - -# The first time you may need to -# # docker run --entrypoint /bin/bash --rm -it --volume=qaboard-postgresql-production:/etc/postgresql --volume=qaboard-postgresql-log-production:/var/log/postgresql --volume=qaboard-postgresql-lib-production:/var/lib/postgresql gitlab-srv.transchip.com:4567/common-infrastructure/qaboard:production -# # sudo pg_createcluster 10 main - -echo '...applying database migrations' -cd /qaboard/qaboard-backend/slamvizapp -alembic upgrade head || alembic downgrade head || alembic stamp head - - -echo '...starting the application' -sleep 1 -sudo chmod 777 /qaboard/qaboard-backend/deployment/ -cd /qaboard/qaboard-backend && sudo -E /opt/anaconda3/bin/uwsgi --ini /qaboard/qaboard-backend/deployment/slamvizapp.ini & - -export QABOARD_DB_ECHO=True -cd /qaboard/qaboard-backend && FLASK_APP=slamvizapp FLASK_DEBUG=1 flask run --host 0.0.0.0 --with-threads --port 5002 & - -# command -# status=$? -# if [ $status -ne 0 ]; then -# echo "Failed to start my_first_process: $status" -# exit $status -# fi - - -while sleep 43200; do - echo OK -done - - -exec "${@}" diff --git a/qaboard-backend/deployment/nginx/conf.d/qaboard.conf b/qaboard-backend/deployment/nginx/conf.d/qaboard.conf deleted file mode 120000 index 2b451c93a..000000000 --- a/qaboard-backend/deployment/nginx/conf.d/qaboard.conf +++ /dev/null @@ -1 +0,0 @@ -../sites-available/slamvizapp \ No newline at end of file diff --git a/qaboard-backend/deployment/nginx/sites-available/default b/qaboard-backend/deployment/nginx/sites-available/default deleted file mode 100644 index 45888f2aa..000000000 --- a/qaboard-backend/deployment/nginx/sites-available/default +++ /dev/null @@ -1,86 +0,0 @@ -## -# You should look at the following URL's in order to grasp a solid understanding -# of Nginx configuration files in order to fully unleash the power of Nginx. -# http://wiki.nginx.org/Pitfalls -# http://wiki.nginx.org/QuickStart -# http://wiki.nginx.org/Configuration -# -# Generally, you will want to move this file somewhere, and start with a clean -# file but keep this around for reference. Or just disable in sites-enabled. -# -# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples. -## - -# Default server configuration -# -server { - listen 9000 default_server; - listen [::]:9000 default_server; - - # SSL configuration - # - # listen 443 ssl default_server; - # listen [::]:443 ssl default_server; - # - # Note: You should disable gzip for SSL traffic. - # See: https://bugs.debian.org/773332 - # - # Read up on ssl_ciphers to ensure a secure configuration. - # See: https://bugs.debian.org/765782 - # - # Self signed certs generated by the ssl-cert package - # Don't use them in a production server! - # - # include snippets/snakeoil.conf; - - root /var/www/html; - - # Add index.php to the list if you are using PHP - index index.html index.htm index.nginx-debian.html; - - server_name _; - - location / { - # First attempt to serve request as file, then - # as directory, then fall back to displaying a 404. - try_files $uri $uri/ =404; - } - - # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 - # - #location ~ \.php$ { - # include snippets/fastcgi-php.conf; - # - # # With php7.0-cgi alone: - # fastcgi_pass 127.0.0.1:9000; - # # With php7.0-fpm: - # fastcgi_pass unix:/run/php/php7.0-fpm.sock; - #} - - # deny access to .htaccess files, if Apache's document root - # concurs with nginx's one - # - #location ~ /\.ht { - # deny all; - #} -} - - -# Virtual Host configuration for example.com -# -# You can move that to a different file under sites-available/ and symlink that -# to sites-enabled/ to enable it. -# -#server { -# listen 80; -# listen [::]:80; -# -# server_name example.com; -# -# root /var/www/example.com; -# index index.html; -# -# location / { -# try_files $uri $uri/ =404; -# } -#} diff --git a/qaboard-backend/deployment/nginx/sites-available/slamvizapp b/qaboard-backend/deployment/nginx/sites-available/slamvizapp deleted file mode 100755 index f77204ebc..000000000 --- a/qaboard-backend/deployment/nginx/sites-available/slamvizapp +++ /dev/null @@ -1,350 +0,0 @@ -server { - # This endpoint is only used for HTTP API calls, - # until all clients trusts IT's certficates - listen 5000; - listen [::]:5000; - server_name qa, qatools01, localhost, 106.199.20.32; - - location ^~ /api { - include uwsgi_params; - uwsgi_pass unix:/qaboard/qaboard-backend/deployment/qaboard.sock; - uwsgi_read_timeout 300; - } - location ^~ /webhook { - include uwsgi_params; - uwsgi_pass unix:/qaboard/qaboard-backend/deployment/qaboard.sock; - } - - - location ^~ /s/algo_archive/ { - alias /net/f2/algo_archive/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/algo_data/ { - alias /stage/algo_data/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/net/f2/algo_archive/ { - alias /net/f2/algo_archive/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/stage/algo_data/ { - alias /stage/algo_data/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - - # http://nginx.org/en/docs/http/ngx_http_dav_module.html - location /davs/stage/ { - alias /stage/; - # if (-d $request_filename) { rewrite ^(.*[^/])$ $1/ break; } - # if ($request_method = MKCOL) { rewrite ^(.*[^/])$ $1/ break; } - - # enable creating directories without trailing slash - set $x $uri$request_method; - if ($x ~ [^/]MKCOL$) { - rewrite ^(.*)$ $1/; - } - - client_body_temp_path /temp; - client_max_body_size 500M; - - autoindex on; - dav_methods PUT DELETE MKCOL COPY MOVE; - dav_ext_methods PROPFIND OPTIONS; - create_full_put_path on; - dav_access group:rw all:r; - - # $ sudo mkdir -p /etc/nginx/passwd - # $ sudo htpasswd -c /etc/nginx/passwd/webdav_pass S1rC - # auth_basic "Enter Password:"; - # auth_basic_user_file "/etc/nginx/passwd/webdav_pass"; - } - location /davs/net/f2/ { - alias /net/f2/; - # if (-d $request_filename) { rewrite ^(.*[^/])$ $1/ break; } - # if ($request_method = MKCOL) { rewrite ^(.*[^/])$ $1/ break; } - - # enable creating directories without trailing slash - set $x $uri$request_method; - if ($x ~ [^/]MKCOL$) { - rewrite ^(.*)$ $1/; - } - - client_body_temp_path /temp; - client_max_body_size 500M; - - autoindex on; - dav_methods PUT DELETE MKCOL COPY MOVE; - dav_ext_methods PROPFIND OPTIONS; - create_full_put_path on; - dav_access group:rw all:r; - } - - - -} - - - - -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name qa, qatools01, localhost, 106.199.20.32; - - ssl_certificate_key /etc/nginx/ssl/dvs/dvs.key; - ssl_certificate /etc/nginx/ssl/dvs/dvs.pem; - - location ~* (service-worker\.js)$ { - add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; - expires off; - proxy_no_cache 1; - root /qaboard/qaboard-webapp/build; - try_files $uri $uri/index.html $uri.html /index.html; - } - - location ~* \.(?:css|js)$ { - expires 1y; - access_log off; - add_header Cache-Control "public"; - root /qaboard/qaboard-webapp/build; - try_files $uri $uri/index.html $uri.html /index.html; - } - - location / { - root /qaboard/qaboard-webapp/build; - try_files $uri $uri/index.html $uri.html /index.html; - } - - location ^~ /api { - include uwsgi_params; - uwsgi_pass unix:/qaboard/qaboard-backend/deployment/qaboard.sock; - uwsgi_read_timeout 300; - if ($request_method ~* "(GET|POST|PUT|DELETE)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - location ^~ /webhook { - include uwsgi_params; - uwsgi_pass unix:/qaboard/qaboard-backend/deployment/qaboard.sock; - } - location ^~ /admin { - include uwsgi_params; - uwsgi_pass unix:/qaboard/qaboard-backend/deployment/qaboard.sock; - } - - - location ^~ /s/ { - alias /home/arthurf/ci/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/home/ { - alias /home/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/home/arthurf { - deny all; - } - - location ^~ /s/algo_archive/ { - alias /net/f2/algo_archive/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/algo_data/ { - alias /stage/algo_data/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/net/f2/algo_archive/ { - alias /net/f2/algo_archive/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - location ^~ /s/stage/algo_data/ { - alias /stage/algo_data/; - autoindex on; - access_log off; - - if ($request_method ~* "(GET|POST)") { - add_header "Access-Control-Allow-Origin" *; - } - # Preflighted requests - if ($request_method = OPTIONS ) { - add_header "Access-Control-Allow-Origin" *; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; - return 200; - } - } - - - # http://nginx.org/en/docs/http/ngx_http_dav_module.html - location /davs/stage/ { - alias /stage/; - # if (-d $request_filename) { rewrite ^(.*[^/])$ $1/ break; } - # if ($request_method = MKCOL) { rewrite ^(.*[^/])$ $1/ break; } - - client_body_temp_path /temp; - client_max_body_size 500M; - - autoindex on; - dav_methods PUT DELETE MKCOL COPY MOVE; - dav_ext_methods PROPFIND OPTIONS; - create_full_put_path on; - dav_access group:rw all:r; - - # enable creating directories without trailing slash - set $x $uri$request_method; - if ($x ~ [^/]MKCOL$) { - rewrite ^(.*)$ $1/; - } - - # $ sudo mkdir -p /etc/nginx/passwd - # $ sudo htpasswd -c /etc/nginx/passwd/webdav_pass S1rC - # auth_basic "Enter Password:"; - # auth_basic_user_file "/etc/nginx/passwd/webdav_pass"; - } - location /davs/net/f2/ { - alias /net/f2/; - # if (-d $request_filename) { rewrite ^(.*[^/])$ $1/ break; } - # if ($request_method = MKCOL) { rewrite ^(.*[^/])$ $1/ break; } - - # enable creating directories without trailing slash - set $x $uri$request_method; - if ($x ~ [^/]MKCOL$) { - rewrite ^(.*)$ $1/; - } - - client_body_temp_path /temp; - client_max_body_size 500M; - - autoindex on; - dav_methods PUT DELETE MKCOL COPY MOVE; - dav_ext_methods PROPFIND OPTIONS; - create_full_put_path on; - dav_access group:rw all:r; - } - -} diff --git a/qaboard-backend/deployment/nginx/ssl/dhparam.pem b/qaboard-backend/deployment/nginx/ssl/dhparam.pem deleted file mode 100644 index ef1cd9a6f..000000000 --- a/qaboard-backend/deployment/nginx/ssl/dhparam.pem +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN DH PARAMETERS----- -MIIBCAKCAQEA0dm9U3KUruYJPv4rwkm25XRHtdK8ihWqNEugym9K6PrCbH68Dxca -YNcaq1tsg7xQ+6jghyIDfcapUZ7hDuoMQvRqGcqW2k5leaSRnt3nCa+CURzh0qq/ -l1RnWLfd6wMY51pnJlCN0cfrzL12Mex+qgc5GecIMPHUWNlIshzvd2cC/u1KYmsK -BHkDmStNOKd+GxVlq9nRXZaKFSxgfA55eKqM6eSoLgOMcTmHshSeR5eIjtagc02D -PmeGkCCs2e+ANFaGfbgIgynbVCc03j1JG0+SO3jx+GcVroqcUNRMm9C0iaCF/Off -S3B7GicQcocc0dGRa/0Y5a4eowkSlQFvOwIBAg== ------END DH PARAMETERS----- diff --git a/qaboard-backend/deployment/slamvizapp.ini b/qaboard-backend/deployment/slamvizapp.ini deleted file mode 100755 index f287d33b1..000000000 --- a/qaboard-backend/deployment/slamvizapp.ini +++ /dev/null @@ -1,31 +0,0 @@ -# uwsgi configuration -# -# Can 100% be ignored if you run the app via `flask run` - -[uwsgi] -module = slamvizapp:app -master = true -processes = 8 - -# arthurf -# this lets us use SIRC NFS mounts, as they seem to squash_root -uid = arthurf -gid = uucp -# uid = 11611 -# gid = 10 - -# protocol = uwsgi -# harakiri = 300 - -# we need to increase it a bit from the 4096 default -# https://stackoverflow.com/questions/15878176/uwsgi-invalid-request-block-size -buffer-size=32768 - -socket = /qaboard/qaboard-backend/deployment/qaboard.sock -# 777 for docker -chmod-socket = 777 -# chmod-socket = 660 -vacuum = true - -die-on-term = true -# logto = /home/arthurf/qaboard/qaboard-backend/%n.log diff --git a/qaboard-backend/deployment/start-docker.sh b/qaboard-backend/deployment/start-docker.sh deleted file mode 100755 index 8e492f455..000000000 --- a/qaboard-backend/deployment/start-docker.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# `docker run` wrapper -# TODO: define everything in a `docker-compose` file -set -ex -DOCKER_IMAGE=qaboard -: "${DOCKER_TAG:=$CI_ENVIRONMENT_SLUG}" -DOCKER_IMAGE=$DOCKER_IMAGE:$DOCKER_TAG - -## uncomment when deploying debug server: -# DOCKER_IMAGE="qaboard-${DOCKER_TAG:=$CI_ENVIRONMENT_SLUG}" -echo "===== $DOCKER_IMAGE =====" - -DOCKER_ENV="" - - -DOCKER_VOLUMES="" -DOCKER_VOLUMES+=" --volume=/home:/home" -# optionnal mounts -# DOCKER_VOLUMES+=" --volume=src:dst" -# ... - -if [ -z ${CI_ENVIRONMENT_SLUG+x} ]; then - echo "[Error] \$CI_ENVIRONMENT_SLUG is not defined."; exit -else - if [ $CI_ENVIRONMENT_SLUG = "production" ]; then - # frontend debug api database https-frontend - PORTS="-p0.0.0.0:5001:5000 -p0.0.0.0:5002:5002 -p0.0.0.0:5432:5432 -p0.0.0.0:443:443" - else - if [ $CI_ENVIRONMENT_SLUG = "staging" ]; then - # frontend debug api database https-frontend - PORTS="-p0.0.0.0:9000:5000 -p0.0.0.0:9002:5002 -p0.0.0.0:9433:5432 -p0.0.0.0:9001:443" - # DOCKER_ENV+=" --env QABOARD_DB_HOST=dvs" - # DOCKER_ENV+=" --env QABOARD_DB_PORT=5432" - else - PORTS="-p0.0.0.0:10000:5000 -p0.0.0.0:10002:5002 -p0.0.0.0:10001:443" - # PORTS="" - # or we could yse a dummy port and change the host's nginx config to point to the correct port.. - # DOCKER_VOLUMES+=" --volume=slamvizapp:/var/slamvizapp" - # this would replace using port 5000, but we need to update some nginx configurations before it works... - # --volume=/tmp/slamvizapp/slamvizapp-$CI_ENVIRONMENT_SLUG.sock:/slamvizapp/socks/slamvizapp.sock - fi - fi -fi - - - -if [ -z ${GITLAB_ACCESS_TOKEN+x} ]; then - echo "[Error] \$GITLAB_ACCESS_TOKEN is not defined: create one at http://gitlab-srv/profile/personal_access_tokens"; exit -else - DOCKER_ENV+=" --env GITLAB_ACCESS_TOKEN=${GITLAB_ACCESS_TOKEN}" -fi -if [ -z ${JENKINS_USER_NAME+x} ]; then - echo "[WARNING] \$JENKINS_USER_NAME is not defined: create one at http://http://qa-docs/docs/triggering-third-party-tools"; -else - DOCKER_ENV+=" --env JENKINS_USER_NAME=${JENKINS_USER_NAME}" - DOCKER_ENV+=" --env JENKINS_USER_TOKEN=${JENKINS_USER_TOKEN}" - DOCKER_ENV+=" --env JENKINS_USER_CRUMB=${JENKINS_USER_CRUMB}" -fi - - -# Git clone configuration -DOCKER_VOLUMES+=" --volume=slamvizapp:/var/qaboard" -# Database configuration -DOCKER_VOLUMES+=" --volume=slamvizapp-postgresql-$CI_ENVIRONMENT_SLUG:/etc/postgresql" -DOCKER_VOLUMES+=" --volume=slamvizapp-postgresql-log-$CI_ENVIRONMENT_SLUG:/var/log/postgresql" -DOCKER_VOLUMES+=" --volume=slamvizapp-postgresql-lib-$CI_ENVIRONMENT_SLUG:/var/lib/postgresql" - -HOME_DOCKER=/opt/dockermounts$HOME -# Custom configuration -# DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/.zshrc:/root/.zshrc" -# DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/.oh-my-zsh:/root/.oh-my-zsh" -# DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/.zsh_history:/root/.zsh_history" - -if [ $CI_ENVIRONMENT_SLUG = "production" ]; then - echo 'production !' - DOCKER_VOLUMES+=" --volume=/opt/dockermounts/home/ispq/qaboard/webapp_builds:/qaboard/qaboard-webapp/build" -else - if [ -z ${QABOARD_DEBUG_WITH_MOUNTS+x} ]; then - echo 'reading source from container' - else - # DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend/deployment/nginx/nginx.conf:/etc/nginx/nginx.conf" - # DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend/deployment/nginx/conf.d:/etc/nginx/conf.d" - DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend:/qaboard/qaboard-backend" - # DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/anaconda3:/opt/anaconda3" - fi -fi -DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend/deployment/nginx/ssl/dvs:/etc/nginx/ssl/dvs" -DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend/deployment/nginx/ssl/qa:/etc/nginx/ssl/qa" - -if [ -z ${QABOARD_DB_HOST+x} ]; then - echo 'Using container database' -else - DOCKER_ENV+=" --env QABOARD_DB_HOST=qa" -fi - -# ! we already copy the whole nginx config folder in the dockerfile... that's not great. -# DOCKER_VOLUMES+=" --volume=$HOME_DOCKER/qaboard/qaboard-backend/deployment/init.sh:/qaboard/qaboard-backend/deployment/init.sh" - -# Networking: -# --publish-all -P -# --publish -p -# --ip -# --ip6 - -# Container lifecycle: -if [ -z ${CI_DEBUG+x} ]; then - echo 'Always-on' - POLICY="--restart always --detach" -else - echo 'Interactive session' - POLICY="--rm -it" -fi -# --rm: removed container on exit -# -i interactive -# -t pseudo tty -# -u=$USER:$UID -# -u=$UID -# -u=$UID -# --privileged=true -command="docker run --name qaboard-$CI_ENVIRONMENT_SLUG${CI_DEBUG} $POLICY $DOCKER_VOLUMES $DOCKER_ENV $PORTS $DOCKER_IMAGE ${@}" -echo $command -exec $command diff --git a/qaboard-backend/setup.py b/qaboard-backend/setup.py deleted file mode 100755 index 2135a0055..000000000 --- a/qaboard-backend/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -# More information at -# https://setuptools.readthedocs.io/en/latest/setuptools.html -from setuptools import setup, find_packages - -setup( - name='qaboard-backend', - version="0.2", - license="Apache-2.0", - - description="Backend for QA-Board", - url="https://github.com/Samsung/qaboard", - - author="Arthur Flam", - author_email="arthur.flam@samsung.com", - - packages=find_packages(), - install_requires=[ - 'pandas>=0.22', - 'gitpython', # manipulate git repositories - 'click', # build CLI tools easily - 'flask', # HTTP server - 'flask_cors', - 'sqlalchemy', # ORM - 'alembic', # SQL schema migrations - 'psycopg2', # postgresql driver used by sqlalchemy - 'sqlalchemy_utils', - 'flask-admin', - 'uwsgi', - 'ujson', - ], - - entry_points= { - 'console_scripts': [ - 'slamvizapp_clean = slamvizapp.clean:clean', - 'slamvizapp_init_database = slamvizapp.scripts.init_database:init_database', - ] - }, - - # https://setuptools.readthedocs.io/en/latest/setuptools.html#including-data-files - include_package_data=True, - -) diff --git a/qaboard-backend/slamvizapp/README.md b/qaboard-backend/slamvizapp/README.md deleted file mode 100755 index 969d86eaa..000000000 --- a/qaboard-backend/slamvizapp/README.md +++ /dev/null @@ -1,117 +0,0 @@ -## slamvizapp-backend -Backend for `slamvizapp` built as a [flask](https://flask.pocoo.org) application. - - - that handle all our HTTP needs. - -## Overview -[sqlalchemy](http://docs.sqlalchemy.org/en/latest/orm/tutorial.html) maps our classes (defined in [/models](models/)) to database tables: - * **Projects** - * Versions of the code, called **CiCommits** - * Each commit has **Batches** of related **Outputs** - * Each output was run on a specific **TestInputs** - -Flask helps us create an HTTP server. It exposes API endpoints defined in the [api/](api/) folder. -- `/api.py`: read/list data about projects/commits/outputs -- `webhooks.py`: listens for (i) push notification from gitlab (ii) new results sent by `qatools`. -- `tuning.py`: ask for new tuning runs, - -`database.py` manages how we access our database, and connect to the git repository via `gitpython`. - -## Backups -```bash -# Take a look at: -# deployment/create-backup.sh - -# Manually, you can just do... -# https://www.postgresql.org/docs/9.1/backup-dump.html -export LC_ALL=C.UTF-8 -export LANG=C.UTF-8 -# from a computer with the same postgresql major version, run something like... -pg_dump --dbname=slamvizapp --username=ci --password -h localhost > backup.07-01-2019.sql - -``` - -# Recovery -```bash -> docker exec -it qaboard-production bash -export LC_ALL=C.UTF-8 LANG=C.UTF-8 -ps -aux | grep '\(flask run\|sudo .*uwsgi\)' | grep -v grep | awk '{print $2}' | xargs -I{} sudo kill {} -auth='--username=ci --password -h localhost' -PGPASS=$HOME/.pgpass -auth='--username=ci --no-password -h localhost' - -dropdb $auth slamvizapp -# Password: -createdb -T template0 $auth slamvizapp -Password: -$ pg_restore $auth --dbname slamvizapp /home/ispq/qaboard/database_backups/2019-03-21.dump -Password: -$ exit -> docker restart qaboard-production -``` - - -## Changing the database schemas -- when you add/rename/delete tables or fields to the database, you should define a migration - * we use [`alembic`](http://alembic.zzzcomputing.com/en/latest/tutorial.html) to manage migrations - * you'll find [many examples here](alembic/versions) - - -## Monitoring -``` -https://hub.docker.com/r/fenglc/pgadmin4/ -``` - -## Application performance -To get information about how much time is spend where in the python code: -```python -from ..utils import profiled -with profiled(): -``` - -[Read here](https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server) about how to investigate the database's performance. - -From the container, here is how to investigate the database CLI prompt: -```bash -sudo su - -# check performance issues with -# https://github.com/jfcoz/postgresqltuner -apt-get install -y libdbd-pg-perl -postgresqltuner.pl --host=localhost --database=slamvizapp --user=ci --password=dvsdvs - -# note that the database configuration is here -nano /etc/postgresql/9.6/main/postgresql.conf - -# you could also use pgbadger: -# https://github.com/dalibo/pgbadger -``` - -Profiling and getting an SQL prompt -``` - -# sudo -u postgres /usr/lib/postgresql/9.6/bin/postgres \ -# -D /var/lib/postgresql/9.6/main \ -# -c config_file=/etc/postgresql/9.6/main/postgresql.conf & -# sudo su postgres -# psql -d slamvizapp -# \dt -# select count(*) from outputs; -# alter table outputs rename to outputs_backup; -# alembic stamp f6a4bc0b55f8 -# alembic upgrade +1 -# alembic stamp head -# drop table.. -# EXPLAIN ANALYZE SELECT ci_commits.id AS ci_commits_id, ci_commits.project_id AS ci_commits_project_id, ci_commits.authored_datetime AS ci_commits_authored_datetime, ci_commits.branch AS ci_commits_branch, ci_commits.committer_name AS ci_commits_committer_name, ci_commits.message AS ci_commits_message, ci_commits.commit_dir_override AS ci_commits_commit_dir_override, ci_commits.commit_type AS ci_commits_commit_type, ci_commits.time_of_last_batch AS ci_commits_time_of_last_batch, ci_commits.latest_gitlab_pipeline AS ci_commits_latest_gitlab_pipeline, test_inputs_1.id AS test_inputs_1_id, test_inputs_1.path AS test_inputs_1_path, test_inputs_1.database AS test_inputs_1_database, test_inputs_1.data AS test_inputs_1_data, test_inputs_1.stereo_baseline AS test_inputs_1_stereo_baseline, test_inputs_1.is_wide_angle AS test_inputs_1_is_wide_angle, test_inputs_1.duration AS test_inputs_1_duration, test_inputs_1.is_dynamic AS test_inputs_1_is_dynamic, test_inputs_1.is_static AS test_inputs_1_is_static, test_inputs_1.is_calibration AS test_inputs_1_is_calibration, test_inputs_1.is_low_light AS test_inputs_1_is_low_light, test_inputs_1.is_flickering AS test_inputs_1_is_flickering, test_inputs_1.is_hdr AS test_inputs_1_is_hdr, test_inputs_1.motion_is_translation AS test_inputs_1_motion_is_translation, test_inputs_1.motion_is_rotation AS test_inputs_1_motion_is_rotation, test_inputs_1.motion_axis AS test_inputs_1_motion_axis, test_inputs_1.motion_speed AS test_inputs_1_motion_speed, outputs_1.id AS outputs_1_id, outputs_1.batch_id AS outputs_1_batch_id, outputs_1.created_date AS outputs_1_created_date, outputs_1.output_dir_override AS outputs_1_output_dir_override, outputs_1.output_type AS outputs_1_output_type, outputs_1.test_input_id AS outputs_1_test_input_id, outputs_1.platform AS outputs_1_platform, outputs_1.configuration AS outputs_1_configuration, outputs_1.extra_parameters AS outputs_1_extra_parameters, outputs_1.is_pending AS outputs_1_is_pending, outputs_1.is_running AS outputs_1_is_running, outputs_1.is_failed AS outputs_1_is_failed, outputs_1.metrics AS outputs_1_metrics, outputs_1.data AS outputs_1_data, batches_1.id AS batches_1_id, batches_1.created_date AS batches_1_created_date, batches_1.ci_commit_id AS batches_1_ci_commit_id, batches_1.label AS batches_1_label FROM ci_commits LEFT OUTER JOIN batches AS batches_1 ON ci_commits.id = batches_1.ci_commit_id LEFT OUTER JOIN outputs AS outputs_1 ON batches_1.id = outputs_1.batch_id LEFT OUTER JOIN test_inputs AS test_inputs_1 ON test_inputs_1.id = outputs_1.test_input_id WHERE ci_commits.project_id = 'dvs/psp_swip' AND ci_commits.authored_datetime <= '2018-07-02 13:16:10+00' AND ci_commits.authored_datetime >= '2018-06-28 13:16:10+00' ORDER BY ci_commits.authored_datetime DESC, batches_1.created_date; -``` - -permissions -``` -# quid: check access permissions -# https://gist.github.com/d11wtq/8699521 -# eval "$(ssh-agent -s)" -# ssh-add ~/.ssh/id_rsa -# ssh -o StrictHostKeyChecking=no git@gitlab-srv:dvs/psp_swip -# git clone git@gitlab-srv:dvs/psp_swip - -``` \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/__init__.py b/qaboard-backend/slamvizapp/__init__.py deleted file mode 100755 index eee345e5f..000000000 --- a/qaboard-backend/slamvizapp/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -from .database import db_session, Session - -# Configure the flask application -from flask import Flask -from flask_cors import CORS -app = Flask(__name__) - -# This is needed to use flask's sessions -# and eg display flash messages after redirects -app.secret_key = 'A0Zr98j/3yX R~JHCXQ!fgdsrtgLWX/,?RT' - -# Provide easy access to our git repositories -from .git_utils import Repos -from .config import git_server, app_data_directory -repos = Repos(git_server, app_data_directory) - - -# We could fetch the latest commits at startup -# TODO: find which projects to pull using the latest commits in the database -# from .git_utils import git_pull -# default_repo = repos['dvs/psp_swip'] -# git_pull(default_repo) - -# Some magic to use sqlalchemy safely with Flask -# http://flask.pocoo.org/docs/0.12/patterns/sqlalchemy/ -from slamvizapp.database import db_session -@app.teardown_appcontext -def shutdown_session(exception=None): - db_session.remove() - -import slamvizapp.api.api -import slamvizapp.api.webhooks -import slamvizapp.api.integrations -import slamvizapp.api.tuning -import slamvizapp.api.export_to_folder -import slamvizapp.api.auto_rois -import slamvizapp.api.milestones -import slamvizapp.admin - -# Enable cross-origin requests to avoid development headcaches -# cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) -CORS(app) diff --git a/qaboard-backend/slamvizapp/admin.py b/qaboard-backend/slamvizapp/admin.py deleted file mode 100644 index 5b61c28c4..000000000 --- a/qaboard-backend/slamvizapp/admin.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Small admin panel available at /admin - -https://flask-admin.readthedocs.io/en/latest/introduction/#getting-started -""" -from flask_admin import Admin -from flask_admin.contrib.sqla import ModelView -from flask_admin.form import fields as fa_fields - -from slamvizapp import app, db_session -from slamvizapp.models import TestInput, CiCommit, Batch, Project, Output - - -admin = Admin(app, name='slamvizapp', - template_mode='bootstrap3', - # index_view=ModelView(TestInput, db_session), - endpoint='admin', - ) - - -# we don't want to display those -one_to_many_columns = ['outputs',] -columns = set(c.name for c in TestInput.metadata.tables['test_inputs'].columns) -editable_columns = columns - set(['path', 'database', 'data']) -class TestInputModelView(ModelView): - page_size = 50 - create_modal = True - edit_modal = True - column_exclude_list = one_to_many_columns - form_excluded_columns = one_to_many_columns - can_view_details = True - column_searchable_list = ['path', 'database', 'data'] - column_filters = editable_columns - column_editable_list = editable_columns - form_override = dict( - json=fa_fields.JSONField - ) - -class ProjectModelView(ModelView): - column_list = ['id', 'data'] - column_searchable_list = ['id', 'data'] - can_delete = False - -class CommitModelView(ModelView): - column_list = ['id', 'hexsha', 'project_id', 'committer_name', 'message'] - column_searchable_list = ['hexsha', 'project_id', 'committer_name', 'message'] - column_filters = ['hexsha', 'committer_name', 'message'] - -class OutputModelView(ModelView): - column_list = ['id', 'project_id', 'committer_name', 'message'] - column_searchable_list = ['id', 'output_dir_override'] - column_filters = ['id', 'output_dir_override'] - - -admin.add_view(TestInputModelView(TestInput, db_session)) -admin.add_view(CommitModelView(CiCommit, db_session)) -admin.add_view(ModelView(Batch, db_session)) -admin.add_view(OutputModelView(Output, db_session)) -admin.add_view(ProjectModelView(Project, db_session)) diff --git a/qaboard-backend/slamvizapp/api/api.py b/qaboard-backend/slamvizapp/api/api.py deleted file mode 100755 index b67581605..000000000 --- a/qaboard-backend/slamvizapp/api/api.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Simple REST API to list the objects in our database. -""" -import sys -import datetime -import pytz -import subprocess -import json -from pathlib import Path - -import ujson -from gitdb.exc import BadName -from flask import request, jsonify, make_response, redirect - -from sqlalchemy import func, and_, asc, or_ -from sqlalchemy.orm import joinedload, selectinload -from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound -from sqlalchemy.sql import label - -from slamvizapp import app, db_session -from ..models import Project, CiCommit, Batch, Output -from ..models.LocalMocks import LocalCommit -from ..models import latest_successful_commit - - - -to_datetime = lambda s: timezone.localize(datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ')) -timezone = pytz.timezone("Asia/Tel_Aviv") - - -@app.route("/api/v1/commits") -@app.route("/api/v1/commits/") -@app.route("/api/v1/commits/") -def get_commits(branch=None): - project_id = request.args.get('project', 'dvs/psp_swip') - - to_date_s = request.args.get('to', None) - now_localized = timezone.localize(datetime.datetime.now()) - - to_date = to_datetime(to_date_s) if to_date_s else now_localized - to_date = to_date + datetime.timedelta(hours=3) # fix timezones hahaha - - from_date_s = request.args.get('from', None) - from_date = to_datetime(from_date_s) if from_date_s else (now_localized - datetime.timedelta(days=4)) - ci_commits = (db_session - .query(func.max(CiCommit.authored_datetime)) - .filter(CiCommit.project_id == project_id) - # now all projects should have results for all commits, with some CI - # .filter(CiCommit.batches.any()) - ) - if branch: - branch = branch.replace('origin/', '') - ci_commits = ci_commits.filter(or_(CiCommit.branch == branch, CiCommit.branch == f'origin/{branch}')) - committer_name = request.args.get('committer', None) - if committer_name: - ci_commits = ci_commits.filter_by(committer_name=committer_name) - - latest_authored_datetime = ci_commits.scalar() - if not latest_authored_datetime: - return jsonify([]) - from_date = min(latest_authored_datetime - (to_date - from_date), from_date) - - ci_commits = (db_session - .query(CiCommit) - .options(selectinload(CiCommit.batches).selectinload(Batch.outputs)) - .filter( - CiCommit.authored_datetime >= from_date, - CiCommit.authored_datetime <= to_date, - CiCommit.project_id == project_id, - ) - .order_by(CiCommit.authored_datetime.desc()) - ) - - if committer_name: - ci_commits = ci_commits.filter_by(committer_name=committer_name) - if branch: - ci_commits = ci_commits.filter(or_(CiCommit.branch == branch, CiCommit.branch == f'origin/{branch}')) - - - metrics_to_aggregate = json.loads(request.args.get('metrics', '{}')) - with_batches = None - batch = request.args.get('batch', None) - if batch: - with_batches = [batch] - else: - only_ci_batches = False if request.args.get('only_ci_batches', 'false')=='false' else True - if only_ci_batches: - with_batches = ['default', 'ci-android-rt', 'manual-android-rt'] - with_outputs = False if request.args.get('with_outputs', 'false')=='false' else True - # from ..utils import profiled - # with profiled(): - serializable_commits = [c.to_dict(with_aggregation=metrics_to_aggregate, with_batches=with_batches, with_outputs=with_outputs) - for c in ci_commits] - response = make_response(ujson.dumps(serializable_commits)) - response.headers['Content-Type'] = 'application/json' - return response - -@app.route("/api/v1/project/branches") -def get_branches(): - """Returns a list of that project's branches""" - project_id = request.args.get('project') - branches = (db_session - .query(CiCommit.branch) - .filter(CiCommit.project_id==project_id) - .distinct() - .order_by(CiCommit.branch) - ) - return jsonify([b[0] for b in branches]) - - - -@app.route("/api/v1/projects") -def get_projects(): - projects = (db_session - .query( - Project.id, - Project.data, - Project.latest_output_datetime, - label('latest_commit_datetime', func.max(CiCommit.authored_datetime)), - label('total_commits', func.count(CiCommit.id)), - ) - .join(CiCommit) - .group_by(Project.id) - .order_by(asc(func.lower(Project.id))) - .all() - ) - projects = { - project_id: { - # TODO: drop qatools_metrics from each project - # TODO: drop qatools_config - 'data': data, - 'latest_output_datetime': latest_output_datetime.isoformat() if latest_output_datetime else None, # isoformat not necessary? - 'latest_commit_datetime': latest_commit_datetime.isoformat(), - 'total_commits': total_commits, - } for project_id, data, latest_output_datetime, latest_commit_datetime, total_commits in projects } - response = make_response(ujson.dumps(projects)) - response.headers['Content-Type'] = 'application/json' - return response - -@app.route("/api/v1/project") -def get_project(): - project_id = request.args['project'] - project = (Project - .query.filter( - Project.id==project_id, - ) - .one() - ) - return jsonify(project.data) - - -@app.route("/api/v1/output/", methods=['GET', 'DELETE']) -@app.route("/api/v1/output//", methods=['GET', 'DELETE']) -def crud_output(output_id): - output = Output.query.filter(Output.id==output_id).one() - if request.method == 'GET': - return jsonify(output.to_dict()) - if request.method == 'DELETE': - if output.is_pending: - return {"error": "Please wait for the Output to finish running before deleting it"}, 500 - output.delete(soft=False) - db_session.delete(output) - db_session.commit() - return {"status": "OK"} - - -@app.route("/api/v1/output//manifest", methods=['GET']) -@app.route("/api/v1/output//manifest/", methods=['GET']) -def get_output_manifest(output_id): - output = Output.query.filter(Output.id==output_id).one() - if output.is_running or request.args.get('refresh'): - manifest = output.update_manifest(compute_hashes=False) - return jsonify(manifest) - else: - return redirect(f"{output.output_dir_url}/manifest.outputs.json", code=302) - - - - - -@app.route("/api/v1/commit") -@app.route("/api/v1/commit/") -@app.route("/api/v1/commit/") -def get_ci_commit(commit_id=None): - project_id = request.args['project'] - if not commit_id: - commit_id = request.args.get('commit', None) - - if not commit_id: - try: - project = Project.query.filter(Project.id==project_id).one() - default_branch = project.data['qatools_config']['project']['reference_branch'] - except: - default_branch = 'master' - branch = request.args.get('branch', default_branch) - ci_commit = latest_successful_commit(db_session, project_id=project_id, branch=branch, batch_label=request.args.get('batch')) - if not ci_commit: - return jsonify({'error': f'Sorry, we cant find any commit with results for this project on {branch}.'}), 404 - else: - try: # we try a commit from git - ci_commit = (db_session - .query(CiCommit) - .options( - joinedload(CiCommit.batches). - joinedload(Batch.outputs) - ) - .filter( - CiCommit.project_id==project_id, - CiCommit.hexsha.startswith(commit_id), - ) - .one() - ) - except MultipleResultsFound: - print(f'!!!!!!!!!!!!! Multiple results for commit {commit_id} @{project_id}') - ci_commit = (db_session - .query(CiCommit) - .options( - joinedload(CiCommit.batches). - joinedload(Batch.outputs) - ) - .filter( - CiCommit.project_id==project_id, - CiCommit.hexsha.startswith(commit_id), - ) - .first() - ) - except NoResultFound: - try: - commit = project.repo.commit(commit_id) - ci_commit = CiCommit(commit, project=project) - db_session.add(ci_commit) - db_session.commit() - except: - return jsonify({'error': 'Sorry, we could not find the commit in the cloned git repo.'}), 404 - except BadName: - try: - ci_commit = LocalCommit(commit_id) - except: - return jsonify({'error': 'Sorry, we could not find the commit folder.'}), 404 - except Exception as e: - raise(e) - return jsonify({'error': 'Sorry, the request failed.'}), 500 - # FIXME: we should add details about the outputs... - # FIXME: how do we get the reference commit? - - batch = request.args.get('batch', None) - with_batches = [batch] if batch else None # by default we show all batches - with_aggregation = json.loads(request.args.get('metrics', '{}')) - response = make_response(ujson.dumps(ci_commit.to_dict(with_aggregation, with_batches=with_batches, with_outputs=True))) - response.headers['Content-Type'] = 'application/json' - return response diff --git a/qaboard-backend/slamvizapp/api/auto_rois.py b/qaboard-backend/slamvizapp/api/auto_rois.py deleted file mode 100755 index f45d4ca43..000000000 --- a/qaboard-backend/slamvizapp/api/auto_rois.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Returns a list of rois. -Create a pdf report of rois comparison. -""" -from pathlib import Path -import time # for Debugging purpose -from math import sqrt, ceil - -import numpy as np -from skimage.color import deltaE_cie76, rgb2lab, rgb2yiq -from skimage.viewer import ImageViewer # for Debugging purpose -from skimage.transform import rescale -from skimage.feature import blob_dog # blob_log, blob_doh -from skimage import io -import matplotlib.pyplot as plt -from matplotlib.backends.backend_pdf import PdfPages - -from requests.utils import unquote -from flask import request, jsonify - -from slamvizapp import app -from ..models import Output - - - -@app.route("/api/v1/output/diff/image", methods=['GET', 'POST']) -def get_images(): - data = request.get_json() - # Directory URLs begin with /s/ - new_url = Path(unquote(data['output_dir_url_new'][2:])) / data["path"] - ref_url = Path(unquote(data['output_dir_url_ref'][2:])) / data["path"] - # print(data) # DEBUG - blobs = createAutoRois(new_url, ref_url, data["diff_type"], data['threshold'], data['diameter']) - - blobs = (blobs.tolist()) - # print(blobs) - # print("len:", len(blobs)) - - ## limits the number of rois to "num_crops", filtering small rois. - while len(blobs) > data['count']: - index_min = np.argmin([yxr[2] for yxr in blobs]) - del blobs[index_min] - - # print(blobs) # DEBUG - # print("sorted len: ", len(blobs)) # DEBUG - - return jsonify(blobs) - - - -def createAutoRois(path1, path2, diff_type, threshold, blob_diameter): - scale = 0.5 # default rescaling for delta image - blob_ratio = 0.1 # default ratio for blob diameter - min_sigma = 5 # for blob_dog algorithm - - image_1 = io.imread(Path(path1)) - image_2 = io.imread(Path(path2)) - - ''' - print(image_1.shape) - print(image_1.size) - print(image_1.shape[0]*image_1.shape[1]) - print("type:", type(image_1)) - print(image_1[0][0]) - ''' - - ''' - #image_1_orig = image_1 # DEBUG - image_1 = rescale(image_1, scale, mode='constant', - multichannel=True, anti_aliasing=True) - image_2 = rescale(image_2, scale, mode='constant', - multichannel=True, anti_aliasing=True) - ''' - - # start = time.time() # DEBUG - delta = diff(image_1, image_2, diff_type) - # end = time.time() # DEBUG - # print("diff time: {} sec".format(end-start)) # DEBUG - - width = image_1.shape[0] - height = image_1.shape[1] - if (width * height < 1000000): - scale = 1 - - - # print("scale: ", scale) # DEBUG - delta = rescale(delta, scale, mode='reflect', multichannel=False, anti_aliasing=True) - width = image_1.shape[0] - height = image_1.shape[1] - # print("delta: ", delta) # DEBUG - - - ''' - output = np.empty([width, height]) - print("output type:", type(output)) - print("image1 shape:",image_1.shape) - print("output shape:", output.shape) - ''' - - ''' - print("delta shape:", delta.shape) - print("delta size:",delta.size) - print(delta.max()) - np.savetxt("/home/itamarp/delta.txt", delta) - viewer = ImageViewer((delta)) #, plugins=[]) - viewer.show() - ''' - - - if int(blob_diameter) == 0 : - blob_diameter = (width + height) / 2 * blob_ratio - - # print("blob_diameter: ", blob_diameter) # DEBUG - max_sigma = int(blob_diameter) * scale - - if min_sigma >= max_sigma: - min_sigma = 1 - - start = time.time() # DEBUG - blobs = blob_dog(delta, min_sigma=min_sigma, max_sigma=int(max_sigma), threshold=(float(threshold) / 100)) # Divide treshold to increase sensetivity - end = time.time() # DEBUG - print(f"blob_dog time: {end-start} sec") - - blobs[:, 0] = blobs[:, 0] * 1 / scale - blobs[:, 1] = blobs[:, 1] * 1 / scale - # The radius of each blob is approximately √2*σ - blobs[:, 2] = blobs[:, 2] * sqrt(2) - - - # print("blobs size:", blobs.size / 3) # DEBUG - # figure, ax = plt.subplots(figsize=(15, 15)) # DEBUG - # ax.imshow(image_1_orig) # DEBUG - - for blob in blobs: - blob[2] = ceil(blob[2]) - # y, x, r = blob # DEBUG - # c = plt.Circle((x, y), r, color="red", linewidth=1, fill=False) # DEBUG - # ax.add_patch(c) # DEBUG - - - # plt.savefig('C:/Users/itamarp/Desktop/blobs.png', dpi=300) # DEBUG - # plt.tight_layout() # DEBUG - # plt.show() # DEBUG - - return blobs - - -################################################################################ -def diff(image_1, image_2, diff_type): - # if (diff_type == "rgb"): # for future development (SSIM) - - delta = pixelmatch(image_1, image_2) - ## other possibilities are CIE94, CIEDE2000, CMC l:c (1984) - # delta = deltaE_cie76(rgb2lab(image_1), rgb2lab(image_2)) - - return delta - - -def pixelmatch(img1, img2) : - yuv1 = rgb2yiq(img1) - yuv2 = rgb2yiq(img2) - delta2 = np.square(yuv1 - yuv2) # why square? - return delta2 @ [0.5053, 0.299, 0.1957] - -################################################################################ -@app.route("/api/v1/output/diff/report", methods=['GET', 'POST']) -def get_rois(): - data = request.get_json() - # Directory URLs begin with /s/ - report_folder = Path(data['output_dir_url_new'][2:]) / "reports" - new_url = Path(data['output_dir_url_new'][2:]) / data["path"] - ref_url = Path(data['output_dir_url_ref'][2:]) / data["path"] - rois = data['rois'] - # print(data) # DEBUG - - time_tuple = time.localtime() # get struct_time - time_string = time.strftime("%d%m%Y_%H%M%S", time_tuple) - report_path = f"{report_folder}/{time_string}_report.pdf" - report_url = f"https://qa/s/{report_folder}/{time_string}_report.pdf" - Path(report_folder).mkdir(parents=True, exist_ok=True) - - image_1 = io.imread(Path(new_url)) - image_2 = io.imread(Path(ref_url)) - - - with PdfPages(report_path) as pdf: - - new_ci_output = Output.query.filter(Output.id == data['output_id_new']).one().batch.ci_commit.hexsha - ref_ci_output = Output.query.filter(Output.id == data['output_id_ref']).one().batch.ci_commit.hexsha - - firstPage = plt.figure(figsize=(10,5)) - firstPage.clf() - txt = f"Auto Rois Report\n{time.asctime(time_tuple)}\nnew: {new_ci_output}\nref: {ref_ci_output}" - - firstPage.text(0.05, 0.5, txt, transform=firstPage.transFigure, size=14, ha='left', linespacing=2) - pdf.savefig() - plt.close() - - for roi in rois: - x, y, w, h = roi['x'], roi['y'] ,roi['w'] ,roi['h'] - crop1 = crop_image(image_1, roi['x'], roi['y'] ,roi['w'] ,roi['h']) - crop2 = crop_image(image_2, roi['x'], roi['y'] ,roi['w'] ,roi['h']) - figure, axes = plt.subplots(1, 2, figsize=(10, 5), sharex=True, sharey=True) - - ax = axes.ravel() - ax[0].imshow(crop1) - ax[1].imshow(crop2) - ax[0].set_title(f"new (x: {x}, y: {y}, w: {w}, h: {h})") - ax[1].set_title("ref") - - figure.canvas.draw() - xlabels = [item.get_text() for item in ax[0].get_xticklabels()] - ylabels = [item.get_text() for item in ax[0].get_yticklabels()] - for i, label in enumerate(xlabels): - try: # The minus signs for negative numbers is encoded as a "minus" (Unicode 2212). - xlabels[i] = int(label) + roi['x'] - except: - continue - - for i, label in enumerate(ylabels): - try: - ylabels[i] = int(label) + roi['y'] - except: - continue - - ax[0].set_xticklabels(xlabels) - ax[0].set_yticklabels(ylabels) - - plt.subplots_adjust(bottom=0.15, wspace=0.01) - pdf.savefig(figure, orientation='portrait') - plt.close() - - print("Report done: ", report_url) - return jsonify(report_url) - - -def crop_image(img, cropx, cropy, cropw, croph): - return img[cropy:cropy+croph, cropx:cropx+cropw] - -################################################################################ -if __name__ == "__main__": - - app.run() - - ''' - start = time.time() - - path1 = 'C:/Users/itamarp/Desktop/itamar1.bmp' - path2 = 'C:/Users/itamarp/Desktop/itamar2.bmp' - threshold = 0.01 - blobs = createAutoRois(path1, path2, "RGB", threshold) - print(blobs) - - end = time.time() - print("time: {} sec".format(end-start)) - ''' \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/api/export_to_folder.py b/qaboard-backend/slamvizapp/api/export_to_folder.py deleted file mode 100755 index 76b2f158d..000000000 --- a/qaboard-backend/slamvizapp/api/export_to_folder.py +++ /dev/null @@ -1,319 +0,0 @@ -""" -Implement the API used by the "Export to a shared directory" plugin. -""" -import sys -import os -import re -import json -import hashlib -from pathlib import Path - -from requests.utils import quote -from flask import request, jsonify, make_response -from sqlalchemy import func, and_, asc, or_ -from sqlalchemy.orm import joinedload -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.sql import label - -from qatools.utils import copy -from qatools.conventions import deserialize_config, serialize_config -from slamvizapp import app, db_session -from ..models import Project, CiCommit, Batch, slugify_config - - - -def load_commit(project_id, commit_id): - if not commit_id: return None - try: - return (db_session - .query(CiCommit) - .options( # avoid n+1 queries - joinedload(CiCommit.batches). - joinedload(Batch.outputs) - ) - .filter( - CiCommit.project_id==project_id, - CiCommit.hexsha==commit_id, - ) - .one() - ) - except: - return None - - - -# no need to make a copy - we don't reuse the outputs -def filter_outputs(query, outputs): - if not query: - return outputs - - query = query.lower().replace('"', '') - query = re.sub(r'[=:] +', ':', query) - - tokens = query = query.split() - negative_tokens = [t[1:] for t in tokens if t.startswith('-')] - positive_tokens = [t for t in tokens if not t.startswith('-')] - - def match(output): - extra_parameters = json.dumps(output.extra_parameters) - extra_parameters = re.sub(r'[=:] +', ':', extra_parameters).replace('"', '') - searched = f"{output.test_input.path} {output.platform} {output.configuration} {extra_parameters}".lower() - # print(searched) - # not using output.test_input_tags.join() like in the JS - if any([t in searched for t in negative_tokens]): - return False - found = all([t in searched for t in positive_tokens]) - # print(found) - return (not positive_tokens or found) - outputs = [o for o in outputs if match(o)] - return outputs - - -def compatible(o1, o2): - if o1.test_input.path == o2.test_input.path: - return True - if o1.test_input.data and o2.test_input.data and o1.test_input.data.get('id') and o1.test_input.data.get('id') == o2.test_input.data.get('id'): - return False - -# Note: already defined in qatools.tuning, but raises instead of returning None -def matching_output(output_reference, outputs): - """ - Return the output from from a given batch that looks most similar to a given output. - This helps us compare an output to historical results. - """ - possible_matching_outputs = [o for o in outputs if compatible(o, output_reference)] - valid_outputs = [o for o in possible_matching_outputs if not o.is_pending and not o.is_failed] - if not valid_outputs: return None - - def match_key(output): - has_meta_id = output.test_input.data and output_reference.test_input.data and output.test_input.data.get('id') - return ( - 4 if has_meta_id and output.test_input.data.get('id') == output_reference.test_input.data.get('id') else 0 + - 4 if output.configuration == output_reference.configuration else 0 + - 2 if output.platform == output_reference.platform else 0 + - 1 if json.dumps(output.extra_parameters, sorted=True) == json.dumps(output_reference.extra_parameters, sorted=True) else 0 - ) - valid_outputs.sort(key=match_key, reverse=True) - return valid_outputs[0] - - - -def commonprefix(m): - # https://github.com/python/cpython/blob/3.7/Lib/genericpath.py#L69 - if not m: return [] - def key(x): - return (len(x), str(x)) - s1 = min(m, key=key) - s2 = max(m, key=key) - for i, c in enumerate(s1): - print(i, c, file=sys.stderr) - if c != s2[i]: - return s1[:i] - return s1 - - -@app.route("/api/v1/export") -@app.route("/api/v1/export/") -def export_to_folder(): - project_id = request.args['project'] - - new_commit = load_commit(project_id, request.args['new_commit_id']) - if not new_commit: - return f"ERROR: Commit {request.args['new_commit_id']} not found", 404 - new_batch = new_commit.get_or_create_batch(request.args.get('batch_new', 'default')) - new_outputs = new_batch.outputs - - if request.args.get('ref_commit_id'): - ref_commit = load_commit(project_id, request.args['ref_commit_id']) - if not ref_commit: - return f"ERROR: Commit {request.args['ref_commit_id']} not found", 404 - ref_batch = ref_commit.get_or_create_batch(request.args.get('batch_ref', 'default')) - ref_outputs = ref_batch.outputs - else: - ref_commit = None - ref_batch = None - ref_outputs = [] - - filter_new = request.args.get('filter_new') - filter_ref = request.args.get('filter_ref') - new_outputs = filter_outputs(filter_new, new_outputs) - ref_outputs = filter_outputs(filter_ref, ref_outputs) - - # We save the links in a unique folder - query_string = f"{project_id} {new_commit.hexsha} {ref_commit.hexsha if ref_commit else ''} {new_batch.id} {ref_batch.id if ref_batch else ''} {filter_new} {filter_ref}" - m = hashlib.md5(query_string.encode('utf-8')).hexdigest() - export_dir = new_commit.repo_commit_dir / 'share' / m[:8] - export_dir.mkdir(parents=True, exist_ok=True) - - output_refs = {} - for output in new_outputs: - output_refs[output.id] = matching_output(output, ref_outputs) - - # find common characteristics - common_data = {} - if not ref_commit or ref_commit.id == new_commit.id: - common_data['commit'] = new_commit.hexsha - all_outputs = [*new_outputs, *list(output_refs.values())] - all_outputs = [o for o in all_outputs if o] # remove None outputs - all_platforms = {o.platform for o in all_outputs} - if len(all_platforms) == 1: - common_data['platform'] = all_outputs[0].platform - all_configurations = {o.configuration for o in all_outputs} - if len(all_configurations) == 1: - common_data['configuration'] = deserialize_config(all_outputs[0].configuration) - elif len(all_configurations) > 1: - all_configurations = [deserialize_config(o.configuration) for o in all_outputs] - common_data['configuration_prefix'] = commonprefix(all_configurations) - all_reversed_configurations = [list(reversed(deserialize_config(o.configuration))) for o in all_outputs] - common_data['configuration_suffix'] = list(reversed(commonprefix(all_reversed_configurations))) - # To be honest, we really should find what is common in each batch - # and use @new-* @ref-*. It gives more flexibility for comparing N batches, and can shorten things even more - - all_extra_parameters = set() - common_extra_parameters = {} - for o in all_outputs: - all_extra_parameters.update(set(o.extra_parameters.keys())) - all_extra_parameters_prefix = commonprefix([p for p in all_extra_parameters]) - for key in all_extra_parameters: - values = set() - for o in all_outputs: - if not o.extra_parameters: o.extra_parameters = {} - o_value = [str(o.extra_parameters.get(key))] - # print(o.id, o_value) - values.update(set(o_value)) - print(key, values) - if len(values) == 1: - if not all_outputs[0].extra_parameters: all_outputs[0].extra_parameters = {} - common_extra_parameters[key] = all_outputs[0].extra_parameters.get(key) - if common_extra_parameters: - common_data['extra_parameters'] = common_extra_parameters - with (export_dir / '0.common.json').open('w') as f: - json.dump(common_data, f, sort_keys=True, indent=2, separators=(',', ': ')) - - - - glob = request.args.get('path', '*') - for output in new_outputs: - output_ref = output_refs[output.id] - if not output_ref: - output_ref = output - - # we save a mapping label => full into - label_mappings = { - 'extra_parameters': {}, - 'configurations': {}, - } - - def get_labels(output): - labels = [] - if output.batch.ci_commit.hexsha != common_data.get("commit"): - labels.append(output.batch.ci_commit.hexsha[:4]) - if output.platform != common_data.get("platform"): - labels.append(output.platform) - if not common_data.get("configuration"): - def strip_config(c): - c_prefix = serialize_config(common_data.get("configuration_prefix", 'placeholder-placeholder')) - c_suffix = serialize_config(common_data.get("configuration_suffix", 'placeholder-placeholder')) - return c.replace(c_prefix, '').replace(c_suffix, '') - stripped_config = slugify_config(strip_config(output.configuration)) - # list of common SIRC-specific names - stripped_config = stripped_config.replace('workspace-configurations-', '') - if stripped_config: - labels.append(stripped_config) - label_mappings['configurations'][stripped_config] = output.extra_parameters - if str(output.extra_parameters) != str(common_data.get("extra_parameters")): - tame = lambda o: set(((k.replace(all_extra_parameters_prefix, ''), str(v)) for k, v in o.items())) - p = tame(output.extra_parameters) - tame(common_extra_parameters) - # print('common_extra_parameters', common_extra_parameters) - # print('tame(common_extra_parameters)', tame(common_extra_parameters)) - # print('output.extra_parameters', output.extra_parameters) - # print('tame(output.extra_parameters)', tame(output.extra_parameters)) - # print('p_new', p_new) - extra_parameters_label = slugify_config(str(p)) - label_mappings['extra_parameters'][extra_parameters_label] = output.extra_parameters - if p: labels.append(extra_parameters_label) - stitch = lambda l: f"@{'@'.join(l)}" if l else '' - label = stitch(labels) - # print('label', label) - return label - - - label_new = get_labels(output) - label_ref = get_labels(output_ref) - - if label_mappings['configurations'] or label_mappings['extra_parameters']: - with (export_dir / '0.mappings.json').open('w') as f: - json.dump(label_mappings, f, indent=4, sort_keys=True) - - for output_path in output.output_dir.glob(glob): - output_path_rel = output_path.relative_to(output.output_dir) - copied_to_rel = copy_path_rel(output, output_path, label=label_new) - symlink_to(export_dir / copied_to_rel, output_path) - # copy(output_path, export_dir / copied_to_rel) - if output_ref and output_ref.id != output.id: - output_path_ref = output_ref.output_dir / output_path_rel - if output_path_ref.exists(): - copied_to_rel = copy_path_rel(output_ref, output_path_ref, label=label_ref) - symlink_to(export_dir / copied_to_rel, output_path_ref) - # copy(output_path, export_dir / copied_to_rel) - - params = { - "batch": new_batch.label, - "reference": ref_commit.hexsha if ref_commit else None, - "batch_ref": ref_batch.label if ref_batch else None, - "filter": filter_new if filter_new else None, - "filter_ref": filter_ref if filter_ref else None, - } - params = {k: quote(v) for k, v in params.items() if v} - url = f"https://qa/{project_id}/commit/{new_commit.hexsha}?{'&'.join(f'{k}={v}' for k, v in params.items())}" - redirect = f""" - - - - - - Page Redirection - - - - If you are not redirected automatically, follow this link to the QA results. - - """ - redirect_file = export_dir / '0.qa.html' - if not redirect_file.exists(): - with redirect_file.open('w') as f: - f.write(redirect) - - link_content = f"[InternetShortcut]\nURL={url}\n" - link_file = export_dir / '0.qa.url' - if not link_file.exists(): - with link_file.open('w') as f: - f.write(link_content) - return jsonify({ - "export_dir": str(export_dir), - }) - - - - - -def symlink_to(path_from, path_to): - try: - if path_from.exists(): - path_from.unlink() - os.link(str(path_to), str(path_from)) - # path_from.symlink_to(path_to) - except: - pass - - -def copy_path_rel(output, output_path, label): - output_path_rel = output_path.relative_to(output.batch.output_dir) - # we remove the platform, configuration, and tuning hashes - levels_to_ignore = 2 if output.batch.label == 'default' else 4 - copied_rel = Path(*output_path_rel.parts[levels_to_ignore:]) - copied_rel = copied_rel.parent / f"{output_path_rel.stem}{label}{output_path_rel.suffix}" - copied_rel = str(copied_rel).replace('/', '•') # or \ ? or just name .... ?? - return copied_rel diff --git a/qaboard-backend/slamvizapp/api/integrations.py b/qaboard-backend/slamvizapp/api/integrations.py deleted file mode 100755 index 2990cfd5e..000000000 --- a/qaboard-backend/slamvizapp/api/integrations.py +++ /dev/null @@ -1,272 +0,0 @@ -""" -Backend API for the integrations features. -""" -import os -import re -import time - -from flask import request, jsonify, make_response -import requests -from requests import Request, Session -from requests.utils import quote -from requests.auth import HTTPBasicAuth - -from slamvizapp import app - - -# FIXME: Currently we rely on environment variables for the gitlab/jenkins credentials -# We only allow 1 single instance of each, with a single auth. -# TODO: - Short term, we can at least read from os.environ["QABOARD_SECRETS"]=/etc/qaboard_secrets.json -# { -# secrets: [ -# { -# "host": "http://jensirc:8080", -# "auth": ("user", "token"), -# "headers": {"Jenkins-User-Crumb": "xxxxxxx"} -# }, -# ... -# ] -# } -# TODO: - Longer-term, we should use a centralized per user/project secret store - -# We love our proxies -import urllib3 -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -@app.route("/api/v1/webhook/proxy", methods=['POST']) -@app.route("/api/v1/webhook/proxy/", methods=['POST']) -def proxy_webook(): - """ - Proxy users' webhook triggers to avoid CORS issues. - """ - data = request.get_json() - print(data['method'], data.get('url')) - data['method'] = data['method'].upper() - if 'auth' in data: - # we could easily support other types of authentification - # https://2.python-requests.org/en/master/user/authentication/ - data['auth'] = HTTPBasicAuth(data['auth']['username'], data['auth']['password']) - session = Session() - r = Request(**data) - r_prepped = r.prepare() - r = session.send(r_prepped, verify=False) - # It would be great to just - # return r.content, r.status_code - # but e.g. Jenkins returns important data in its headers - resp = make_response(r.content, r.status_code) - # this might not be the cleanest way to pass headers, - # e.g. what happens to Content-Length? - for k, v in r.headers.items(): - if k.lower() == 'content-length': - continue - resp.headers.set(k, v) - return resp - - -@app.route("/api/v1/gitlab/job", methods=['POST']) -@app.route("/api/v1/gitlab/job/", methods=['POST']) -def gitlab_job(): - """ - Get information about a GitlabCI manual job. - """ - data = request.get_json() - gitlab_api = f"{data['gitlab_host']}/api/v4" - gitlab_headers = { - 'Private-Token': os.environ['GITLAB_ACCESS_TOKEN'], - } - project_id = quote(data['project_id'], safe='') - if data.get('job_id'): - job_id = data['job_id'] - else: - # Get the latest pipeline for this commit - url = f"{gitlab_api}/projects/{project_id}/repository/commits/{data['commit_id']}" - r = requests.get(url, headers=gitlab_headers) - pipeline_id = r.json()['last_pipeline']['id'] - # Get the list of manual jobs in that pipeline - # https://docs.gitlab.com/ee/api/jobs.html#list-pipeline-jobs - url = f"{gitlab_api}/projects/{project_id}/pipelines/{pipeline_id}/jobs" - r = requests.get(url, headers=gitlab_headers) - jobs = r.json() - try: - matching_jobs = [j for j in jobs if data['job_name'] == j['name']] - for j in matching_jobs: - print(j['name'], j['id'], j["created_at"], j['status']) - except Exception as e: - return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 - if not matching_jobs: - return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 - # FIXME: sort by id - job_id = matching_jobs[-1]['id'] - - url = f"{gitlab_api}/projects/{project_id}/jobs/{job_id}" - try: - r = requests.get(url, headers=gitlab_headers) - print(r.json()) - return r.content, r.status_code - except Exception as e: - return jsonify({"error": f'Error: {e}'}), 500 - - - -@app.route("/api/v1/gitlab/job/play", methods=['POST']) -@app.route("/api/v1/gitlab/job/play/", methods=['POST']) -def gitlab_play_manual_job(): - """ - Trigger a GitlabCI manual job. - """ - data = request.get_json() - - gitlab_api = f"{data['gitlab_host']}/api/v4" - gitlab_headers = { - # FIXME: store the credentials in a "secret store", global per user/project - 'Private-Token': os.environ['GITLAB_ACCESS_TOKEN'], - } - project_id = quote(data['project_id'], safe='') - - # Get the latest pipeline for this commit - url = f"{gitlab_api}/projects/{project_id}/repository/commits/{data['commit_id']}" - r = requests.get(url, headers=gitlab_headers) - pipeline_id = r.json()['last_pipeline']['id'] - - # Get the list of manual jobs in that pipeline - # https://docs.gitlab.com/ee/api/jobs.html#list-pipeline-jobs - url = f"{gitlab_api}/projects/{project_id}/pipelines/{pipeline_id}/jobs" - r = requests.get(url, headers=gitlab_headers) - jobs = r.json() - try: - matching_jobs = [j for j in jobs if data['job_name'] == j['name']] - for j in matching_jobs: - print(j['name'], j['id'], j["created_at"], j['status']) - except Exception as e: - return jsonify({"error": f'Only these jobs are available: {jobs}'}), 404 - - # Play the job - # https://docs.gitlab.com/ee/api/jobs.html - url = f"{gitlab_api}/projects/{project_id}/jobs/{matching_jobs[0]['id']}/play" - try: - r = requests.post(url, headers=gitlab_headers) - print(r.json()) - return r.content, r.status_code - except Exception as e: - print(url) - print(e) - return jsonify({"error": f"ERROR: when posting to {url}: {e}"}), 500 - - - -@app.route("/api/v1/jenkins/build", methods=['POST']) -@app.route("/api/v1/jenkins/build/", methods=['POST']) -def jenkins_build(): - """ - Get the status of a Jenkins build. - """ - jenkins_credentials = { - "auth": HTTPBasicAuth( - os.environ['JENKINS_USER_NAME'], - os.environ['JENKINS_USER_TOKEN'], - ), - "headers": { - "Jenkins-Crumb": os.environ['JENKINS_USER_CRUMB'], - } - } - data = request.get_json() - try: - r = requests.get( - f"{data['web_url']}/api/json", - **jenkins_credentials, - ) - except Exception as e: - print(e) - return jsonify({"error": f"ERROR: When reading build info: {e}"}), 500 - build_data = r.json() - # print(build_data.get('building'), build_data.get('result')) - # https://javadoc.jenkins-ci.org/hudson/model/Result.html - allow_failure = False - if build_data.get('blocked'): - status = "BLOCKED" - if build_data.get('stuck'): - status = "STUCK" - if build_data['building']: - status = "running" - elif build_data.get('result'): - if build_data['result'] == 'SUCCESS': - status = "success" - elif build_data['result'] == 'UNSTABLE': - allow_failure = True - status = "UNSTABLE" - elif build_data['result'] == 'FAILURE': - status = "failed" - elif build_data['result'] == 'NOT_BUILT': - status = "NOT_BUILT" - elif build_data['result'] == 'ABORTED': - status = "ABORTED" - else: - return jsonify({"error": "ERROR: unknown status"}), 500 - else: - status = "canceled" - return jsonify({ - "status": status, - "allow_failure": allow_failure, - "web_url": data['web_url'], - }) - - - -@app.route("/api/v1/jenkins/build/trigger", methods=['POST']) -@app.route("/api/v1/jenkins/build/trigger/", methods=['POST']) -def jenkins_build_trigger(): - """ - Trigger a Jenkins build. - """ - - data = request.get_json() - jenkins_credentials = { - "auth": HTTPBasicAuth( - os.environ['JENKINS_USER_NAME'], - os.environ['JENKINS_USER_TOKEN'], - ), - "headers": { - "Jenkins-Crumb": os.environ['JENKINS_USER_CRUMB'], - } - } - build_url = re.sub("/$", "", data['build_url']) - build_trigger_url = f"{build_url}/buildWithParameters" - try: - r = requests.post( - build_trigger_url, - params={ - "token": "qatools", - "cause": "Triggered via QA-Board", - **data['params'], - }, - **jenkins_credentials, - ) - except Exception as e: - print(build_trigger_url) - print(e) - return jsonify({"error": f"ERROR: When triggering job: {e}"}), 500 - - - build_queue_location = f"{r.headers['location']}/api/json" - time.sleep(5) # jenkins quiet period - sleep_total = 5 - web_url = None - while not web_url and sleep_total < 30: - try: - r = requests.get( - build_queue_location, - **jenkins_credentials, - ) - web_url = r.json()['executable']['url'] - print(r.json()) - except Exception as e: - print(e) - time.sleep(0.5) - sleep_total = sleep_total + 0.5 - if not web_url: - return jsonify({"error": f"ERROR: When reading build queue info: {e}"}), 500 - return jsonify({ - "web_url": r.json()['executable']['url'], - "status": 'pending', - }) diff --git a/qaboard-backend/slamvizapp/api/rest.py b/qaboard-backend/slamvizapp/api/rest.py deleted file mode 100644 index 245fa04ea..000000000 --- a/qaboard-backend/slamvizapp/api/rest.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -At some point we may want to generate the API automatically from our schema. -https://flask-restless.readthedocs.io/en/stable/customizing.html -""" - - -# from flask_restless import APIManager -# from flask_restless.serialization import DefaultSerializer - -# manager = APIManager(session=db_session, url_prefix='/api/v1') - - # https://flask-restless.readthedocs.io/en/latest/serialization.html -# class CiCommitSerializer(DefaultSerializer): -# def serialize(self): -# return { -# 'id': self.id, -# 'branch': self.branch, -# 'message': self.gitcommit.message, -# 'authored_datetime': self.authored_datetime, -# 'time_of_last_batch': self.time_of_last_batch, -# 'commit_dir_url': self.commit_dir_url, -# 'aggregated_metrics': self.aggregated_metrics(), -# 'failure_count': self.failure_count(), -# 'valid_outputs': [o.id for o in self.valid_outputs], -# } - - -# manager.create_api(CiCommit, -# methods=['GET', 'POST', 'DELETE'], -# # # exclude_columns=['outputs'], -# # serializer_class=CiCommitSerializer, -# # # includes = ['name', 'birth_date', 'computers', 'computers.vendor'] -# ) -# manager.create_api(TestInput, -# methods=['GET', 'POST', 'DELETE'], -# # # results_per_page=40, # ?page=X -# ) -# manager.create_api(ParametersSet, -# methods=['GET', 'POST', 'DELETE'], -# # # results_per_page=40, -# ) -# manager.create_api(Output, -# methods=['GET', 'POST', 'DELETE'], -# # # results_per_page=40, -# ) - -# manager.init_app(app) diff --git a/qaboard-backend/slamvizapp/api/tuning.py b/qaboard-backend/slamvizapp/api/tuning.py deleted file mode 100755 index 28877f4d8..000000000 --- a/qaboard-backend/slamvizapp/api/tuning.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -APIs related to parameter tuning -""" -import re -import os -import sys -import json -import datetime -import itertools -import subprocess -from pathlib import Path - -import yaml -from flask import request, jsonify -from sqlalchemy.orm.exc import NoResultFound - -from qatools.iterators import iter_inputs -from qatools.conventions import deserialize_config - -from slamvizapp import app, db_session -from ..models import CiCommit, Project -from ..config import shared_data_directory - - -def get_groups_path(project_id, name="extra-batches"): - """ - Return the path of the file where we save the groups of tests we defined for a project. - Creates it if it does not exist yet. - """ - path = shared_data_directory / project_id / f"{name}.yml" - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w") as f: - f.write("""# Docs:\n# http://qa-docs/docs/batches-running-on-multiple-inputs""") - return path - - - -@app.route("/api/v1/tests/groups", methods=["GET", "POST"]) -def groups(): - """ - Return or update the groups of tests we defined for a project. - TODO: We could just make it part of the database, why bother with files... - It could be saved as test as project.data.test_groups - We would *just* need to write the migration, and it would save 30 lines of code. - """ - project_id = request.args["project"] - groups_path = get_groups_path(project_id) - if request.method == "POST": - data = request.get_json() - try: - yaml.load(data["groups"], Loader=yaml.SafeLoader) - except Exception as e: - return jsonify(str(e)), 400 - with groups_path.open("w") as f: - f.write(data["groups"]) - return jsonify("OK") - else: - try: - with groups_path.open("r") as f: - return f.read() - except: - return ( - jsonify( - {"error": f"Could not open or read {groups_path}"} - ), - 500, - ) - - -def get_commit_groups_paths(project, commit_id): - groups_paths = [] - try: - ci_commit = CiCommit.query.filter( - CiCommit.project_id == project.id, CiCommit.hexsha.startswith(commit_id) - ).one() - commit_config_inputs = ci_commit.data['qatools_config'].get('inputs', {}) - commit_group_files = commit_config_inputs.get('batches', commit_config_inputs.get('groups', [])) - print(commit_group_files, file=sys.stderr) - if not (isinstance(commit_group_files, list) or isinstance(commit_group_files, tuple)): - commit_group_files = [commit_group_files] - - # custom groups have priority over the commit's groups - for group_file in commit_group_files: - if (ci_commit.repo_commit_dir / group_file).exists(): - groups_paths.insert(0, ci_commit.repo_commit_dir / group_file) - return groups_paths - except NoResultFound: - return [] - - -@app.route("/api/v1/tests/group") -def get_group(): - if not request.args["name"]: - return jsonify({"tests": []}) - - project_id = request.args["project"] - project = Project.get_or_create(session=db_session, id=project_id) - - message = None - groups_paths = [get_groups_path(project_id)] - commit_id = request.args.get("commit") - if commit_id: - commit_groups_paths = get_commit_groups_paths(project, commit_id) - if not commit_groups_paths: - message = "

Could not load the inputs.batches files defined in qatools.yaml.

For tuning to work, qa save-artifacts needs to be called.

" - groups_paths = [*commit_groups_paths, *groups_paths] - try: - ci_commit = CiCommit.query.filter( - CiCommit.project_id == project_id, - CiCommit.hexsha.startswith(commit_id), - ).one() - except NoResultFound: - return jsonify("Sorry, the commit id was not found"), 404 - qatools_config = ci_commit.data["qatools_config"] - else: - qatools_config = project.data["qatools_config"] - - default_configuration = qatools_config.get('inputs', {}).get('configuration', "default") - if not (isinstance(default_configuration, list) or isinstance(default_configuration, tuple)): - default_configuration = deserialize_config(default_configuration) - # print('group', request.args["name"], groups_paths) - - - has_custom_iter_inputs = False - # TODO: make it more robust in case of "from iters import *" - qatools_config['project']['entrypoint'] = ci_commit.repo_commit_dir / qatools_config['project']['entrypoint'] - if qatools_config['project']['entrypoint'].exists(): - with qatools_config['project']['entrypoint'].open() as f: - entrypoint_source = f.read() - has_custom_iter_inputs = re.search(r'^\s*(def iter_inputs\(|from .* import.* iter_inputs)', entrypoint_source, re.MULTILINE) - # prpject fallback? - if has_custom_iter_inputs: - cwd = ci_commit.commit_dir - parent_including_cwd = [*list(reversed(list(cwd.parents))), cwd] - envrcs = [f'source "{p}/.envrc"\n' for p in parent_including_cwd if (p / '.envrc').exists()] - cmd = ' '.join([ - 'qa', - 'batch', - *list(itertools.chain.from_iterable((('--batches-file', f'"{f}"') for f in groups_paths))), - '--list', - request.args["name"], - ]) - cmd = '\n'.join([*envrcs, cmd]) - print(cmd) - try: - process = subprocess.run( - ['bash', '-c', cmd], - cwd=cwd, - encoding="utf-8", - capture_output=True, - ) - # print(cmd) - # print(process.stdout) - print(process.stderr) - process.check_returncode() - except: - return jsonify({"error": str(process.stdout), "cmd": str(cmd)}), 500 - return jsonify({"tests": json.loads(process.stdout), "message": message}) - - # We don't need to seperate the two cases, but - # doing so might let us avoid a fork and qa startup... - try: - tests = list( - iter_inputs( - [request.args["name"]], - groups_paths, - project.database, - default_configuration, - {}, - qatools_config, - ) - ) - return jsonify({ - "tests": [{"input_path": str(test.relative_to(database)), "configurations": configuration} for test, configuration, _, database, _ in tests], - "message": message, - }) - except Exception as e: - print(f'Error: {e}') - return jsonify({"tests": [], "error": str(e)}) - - -@app.route("/api/v1/commit//batch", methods=["POST"], strict_slashes=False) -def start_tuning(hexsha): - """ - Request that we run extra tests for a given project. - """ - project_id = request.args["project"] - data = request.get_json() - - try: - ci_commit = CiCommit.query.filter( - CiCommit.project_id == project_id, - CiCommit.hexsha.startswith(hexsha) - ).one() - except NoResultFound: - return jsonify("Sorry, the commit id was not found"), 404 - - if "qatools_config" not in ci_commit.project.data: - return jsonify("Please configure `qatools first`"), 404 - - ci_commit.latest_output_datetime = datetime.datetime.now() - ci_commit.latest_output_datetime = datetime.datetime.now() - batch = ci_commit.get_or_create_batch(data['batch_label']) - db_session.add(ci_commit) - db_session.commit() - - if ci_commit.deleted: - # Now that we updated the last_output_datetime, it won't be deleted again until a little while - return jsonify("Artifacts for this commit were deleted! Re-run your CI pipeline, or `git checkout / build / qa --ci save-artifacts`"), 404 - - - groups_paths = [*get_commit_groups_paths(ci_commit.project, hexsha), get_groups_path(project_id)] - # We store in this directory the scripts used to run this new batch, as well as the logs - # We may instead want to use the folder where this batch's results are stored - # Or even store the metadata in the database itself... - prev_mask = os.umask(000) - if not batch.output_dir.exists(): - batch.output_dir.mkdir(exist_ok=True, parents=True) - os.umask(prev_mask) - - - working_directory = ci_commit.commit_dir - print(working_directory) - - # This will make us do automated tuning, versus a single manual batch - do_optimize = data['tuning_search']['search_type'] == 'optimize' - if do_optimize: - # we write somewhere the optimzation search configuration - # it needs to be accessed from LSF so we can't use temporary files... - config_path = batch.output_dir / 'optim-config.yaml' - config_option = f"--config-file '{config_path}'" - with config_path.open("w") as f: - f.write(data['tuning_search']['parameter_search']) - else: - config_option = f"--tuning-search '{json.dumps(data['tuning_search'])}'" - - overwrite = "--action-on-existing run" if data["overwrite"] == "on" else "--action-on-existing sync" - # FIXME: cd relative to main project - batch_command = " ".join( - [ - "qa", - f"--platform '{data['platform']}'" if "platform" in data else "", - f"--label '{data['batch_label']}'", - "optimize" if do_optimize else "batch", - ' '.join([f'--batches-file "{p}"' for p in groups_paths]), - f"--batch '{data['selected_group']}'", - config_option, - f"{overwrite} --no-wait" if not do_optimize else '', - "\n", - ] - ) - # print(batch_command) - - # To avoid issues with quoting, we write a script to run the batch, - # and execute it with bsub/LSF - # We could also play with heredocs-within-heredocs, but it is painful, and this way we get logs - # openstf is our Android device farm - use_openstf = data["android_device"].lower() == "openstf" - parent_including_cwd = [*list(reversed(list(working_directory.parents))), working_directory] - envrcs = [f'source "{p}/.envrc"\n' for p in parent_including_cwd if (p / '.envrc').exists()] - qa_batch_script = "".join( - [ - "#!/bin/bash\n", - "set -xe\n\n", - f'cd "{working_directory}";\n\n', - ('\n'.join(envrcs) + '\n') if envrcs else "", - # qa uses click, which hates non-utf8 locales - 'export LC_ALL=en_US.utf8;\n', - 'export LANG=en_US.utf8;\n\n', - # we avoid DISPLAY issues with matplotlib, since we're headless here - 'export MPLBACKEND=agg;\n', - - f"export RESERVED_ANDROID_DEVICE='{data['android_device']}';\n" if not use_openstf else "", - # https://unix.stackexchange.com/questions/115129/why-does-root-not-have-usr-local-in-path - # Those options are specific to android - f"export RESERVED_ANDROID_DEVICE='{data['android_device']}';\n" if not use_openstf else "", - f"export OPENSTF_STORAGE_QUOTA=12;\n" if not use_openstf else "", - - # Make sure qatools doesn't complain about not being in a git repository and knows where to save results - f"\nexport CI=true;\n", - f"export CI_COMMIT_SHA='{ci_commit.gitcommit.hexsha}';\n", - f"export QATOOLS_CI_COMMIT_DIR='{ci_commit.commit_dir}';\n\n", - batch_command, - ] - ) - print(qa_batch_script) - qa_batch_path = batch.output_dir / f"qa_batch.sh" - with qa_batch_path.open("w") as f: - f.write(qa_batch_script) - - qatools_config = ci_commit.project.data["qatools_config"] - lsf_config = qatools_config.get('runners', qatools_config).get("lsf", {}) - default_user = lsf_config.get('user') - user = data.get('user', default_user) - if not user: - return jsonify("You must provide a user as whom to run the tuning experiment."), 403 - - queue = lsf_config.get("fast_queue", lsf_config['queue']) - start_script = "".join( - [ - "#!/bin/bash\n", - "set -xe\n\n", - f'mkdir -p "{batch.output_dir}"\n', - f'bsub_su "{user}" -q "{queue}" ', - '-sp 4000 ', # highest priority for manual runs - ## LSF refuses to give us long-running jobs.... - ## '-W 24:00 ' if do_optimize else '-sp 4000 ', # highest priority for manual runs - f'-o "{batch.output_dir}/log.txt" << "EOF"\n', - f'\tssh -o StrictHostKeyChecking=no -q {user}@{user}-vdi \'bash "{qa_batch_path}"\'', - '\nEOF' - ] - ) - print(start_script) - - - start_path = batch.output_dir / f"start.sh" - with start_path.open("w") as f: - f.write(start_script) - - # Wraps and execute the script that starts the batch - cmd = " ".join( - [ - # there is only C.utf8 on our container, but it is not available on LSF - "LC_ALL=en_US.utf8 LANG=en_US.utf8", - "ssh", - # quiet to avoid the welcome banner - "-q", - # ask, and force a TTY, otherwise bsub->su will complain - "-tt", - # make sure we OK the server key during the first-connection - "-o StrictHostKeyChecking=no", - # ispq is the only user that can use bsub_su, an alias for sudo -i -u {0} {1:}. - "-i /home/arthurf/.ssh/ispq.id_rsa", - "ispq@ispq-vdi", - f'\'bash "{start_path}"\'', - ] - ) - print(cmd) - - try: - out = subprocess.run(cmd, shell=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - out.check_returncode() - print(out.stdout) - except: - return jsonify({"error": str(out.stdout), "cmd": str(cmd)}), 500 - return jsonify({"cmd": str(cmd), "stdout": str(out.stdout)}) diff --git a/qaboard-backend/slamvizapp/api/webhooks.py b/qaboard-backend/slamvizapp/api/webhooks.py deleted file mode 100755 index 3227072d5..000000000 --- a/qaboard-backend/slamvizapp/api/webhooks.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Here is the "write" part of the API, to signal more data is ready. -It includes the actual webhooks sent e.g. by Gitlab, as well as -API calls to update batches and outputs. -""" -import sys -import json -import yaml -import datetime -import traceback -import subprocess -from pathlib import Path - -from flask import request, jsonify -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm.attributes import flag_modified - -from slamvizapp import app, repos, db_session -from ..models import Project, CiCommit, Batch, Output, TestInput -from ..models.Project import update_project - - -@app.route('/api/v1/commit', methods=['POST']) -@app.route('/api/v1/commit/', methods=['POST']) -def update_commit(): - try: - commit = CiCommit.get_or_create( - session=db_session, - hexsha=request.json['git_commit_sha'], - project_id=request.json['project'], - ) - except: - return f"404 ERROR:\n ({request.json['project']}): There is an issue with your commit id ({request.json['git_commit_sha']})", 404 - if not commit.data: - commit.data = {} - commit_data = request.json.get('data', {}) - commit.data = {**commit.data, **commit_data} - flag_modified(commit, "data") - if commit.deleted: - commit.deleted = False - db_session.add(commit) - db_session.commit() - return jsonify({"status": "OK"}) - - -@app.route('/api/v1/batch', methods=['POST']) -@app.route('/api/v1/batch/', methods=['POST']) -def update_batch(): - data = request.get_json() - try: - ci_commit = CiCommit.get_or_create( - session=db_session, - hexsha=request.json['git_commit_sha'], - project_id=request.json['project'], - ) - except: - return f"404 ERROR:\n ({request.json['project']}): There is an issue with your commit id ({request.json['git_commit_sha']})", 404 - - batch = ci_commit.get_or_create_batch(data['batch_label']) - if not batch.data: - batch.data = {} - batch_data = request.json.get('data', {}) - batch.data = {**batch.data, **batch_data} - - command = request.json.get('command') - if command: - batch.data["commands"] = {**batch.data.get('commands', {}), **command} - flag_modified(batch, "data") - - is_best = 'best_iter' in batch_data and batch_data['best_iter'] != batch.data.get('best_iter') - if is_best: - # remove all non-optim_iteration results from the batch - batch.outputs = [o for o in batch.outputs if o.output_type=='optim_iteration'] - db_session.add(batch) - db_session.commit() - # make copy of all outputs in the best batch - best_batch = ci_commit.get_or_create_batch(f"{data['batch_label']}|iter{batch_data.get('best_iter')}") - for o in best_batch.outputs: - o_copy = o.copy() - o_copy.output_dir_override = str(o.output_dir) - o_copy.batch = batch - db_session.add(o_copy) - - db_session.add(batch) - db_session.commit() - return jsonify({"status": "OK"}) - - - -@app.route('/api/v1/batch/stop', methods=['POST']) -@app.route('/api/v1/batch/stop/', methods=['POST']) -def stop_batch(): - data = request.get_json() - try: - batch = Batch.query.filter(Batch.id == data['id']).one() - except: - return f"404 ERROR:\n Not found", 404 - status = batch.stop() - return jsonify(status), 200 if not "error" in status else 500 - - -@app.route('/api/v1/batch/', methods=['DELETE']) -@app.route('/api/v1/batch//', methods=['DELETE']) -def delete_batch(batch_id): - try: - batch = Batch.query.filter(Batch.id == batch_id).one() - except: - return f"404 ERROR:\nNot found", 404 - stop_status = batch.stop() - if "error" in stop_status: - return jsonify(stop_status), 500 - batch.delete(session=db_session) - return {"status": "OK"} - - - -@app.route('/api/v1/output', methods=['POST']) -@app.route('/api/v1/output/', methods=['POST']) -def new_output_webhook(): - """Updates the database when we get new results.""" - data = request.get_json() - - # We get a handle on the Commit object related to our new output - try: - ci_commit = CiCommit.get_or_create( - session=db_session, - hexsha=data['git_commit_sha'], - project_id=data['project'], - ) - except: - return jsonify({"error": f"Could not find your commit ({data['git_commit_sha']})."}), 404 - - ci_commit.project.latest_output_datetime = datetime.datetime.utcnow() - ci_commit.latest_output_datetime = datetime.datetime.utcnow() - - # We make sure the Test on which we ran exists in the database - test_input_path = data.get('input_path') - if not test_input_path: - return jsonify({"error": "the input path was not provided"}, 400) - test_input = TestInput.get_or_create( - db_session, - path=test_input_path, - database=data.get('database', ci_commit.project.database), - ) - - # We save the basic information about our result - batch = ci_commit.get_or_create_batch(data['batch_label']) - if not batch.data: - batch.data = {} - batch.data.update({"type": data['job_type']}) - if data.get('input_metadata'): - test_input.data['metadata'] = data['input_metadata'] - flag_modified(test_input, "data") - - output = Output.get_or_create(db_session, - batch=batch, - platform=data['platform'], - configuration=data['configuration'], - extra_parameters=data['extra_parameters'], - test_input=test_input, - ) - output.output_type = data.get('input_type', '') - - # we can only trust CI outputs to run on the exact code from the commit - output.data = data.get('data', {"ci": data['job_type'] == 'ci'}) - if output.deleted: - output.deleted = False - - # We allow users to save their data in custom locations - # at the commit and output levels - if Path(data.get('commit_ci_dir', ci_commit.commit_dir)) != ci_commit.commit_dir: - ci_commit.commit_dir_override = data.get('commit_ci_dir') - if Path(data.get('output_directory', output.output_dir)) != output.output_dir: - output.output_dir_override = data.get('output_directory') - - # We update the output's status - output.is_running = data.get('is_running', False) - if output.is_running: - output.is_pending = True - else: - output.is_pending = data.get('is_pending', False) - - # We save the output's metrics - if not output.is_pending: - metrics = data.get('metrics', {}) - output.metrics = metrics - output.is_failed = data.get('is_failed', False) or metrics.get('is_failed') - - db_session.add(output) - db_session.commit() - return jsonify(output.to_dict()) - - - -@app.route('/webhook/gitlab', methods=['GET', 'POST']) -def gitlab_webhook(): - """Gitlab calls this endpoint every push, it garantees we stay synced.""" - # https://docs.gitlab.com/ce/user/project/integrations/webhooks.html - data = json.loads(request.data) - print(data, file=sys.stderr) - update_project(data, db_session) - return "{status:'OK'}" - diff --git a/qaboard-backend/slamvizapp/clean.py b/qaboard-backend/slamvizapp/clean.py deleted file mode 100755 index b5a036d51..000000000 --- a/qaboard-backend/slamvizapp/clean.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python -""" -Remove expired outputs and artifacts from storage. - - -```yaml -# qatools.yaml -storage: - garbage: - after: 2weeks - -# TODO: - # outputs: - # after: 2weeks - # artifacts: - # after: 1year - # by default, the default reference branch, active commits and milestone are not deleted - # except: - # when_older: 2weeks - # except: - # artifacts: - - -cleanup: - except: - - branch: develop - when: - - older: 2w - artifacts: - outputs: -``` - -""" -import re -import datetime - -import click -from click import secho -from sqlalchemy import func, and_, asc, or_ - -from .database import db_session, Session -from .models import Project, CiCommit, Batch, Output - - -now = datetime.datetime.utcnow() - -# def clean_project(project): - - -@click.command() -@click.option('--project', 'project_id') -@click.option('--dryrun', is_flag=True) -@click.option('--verbose', is_flag=True) -def clean(project_id, dryrun, verbose): - projects = db_session.query(Project).all() - for project in projects: - if project_id and project.id != project_id: - continue - if project.id == 'LSC/Calibration': - continue # ask Rivka later when the policies are more flexible - - secho(project.id, bold=True) - - gc_config = project.data.get("qatools_config", {}).get("storage", {}).get('garbage', {}) - old_treshold = now - parse_time(gc_config.get('after', '1month')) - secho(f"deleting data older than {old_treshold}", dim=True) - - # protect milestones defined via qatools.yaml - project_config = project.data.get("qatools_config", {}).get("project", {}) - protected_refs = [ - project_config.get("reference_branch", "master"), - *project_config.get("milestones", []), - ] - protected_refs = [*protected_refs, *[f'origin/{r}' for r in protected_refs]] - secho(f"protected: {protected_refs}", dim=True) - - # protect milestones defined via the web application - # project_commit_milestones = [m['commit'] for m in project.data.get("milestones", {}).values()] - # secho(f"protected: {project_commit_milestones}", dim=True) - - # .filter(CiCommit.id.notin_(project_commit_milestones)) - commits = ( - db_session.query(CiCommit) - .filter(CiCommit.project == project) - .filter(CiCommit.deleted == False) - .filter(CiCommit.branch.notin_(protected_refs)) - .filter(or_( - bool(CiCommit.latest_output_datetime) and CiCommit.latest_output_datetime < old_treshold, - not CiCommit.latest_output_datetime and CiCommit.authored_datetime < old_treshold, - )) - .all() - ) - - # max_date = max(*[c.authored_datetime for c in list(commits)]) - # print(max_date) - - for commit in commits: - secho(str(commit), fg='cyan') - # break - # continue - outputs = (db_session.query(Output).join(Batch).filter(Batch.ci_commit == commit)) - for o in outputs: - if o.deleted: continue - print(o) - try: - o.delete(dryrun=dryrun) - if not dryrun: db_session.add(o) - except Exception as e: - print(e) - # o.delete(ignore=['*.json', '*.txt']) - # commit.delete(dryrun=dryrun) - if not dryrun: - db_session.add(commit) - db_session.commit() - # secho(str(commit.deleted), fg='cyan') - # branches.add(commit.branch) - # secho(f"deleting artifacts", fg='cyan', dim=True) - # secho(f"deleting outputs", fg='cyan', dim=True) - # break - # print(branches) - # print(max_date) - # break - # continue - - - - -delta_re = re.compile(r'^((?P[\.\d]+?)y(ear)?s?)? *((?P[\.\d]+?)m(onth)?s?)? *((?P[\.\d]+?)w(eek)?s?)? *((?P[\.\d]+?)d(ay)?s?)? *((?P[\.\d]+?)h(our)?s?)? *((?P[\.\d]+?)min(ute)?s?)? *((?P[\.\d]+?)s(econd)?s?)?$') -def parse_time(time_str): - """ - Parse a time string e.g. (2h13m) into a timedelta object. - Modified from virhilo's answer at https://stackoverflow.com/a/4628148/851699 - :param time_str: A string identifying a duration. (eg. 2h13m) - :return datetime.timedelta - """ - parts = delta_re.match(time_str) - assert parts is not None, f"Could not parse any time information from '{time_str}'. Examples of valid strings: '1y', '2months 20d', '8h', '2d8h5min20s', '2min4s'" - groupdict = parts.groupdict() - if not groupdict.get('days'): - groupdict['days'] = 0 - else: - groupdict['days'] = float(groupdict['days']) - if groupdict.get('weeks'): - groupdict['days'] = groupdict['days'] + 7 * float(groupdict['weeks']) - del groupdict['weeks'] - if groupdict.get('months'): - groupdict['days'] = groupdict['days'] + 31 * float(groupdict['months']) - del groupdict['months'] - if groupdict.get('years'): - groupdict['days'] = groupdict['days'] + 365 * float(intgroupdict['years']) - del groupdict['years'] - time_params = {name: float(param) for name, param in groupdict.items() if param} - return datetime.timedelta(**time_params) - - -if __name__ == '__main__': - clean() diff --git a/qaboard-backend/slamvizapp/config.py b/qaboard-backend/slamvizapp/config.py deleted file mode 100755 index 16e10b045..000000000 --- a/qaboard-backend/slamvizapp/config.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -from pathlib import Path - -# we clone our repositories locally here to access commit metadata -git_server = os.getenv('QABOARD_GIT_SERVER', 'gitlab-srv') -app_data_directory = Path(os.getenv('QABOARD_DATA', '/var/qaboard')).resolve() - -# shared network location where we save custom per-project groups -# FIXME: save in the database! -shared_data_directory = Path('/home/arthurf/dvs/slamvizapp/data/') - -# unix config -default_ci_directory = Path('/stage/algo_data/ci') - -# windows config -is_windows = os.name == 'nt' -if is_windows: - default_ci_directory = Path('//mars/homes/arthurf/ci') - -# CIS configuration ######################################################### -# there is more at other locations... -cis_ci_directory = Path('/stage/algo_data') diff --git a/qaboard-backend/slamvizapp/git_utils.py b/qaboard-backend/slamvizapp/git_utils.py deleted file mode 100644 index a91ba149d..000000000 --- a/qaboard-backend/slamvizapp/git_utils.py +++ /dev/null @@ -1,63 +0,0 @@ -from git import Repo -from git import RemoteProgress -from git.exc import NoSuchPathError - - -class Repos(): - """Holds data for multiple repositories.""" - - def __init__(self, git_server, clone_directory): - self._repos = {} - self.git_server = git_server - self.clone_directory = clone_directory - - def __getitem__(self, project_path): - """ - Return a git-python Repo object representing a clone - of $QABOARD_GIT_SERVER/project_path at $QABOARD_DATA - - project_path: the full git repository namespace, eg dvs/psp_swip - """ - clone_location = str(self.clone_directory / project_path) - try: - repo = Repo(clone_location) - except NoSuchPathError: - try: - print(f'Cloning <{project_path}> to {self.clone_directory}') - repo = Repo.clone_from( - # for now we expect everything to be on gitlab-srv via http - f'git@{self.git_server}:{project_path}', - str(clone_location) - ) - except Exception as e: - print(f'[ERROR] Could not clone. Please set $QABOARD_DATA to a writable location and verify your network settings') - raise(e) - self._repos[project_path] = repo - return self._repos[project_path] - - -def git_pull(repo): - """Updates the repo and warms the cache listing the latests commits..""" - class MyProgressPrinter(RemoteProgress): - def update(self, op_code, cur_count, max_count=100.0, message="[No message]"): - # print('...') - # print(op_code, cur_count, max_count, (cur_count or 0)/max_count, message) - pass - try: - for fetch_info in repo.remotes.origin.fetch(progress=MyProgressPrinter()): - # print(f"Updated {fetch_info.ref} to {fetch_info.commit}") - pass - except Exception as e: - print(e) - -def find_branch(commit_hash, repo): - """Tries to get from which branch a commit comes from. It's a *guess*.""" - std_out = repo.git.branch(contains=commit_hash, remotes=True) - branches = [l.split(' ')[-1] for l in std_out.splitlines()] - important_branches = ['origin/release', 'origin/master', 'origin/develop'] - for b in important_branches: - if b in branches: - return b - if branches: - return branches[0] - return 'unknown' \ No newline at end of file diff --git a/qaboard-backend/slamvizapp/models/Batch.py b/qaboard-backend/slamvizapp/models/Batch.py deleted file mode 100755 index 6ea6c1d97..000000000 --- a/qaboard-backend/slamvizapp/models/Batch.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Represents SLAM runs belonging to the same commit. -It might by a CI job, or tuning experiments. -""" -import re -import datetime -import json -import subprocess -from pathlib import Path -from functools import lru_cache - -from requests.utils import quote -import numpy as np -from sqlalchemy import ForeignKey, Integer, String, DateTime, JSON -from sqlalchemy import UniqueConstraint, Column -from sqlalchemy.orm import relationship - -from slamvizapp.models import Base, Output - - -def slugify(s : str, maxlength=64): - """Slugiy a string like they do at Gitlab.""" - # lowercased and shortened to 63 bytes - slug = s.lower() - if maxlength: - slug = slug[:(maxlength - 1)] - # everything except 0-9 and a-z replaced with -. - slug = re.sub('[^0-9a-z.=]', '-', slug) - slug = re.sub('-{2,}', '-', slug) - # No leading / trailing -. - return slug.strip('-') - - -class Batch(Base): - __tablename__ = 'batches' - id = Column(Integer, primary_key=True) - created_date = Column(DateTime, default=datetime.datetime.utcnow) - data = Column(JSON(), default={}) - - ci_commit_id = Column(Integer(), ForeignKey('ci_commits.id'), index=True) - ci_commit = relationship("CiCommit", back_populates="batches", foreign_keys=[ci_commit_id]) - - # identifies eg whether it is the default CI job, or a tuning experiment... - label = Column(String(), default="default") - - __table_args__ = (UniqueConstraint('ci_commit_id', 'label', name='_ci_commit__label'),) - - outputs = relationship("Output", - back_populates="batch", - cascade="all, delete-orphan" - ) - - @property - def output_folder(self): - return Path('output') if self.label == 'default' else Path('tuning') / slugify(self.label) - - @property - def output_dir(self): - return self.ci_commit.commit_dir / self.output_folder - - @property - @lru_cache() - def output_dir_url(self): - return f"{self.ci_commit.commit_dir_url}/{quote(str(self.output_folder))}" - - def metrics(self, metric, outputs=None): - """Returns a list of results - for a chosen metric - over the commit's outputs. - The optionnal `outputs` parameter makes it almost like a static method. - It helps with scope issues in the templates. - """ - if not outputs: - outputs = self.outputs - return [getattr(o, metric) for o in outputs if hasattr(o, metric)] - - def to_dict(self, with_outputs=False, with_aggregation=None): - metrics_to_aggregate = with_aggregation if with_aggregation else {} - if with_outputs: - outputs = {'outputs': {o.id: o.to_dict() for o in self.outputs}} - else: - # we don't even supply a key, to make it easier for the JS code to - # just update the batch properties when it get the full version - outputs = {} - return { - 'id': self.id, - 'commit_id': self.ci_commit.hexsha, - 'label': self.label, - 'created_date': self.created_date.isoformat(), - 'data': self.data if self.data else {}, # None check for old batches (todo: migrate them properly) - 'output_dir_url': self.output_dir_url, - - 'aggregated_metrics': aggregated_metrics(self.outputs, metrics_to_aggregate), - 'valid_outputs': len([o for o in self.outputs if not o.is_failed and not o.is_pending]), - 'pending_outputs': len([o for o in self.outputs if o.is_pending]), - 'running_outputs': len([o for o in self.outputs if o.is_running]), - 'failed_outputs': len([o for o in self.outputs if o.is_failed]), - **outputs, - } - - def __repr__(self): - return (f"") - - - def stop(self): - stdouts = [] - kill_commands = [] - for _, command in self.data.get('commands', {}).items(): - ssh = "LC_ALL=en_US.utf8 LANG=en_US.utf8 ssh -q -tt -i /home/arthurf/.ssh/ispq.id_rsa ispq@ispq-vdi" - bsub = f"bsub_su {command['user']} -I" - kill_command = f"{ssh} {bsub} bkill -J '{command['lsf_jobs_prefix']}/*'" - kill_commands.append(kill_command) - print(kill_command) - out = subprocess.run(kill_command, shell=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - try: - out.check_returncode() - print(out.stdout) - stdouts.append(str(out.stdout)) - except: - # If LSF can't find the jobs, they are done already - if 'No match' not in str(out.stdout): - return {"error": str(out.stdout), "cmd": str(kill_command)} - # TODO: check it's enough to mark all outputs as is_pending:false ! - return {"cmd": '\n'.join(kill_commands), "stdout": '\n\n'.join(stdouts)} - - - def delete(self, session): - """ - Hard delete the batch and all related outputs. - Note: You should call .stop() before - """ - for output in self.outputs: - output.delete(soft=False) - session.delete(output) - session.delete(self) - session.commit() - - -# TODO: refactored with proper SQL -def aggregated_metrics(outputs, metrics_to_aggregate): - if not metrics_to_aggregate: - return {} - - valid_outputs = [o for o in outputs if not o.is_failed and not o.is_pending] - aggregated = {} - for metric, treshold in metrics_to_aggregate.items(): - values = np.array([ - o.metrics[metric] for o in valid_outputs - if metric in o.metrics and not o.metrics[metric] is None - ]) - has_values = values.shape[0]>0 - aggregated[f'{metric}_median'] = np.median(values) if has_values else np.NaN - aggregated[f'{metric}_average'] = np.average(values) if has_values else np.NaN - # aggregated[f'{metric}_pc_bad'] = np.mean(values < treshold) if has_values else np.NaN - # TODO: Use qatools to know if smaller_is_better - # aggregated[f'{metric}_threshold_bad'] = treshold - # Remove NaN values - return {k: v for k, v in aggregated.items() if v == v} diff --git a/qaboard-backend/slamvizapp/models/CiCommit.py b/qaboard-backend/slamvizapp/models/CiCommit.py deleted file mode 100755 index 28b12f00b..000000000 --- a/qaboard-backend/slamvizapp/models/CiCommit.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -A version of the code on which we ran SLAM performance test. -""" -import re -import json -import fnmatch -from hashlib import md5 -from pathlib import Path - -from requests.utils import quote -from sqlalchemy import Column, Boolean, Integer, String, DateTime, JSON, ForeignKey -from sqlalchemy import or_, UniqueConstraint -from sqlalchemy.orm import relationship, reconstructor, joinedload -from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound - -from slamvizapp.models import Base, Batch, Output -from slamvizapp.models.LocalMocks import LocalGitCommit -from ..utils import get_users_per_name -from ..git_utils import find_branch - - - -class CiCommit(Base): - """Refers to a git commit of the code - on which we ran some SLAM performance test (likely in the CI). - We keep some useful data in the database, but for the rest it used gitpython. - """ - __tablename__ = 'ci_commits' - id = Column(Integer(), primary_key=True) - hexsha = Column(String(), index=True, nullable=False) - project_id = Column(String(), ForeignKey('projects.id'), index=True) - - project = relationship("Project", back_populates="ci_commits") - __table_args__ = (UniqueConstraint('project_id', 'hexsha', name='_project_hexsha'),) - - authored_datetime = Column(DateTime(timezone=True), index=True) - branch = Column(String(), index=True) # first added as.. we ignore tags? - committer_name = Column(String(), index=True) - message = Column(String()) - parents = Column(JSON()) - data = Column(JSON(), default={}) - - commit_dir_override = Column(String()) - commit_type = Column(String(), default='git') - - batches = relationship("Batch", - back_populates="ci_commit", - cascade="all, delete-orphan", - order_by=Batch.created_date, - ) - - latest_output_datetime = Column(DateTime(timezone=True)) - deleted = Column(Boolean(), default=False) - - - - def get_or_create_batch(self, label): - matching_batches = [b for b in self.batches if b.label == label] - if matching_batches: return matching_batches[0] - return Batch(ci_commit=self, label=label) - - @property - def ci_batch(self): - return self.get_or_create_batch('default') - - - @property - def authored_date(self): - return self.authored_datetime.date() - - - @property - def commit_dir(self): - """Returns the folder in all the data for this commit is stored.""" - if self.commit_dir_override is not None: - out = Path(self.commit_dir_override) - else: - commit_dir_name = f'{int(self.authored_datetime.timestamp())}__{self.committer_name.replace(" ", " ")}__{self.hexsha[:8]}' - out = self.project.ci_directory / self.project.id_git / 'commits' / commit_dir_name - if self.project.id_relative: - return out / self.project.id_relative - else: - return out - - @property - def repo_commit_dir(self): - if self.commit_dir_override is not None: - return Path(self.commit_dir_override) - else: - commit_dir_name = f'{int(self.authored_datetime.timestamp())}__{self.committer_name}__{self.hexsha[:8]}' - return self.project.ci_directory / self.project.id_git / 'commits' / commit_dir_name - - @property - def commit_dir_url(self): - """The URL at which the data about this commit is stored. It's convenient.""" - if self.commit_dir_override is not None: - relative_path = self.commit_dir_override - return quote(f'/s{relative_path}') - return quote(f"/s{self.commit_dir}".replace("/home/arthurf/ci", "")) - - - @property - def repo_commit_dir_url(self): - """The URL at which the data about this commit is stored. It's convenient.""" - if self.commit_dir_override is not None: - relative_path = self.commit_dir_override - return quote(f'/s{relative_path}') - return quote(f"/s{self.repo_commit_dir}") - - - - def __repr__(self): - outputs = f"ci_batch.outputs={len(self.ci_batch.outputs)}" if len(self.ci_batch.outputs) else '' - branch = re.sub('origin/', '', self.branch) - return f"" - - - - def __init__(self, commit, *, project, branch=None, commit_type='git'): - self.project = project - if commit_type == 'git': - self.commit_type = 'git' - else: - self.commit_type = 'local' - if not branch: branch='' - self.hexsha = commit.hexsha - self.message = commit.message - self.parents = [c.hexsha for c in commit.parents] - if branch: - self.branch = branch - else: # a commit belong to many branches, so this is a guess.. - self.branch = find_branch(commit.hexsha, self.project.repo) - self.authored_datetime = commit.authored_datetime - self.latest_output_datetime = commit.authored_datetime - self.committer_name = commit.committer.name - - - @property - def gitcommit(self): - if self.commit_type == 'git': - return self.project.repo.commit(self.hexsha) - else: - # this mocks a real git commit - return LocalGitCommit(self.hexsha, self.message, self.committer_name, self.authored_datetime) - - - def delete(self, ignore=None, dryrun=False): - """ - Delete the commit's artifacts, and mark it as delete. - NOTE: We don't touch batches/outputs, you have to deal with them yourself. - See hard_delete() in api/webhooks.py and clean.py - """ - manifest_dir = self.commit_dir / 'manifests' - if self.commit_dir.exists(): - if not manifest_dir.exists(): - # Old versions of qatools don't have those manifests... - for p in self.commit_dir.iterdir(): - if not dryrun and p.name not in ['output', 'tuning']: - remove(p) - self.deleted = True - return - else: - for manifest in manifest_dir.iterdir(): - print(f'...delete artifacts {manifest.name}') - with manifest.open() as f: - files = json.load(f) - for file in files.keys(): - if ignore: - if any([fnmatch.fnmatch(file, i) for i in ignore]): - continue - print(f'{self.commit_dir / file}') - if not dryrun: - try: - (self.commit_dir / file).unlink() - except: - print(f"WARNING: Could not remove: {self.commit_dir / file}") - self.deleted = True - - @staticmethod - def get_or_create(session, hexsha, project_id): - try: - ci_commit =(session.query(CiCommit) - .filter( - CiCommit.project_id==project_id, - CiCommit.hexsha.startswith(hexsha), - ) - .one()) - except MultipleResultsFound: - print(f'!!!!!!!!!!!!! Multiple results for commit {hexsha} @{project_id}') - ci_commit =(session.query(CiCommit) - .filter( - CiCommit.project_id==project_id, - CiCommit.hexsha.startswith(hexsha), - ) - .first()) - except NoResultFound: - try: - from slamvizapp.models import Project - project = Project.get_or_create(session=session, id=project_id) - try: - commit = project.repo.commit(hexsha) - except Exception as e: - error = f'[ERROR] Could not create a commit for {hexsha}. {e}' - print(error) - raise (ValueError, error) - - ci_commit = CiCommit(commit, project=project) - session.add(ci_commit) - session.commit() - except ValueError: - error = f'[ERROR] ValueError: could not create a commit for {hexsha}' - print(error) - raise (ValueError, error) - if not ci_commit.data: - ci_commit.data = {} - return ci_commit - - def to_dict(self, with_aggregation=None, with_batches=None, with_outputs=False): - users_db = get_users_per_name("") - committer_avatar_url = '' - if users_db: - name = self.committer_name.lower() - if name in users_db: - committer_avatar_url = users_db[name]['avatar_url'] - elif name.replace('.', '') in users_db: - committer_avatar_url = users_db[name.replace('.', '')]['avatar_url'] - elif name.replace(' ', '') in users_db: - committer_avatar_url = users_db[name.replace(' ', '')]['avatar_url'] - else: - name_hash = md5(name.encode('utf8')).hexdigest() - committer_avatar_url = f'http://gravatar.com/avatar/{name_hash}' - out = { - 'id': self.hexsha, - 'type': self.commit_type, - 'branch': re.sub('origin/', '', self.branch), - 'parents': [p for p in self.parents] if self.parents else [], - 'message': self.message, - 'committer_name': self.committer_name, - 'committer_avatar_url': committer_avatar_url, - 'authored_datetime': self.authored_datetime.isoformat(), - 'authored_date': self.authored_date.isoformat(), - 'latest_output_datetime': self.latest_output_datetime.isoformat() if self.latest_output_datetime else None, - 'deleted': self.deleted, - "data": self.data if with_outputs else None, - 'commit_dir_url': str(self.commit_dir_url), - 'repo_commit_dir_url': str(self.repo_commit_dir_url), - 'batches': {b.label: b.to_dict(with_outputs=with_outputs, with_aggregation=with_aggregation) - for b in self.batches - if (with_batches is None and '|iter' not in b.label) or (with_batches is not None and b.label in with_batches)}, - } - if with_outputs: - out["data"] = self.data - return out - - - - -def latest_successful_commit(session, project_id, branch, batch_label=None, within_last=20): - """ - Returns the latest commit on a given branch where we got outputs. - Only the latest within_last commits are checked... - """ - ci_commits = (session - .query(CiCommit) - .options(joinedload(CiCommit.batches)) - .filter( - CiCommit.project_id==project_id, - # we try to be accomodating with the usual remote branch name - or_(CiCommit.branch==branch, CiCommit.branch==f'origin/{branch}') - ) - .order_by(CiCommit.authored_datetime.desc()) - .limit(within_last) - ) - valid_outputs = lambda b: [o for o in b.outputs if not (o.is_failed or o.is_pending)] - for ci_commit in ci_commits: - if not batch_label: - if any([valid_outputs(b) for b in ci_commit.batches]): - return ci_commit - if batch_label: - if valid_outputs(ci_commit.get_or_create_batch(batch_label)): - return ci_commit - - -def parent_successful_commit(ci_commit, batch_label=None): - """Returns a commit's latest successful parent.""" - # if we don't have a git repo, - # we try to find the previous commit on the same "branch"... - if not ci_commit.project.repo: - try: - query = CiCommit.query\ - .filter( - CiCommit.authored_datetime < self.authored_datetime, - CiCommit.branch == self.branch, - ) - for ci_commit in query: - if len(ci_commit.ci_batch.outputs) or (batch_label and len(ci_commit.get_or_create_batch(batch_label).outputs)): - return ci_commit - except: - return None - - parent_ci_commit = None - # we arbitrarly pick the first git parent - parent_hexsha = ci_commit.gitcommit.parents[0] - while True: - try: - parent_ci_commit = CiCommit.query\ - .filter(CiCommit.hexsha == parent_hexsha)\ - .order_by(CiCommit.authored_datetime.desc())\ - .one() - except: - return None - if len(ci_commit.ci_batch.outputs) or (batch_label and len(ci_commit.get_or_create_batch(batch_label).outputs)): - return parent_ci_commit - parent_hexsha = parent_ci_commit.gitcommit.parents[0] - - - - -def remove(path): - if not path.exists(): - raise ValueError(f"ERROR: {path} doesn't exist") - if path.is_file(): - print(str(path)) - try: - path.unlink() - except: - print(f"WARNING: Could not remove: {path}") - return - for p in path.iterdir(): - remove(p) - print(str(path)) - try: - p.unlink() - except: - print(f"WARNING: Could not remove: {p}") diff --git a/qaboard-backend/slamvizapp/models/LocalMocks.py b/qaboard-backend/slamvizapp/models/LocalMocks.py deleted file mode 100644 index 55e47e737..000000000 --- a/qaboard-backend/slamvizapp/models/LocalMocks.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -Hacky-soon-to-be-removed version of our models that lets us -display results computed outside of the CI. -It's slow, not integrated into the database, missing some data, but it does the job. -""" -import datetime -import re -import json -from hashlib import md5 -from pathlib import Path - -from slamvizapp import db_session -from .Batch import aggregated_metrics -from .TestInput import TestInput -from .Output import Output -from ..utils import get_users_per_name - -class Committer(): - def __init__(self, name): - self.name = name - -class LocalGitCommit(): - def __init__(self, hexsha, message, author, authored_datetime): - self.id = hexsha - self.hexsha = hexsha - self.message = message - self.author = author - self.committer = Committer(author) - self.committer_name = author - self.authored_datetime = authored_datetime - self.authored_date = authored_datetime - self.parents = [self] - - - @property - def output_folder(self): - """The path without .bin""" - return self.path[:-4] - - @property - def filename(self): - """The path without .bin""" - return self.path.split('/')[-1] - - -class LocalOutput(): - def __init__(self, test_input, platform, configuration, batch): - self.id = f'{test_input.id}/{platform}/{configuration}' - self.output_type = 'slam/6dof' - self.test_input = test_input - self.test_input_id = 0 - self.data = {} - self.platform = platform - self.configuration = configuration - self.extra_parameters = {} - self.is_pending = False - self.is_running = False - self.is_failed = False - self.batch = batch - self.batch_id = 0 - self.parameters = {} - - @property - def foldername(self): - return Path(self.platform) / self.configuration / self.test_input.output_folder - - @property - def output_dir(self): - return self.batch.output_dir / self.foldername - - @property - def output_dir_url(self): - return self.batch.output_dir_url / self.foldername - - def update_metrics(self, filepath): - """Updates the metrics from a file""" - try: - with filepath.open() as f: - metrics = json.load(f) - except: - print(f'WARNING: failed to read {filepath}') - metrics = {'is_failed': True} - setattr(self, 'metrics', metrics) - self.is_pending = False - self.is_running = False - - def to_dict(self): - cols = [ - 'id', - 'output_type', - 'platform', - 'configuration', - 'extra_parameters', - 'metrics', - 'is_failed', - 'is_pending', - 'is_running', - 'data', - ] - as_dict = {c: getattr(self, c) for c in cols} - return { - **as_dict, - 'output_dir_url': str(self.output_dir_url), - 'test_input_database': str(self.test_input.database), - 'test_input_path': str(self.test_input.path), - 'test_input_tags': self.test_input.data['tags'] if (self.test_input.data and 'tags' in self.test_input.data) else [], - } - -class LocalBatch(): - def __init__(self, ci_commit, label='default', created_date=datetime.datetime.now()): - self.ci_commit = ci_commit - self.ci_commit_id = 0 - self.id = 0 - self.label = label - self.created_date = created_date - self.outputs = [] - - @property - def output_dir(self): - return self.ci_commit.commit_dir / 'output' - - @property - def output_dir_url(self): - return self.ci_commit.commit_dir_url / 'output' - - def discover_outputs(self): - output_dirs = [p.parent for p in self.output_dir.rglob('metrics.json')] - for output_dir in output_dirs: - platform, configuration, *rel_input_path = output_dir.relative_to(self.output_dir).parts - rel_input_path = Path(*rel_input_path) - rel_input_path = f'{rel_input_path}.bin' - test_input = TestInput.get_or_create(db_session, database=Path('/net/f2/algo_archive/DVS_SLAM_Database/'), path=rel_input_path) - output = LocalOutput( - test_input=test_input, - platform=platform, - configuration=configuration, - batch=self, - ) - output.update_metrics(output_dir/'metrics.json') - self.outputs.append(output) - - def to_dict(self, with_outputs=False, with_aggregation=None): - metrics_to_aggregate = with_aggregation if with_aggregation else {} - if with_outputs: - outputs = {'outputs': {o.id: o.to_dict() for o in self.outputs}} - else: - outputs = {'outputs': {}} - return { - 'id': self.id, - 'commit_id': self.ci_commit_id, - 'label': self.label, - 'created_date': self.created_date.isoformat(), - - 'aggregated_metrics': aggregated_metrics(self.outputs, metrics_to_aggregate), - 'valid_outputs': len([o for o in self.outputs if not o.is_failed and not o.is_pending]), - 'pending_outputs': len([o for o in self.outputs if o.is_pending]), - 'running_outputs': len([o for o in self.outputs if o.is_running]), - 'failed_outputs': len([o for o in self.outputs if o.is_failed]), - **outputs, - } - @property - def valid_outputs(self): - return [o for o in self.outputs if not o.is_failed and not o.is_pending] - - @property - def pending_outputs(self): - return [o for o in self.outputs if o.is_pending] - - @property - def running_outputs(self): - return [o for o in self.outputs if o.is_running] - - @property - def failed_outputs(self): - return [o for o in self.outputs if o.is_failed] - - - def failures_count(self): - """Returns an estimate of the number of failed runs""" - return len([o for o in self.outputs if o.is_failed]) - - def aggregated_metrics(self, filename_filter='', filename_exclude=''): - return aggregated_metrics(self.valid_outputs) - - def metrics(self, metric, outputs=None): - """Returns a list of results - for a chosen metric - over the commit's outputs. - The optionnal `outputs` parameter makes it almost like a static method. - It helps with scope issues in the templates. - """ - if not outputs: - outputs = self.outputs - return [getattr(o, metric) for o in outputs if hasattr(o, metric)] - - def __repr__(self): - return (f"") - - -id_parser = re.compile('^(?P