diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 000000000..45eb6472f --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,247 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..21c713479 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "uv" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + target-branch: "pre-production" diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml index 61e950dd1..61efc046a 100644 --- a/.github/workflows/format_code.yml +++ b/.github/workflows/format_code.yml @@ -1,6 +1,8 @@ name: Code Format on: push: +permissions: + contents: read jobs: flake8_py3: runs-on: ubuntu-latest @@ -19,6 +21,9 @@ jobs: args: "--exit-zero --select=E" format: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6a752ced7..bf7f4ca36 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,37 +4,42 @@ name: Tests on: - push: - # branches: [ "main",'pre-production', "dev/jir", "dev/jab"] pull_request: branches: [ "main",'pre-production'] permissions: contents: read -#env: -# TEST_DB_USE_CLOUD: "TRUE" -# TEST_DB_SERVER_NAME: ${{ secrets.TEST_DB_SERVER_NAME }} -# TEST_DB_USER: ${{ secrets.TEST_DB_USER }} -# TEST_DB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }} -# TEST_DB_DATABASE: ${{ secrets.TEST_DB_DATABASE }} - jobs: build: runs-on: ubuntu-latest + + services: + postgis_timescaledb: + image: ghcr.io/dataintegrationgroup/nmdms:latest +# image: postgres +# image: postgis/postgis:latest +# image: postgis/postgis:17-3.5 +# image: timescale/timescaledb:2.18.0-pg17 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + steps: - name: Check out source repository uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libproj-dev libgeos-dev libsqlite3-dev - - - name: Install SpatiaLite - run: | - sudo apt-get update - sudo apt-get install -y libsqlite3-mod-spatialite libspatialite-dev +# - name: Install SpatiaLite +# run: | +# sudo apt-get update +# sudo apt-get install -y libsqlite3-mod-spatialite libspatialite-dev - name: Install uv uses: astral-sh/setup-uv@v5 @@ -51,7 +56,13 @@ jobs: - name: Run tests env: - SPATIALITE_LIBRARY_PATH: /usr/lib/x86_64-linux-gnu/mod_spatialite.so + MODE: development + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_PASSWORD: postgres + DB_DRIVER: postgres +# SPATIALITE_LIBRARY_PATH: /usr/lib/x86_64-linux-gnu/mod_spatialite.so + run: uv run pytest -vv --durations=20 --junitxml=junit.xml # - name: Upload results to Codecov diff --git a/.gitignore b/.gitignore index 92e5de156..26fc77599 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,10 @@ wheels/ # local development files development.db .env -reset_db.sh \ No newline at end of file +reset_db.sh +tests/uploads + + +# deployment files +app.yaml +alembic.ini \ No newline at end of file diff --git a/ADR1.md b/ADR1.md new file mode 100644 index 000000000..d5cc007c8 --- /dev/null +++ b/ADR1.md @@ -0,0 +1,16 @@ +## What is the justification for this Project? + +The bureau of geology has multiple siloed "data systems" that are not integrated. This leads to inefficiencies in data management, analysis, and sharing. + +Since there is only one data services team, it makes sense to have a single, unified data system that can serve the needs of all the different teams within the bureau. This will allow for better collaboration, data sharing, and overall efficiency in managing geological data. + +The goal of this project is to create a unified data system that allows for better data integration, accessibility, and usability across the bureau. + + + +## Components +- NMSampleLocations (Storage. Database+API) +- NMBGMRDataManager (Web-based frontend) + + + diff --git a/README.md b/README.md index e69de29bb..218adfaaa 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,66 @@ +# NMSampleLocations + +[![Code Format](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/format_code.yml/badge.svg)](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/format_code.yml) +[![Dependabot Updates](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/dependabot/dependabot-updates) +[![Sentry Release](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/release.yml/badge.svg)](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/release.yml) +[![Tests](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/tests.yml/badge.svg)](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/tests.yml) + +**Geospatial Sample Data Management System** +_New Mexico Bureau of Geology and Mineral Resources_ + +NMSampleLocations is a FastAPI-based backend service designed to manage geospatial sample location data across New Mexico. It supports research, field operations, and public data delivery for the Bureau of Geology and Mineral Resources. + +--- + +## πŸš€ Features + +- 🌐 RESTful API for managing sample location data +- πŸ—ΊοΈ Native GeoJSON support via PostGIS +- πŸ”Ž Filtering by location, date, type, and more +- πŸ“¦ PostgreSQL + PostGIS database backend +- πŸ” Optional authentication and role-based access +- 🧾 Interactive API documentation via Swagger and ReDoc + +--- + +## πŸ› οΈ Getting Started + +### Prerequisites + +- Python 3.11+ +- PostgreSQL with PostGIS extension +- [`uv`](https://github.com/astral-sh/uv) package manager + +### Installation + +```bash +# Clone the repository +git clone https://github.com/DataIntegrationGroup/NMSampleLocations.git +cd NMSampleLocations + +# Set up virtual environment and install dependencies +uv venv +source .venv/bin/activate +uv pip install -r requirements.txt + +# Set up environment variables +cp .env.example .env +# Edit `.env` to configure database connection and app settings + +# Run database migrations +alembic upgrade head + +# Start the development server +uvicorn app.main:app --reload +``` + +### 🧭 Project Structure +```text +app/ +β”œβ”€β”€ api/ # Route declarations +β”œβ”€β”€ core/ # Settings and application config +β”œβ”€β”€ db/ # Database models, sessions, migrations +β”œβ”€β”€ schemas/ # Pydantic data models +β”œβ”€β”€ services/ # Business logic and helpers +└── main.py # FastAPI entry point +``` \ No newline at end of file diff --git a/admin/base.py b/admin/base.py index bc4db2ff3..4a4ae5301 100644 --- a/admin/base.py +++ b/admin/base.py @@ -13,20 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import uuid from typing import Any - - -import bcrypt +from geoalchemy2.shape import to_shape from fastadmin import register, SqlAlchemyModelAdmin, WidgetType -from sqlalchemy import Integer, Boolean, Text, String, update, select -from sqlalchemy.orm import Mapped, mapped_column -from models import sqlalchemy_sessionmaker, Base -from models.base import SampleLocation, Well +from db import async_database_sessionmaker +from db.base import SampleLocation, Well -@register(SampleLocation, sqlalchemy_sessionmaker=sqlalchemy_sessionmaker) +@register(SampleLocation, sqlalchemy_sessionmaker=async_database_sessionmaker) class SampleLocationsAdmin(SqlAlchemyModelAdmin): """ Admin interface for SampleLocations. @@ -35,8 +30,22 @@ class SampleLocationsAdmin(SqlAlchemyModelAdmin): list_display = ("name",) + async def serialize_obj(self, obj: Any, list_view: bool = False) -> dict: + """ + Serialize the SampleLocation object for display. + This method can be customized to include additional fields or formatting. + """ + print(f"Serializing SampleLocation object: {obj}") + return { + "id": obj.id, + "name": obj.name, + "description": obj.description, + "point": to_shape(obj.point).wkt if obj.point else None, + "created_at": obj.created_at.isoformat() if obj.created_at else None, + } + -@register(Well, sqlalchemy_sessionmaker=sqlalchemy_sessionmaker) +@register(Well, sqlalchemy_sessionmaker=async_database_sessionmaker) class WellAdmin(SqlAlchemyModelAdmin): """ Admin interface for Well. diff --git a/admin/user.py b/admin/user.py index f32a9e239..4abc8dae9 100644 --- a/admin/user.py +++ b/admin/user.py @@ -16,17 +16,14 @@ import uuid from typing import Any - -import bcrypt from fastadmin import register, SqlAlchemyModelAdmin, WidgetType -from sqlalchemy import Integer, Boolean, Text, String, update, select -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import update, select -from models import sqlalchemy_sessionmaker, Base -from models.base import SampleLocation, User +from db import async_database_sessionmaker +from db.base import User -@register(User, sqlalchemy_sessionmaker=sqlalchemy_sessionmaker) +@register(User, sqlalchemy_sessionmaker=async_database_sessionmaker) class UserModelAdmin(SqlAlchemyModelAdmin): list_display = ("id", "username", "is_superuser") list_display_links = ("id", "username") diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 5ed02895a..000000000 --- a/alembic.ini +++ /dev/null @@ -1,142 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -# sqlalchemy.url = driver://user:pass@localhost/dbname -sqlalchemy.url = sqlite:///./development.db - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py index a43172681..6671345f3 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -18,8 +18,17 @@ # for 'autogenerate' support # from myapp import mymodel -from models import Base # Import your Base from models/__init__.py -from models.base import * +# from db import Base # Import your Base from models/__init__.py +from db.base import * +from db.lexicon import * +from db.publication import * +from db.geothermal import * +from db.geochronology import * +from db.collabnet import * +from db.chemistry import * +from db.asset import * +from db.timeseries import * + # target_metadata = mymodel.Base.metadata target_metadata = Base.metadata diff --git a/alembic/versions/6d7f6e8d9ae7_initial_migration.py b/alembic/versions/6d7f6e8d9ae7_initial_migration.py deleted file mode 100644 index f590361d0..000000000 --- a/alembic/versions/6d7f6e8d9ae7_initial_migration.py +++ /dev/null @@ -1,386 +0,0 @@ -"""initial migration - -Revision ID: 6d7f6e8d9ae7 -Revises: -Create Date: 2025-06-12 16:38:14.559354 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "6d7f6e8d9ae7" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "asset", - sa.Column("fs_path", sa.String(length=255), nullable=False), - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("file_type", sa.String(length=50), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("fs_path"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "group", - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "lexicon", - sa.Column("term", sa.String(length=100), nullable=False), - sa.Column("definition", sa.String(length=255), nullable=False), - sa.Column("category", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("term"), - ) - op.create_table( - "owner", - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "user", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("username", sa.String(length=255), nullable=False), - sa.Column("password", sa.String(length=255), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("avatar_url", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "contact", - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column("email", sa.String(length=100), nullable=True), - sa.Column("phone", sa.String(length=20), nullable=True), - sa.Column("owner_id", sa.Integer(), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["owner_id"], - ["owner.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "samplelocation", - sa.Column("name", sa.String(length=100), nullable=True), - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column("visible", sa.Boolean(), nullable=False), - sa.Column( - "point", - geoalchemy2.types.Geometry( - geometry_type="POINT", - srid=4326, - from_text="ST_GeomFromEWKT", - name="geometry", - nullable=False, - ), - nullable=False, - ), - sa.Column("owner_id", sa.Integer(), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["owner_id"], - ["owner.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "idx_samplelocation_point", - "samplelocation", - ["point"], - unique=False, - postgresql_using="gist", - ) - op.create_table( - "assetlocation", - sa.Column("asset_id", sa.Integer(), nullable=False), - sa.Column("location_id", sa.Integer(), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["asset_id"], - ["asset.id"], - ), - sa.ForeignKeyConstraint( - ["location_id"], - ["samplelocation.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "equipment", - sa.Column("equipment_type", sa.String(length=50), nullable=True), - sa.Column("model", sa.String(length=50), nullable=True), - sa.Column("serial_no", sa.String(length=50), nullable=True), - sa.Column("date_installed", sa.DateTime(), nullable=True), - sa.Column("date_removed", sa.DateTime(), nullable=True), - sa.Column("recording_interval", sa.Integer(), nullable=True), - sa.Column("equipment_notes", sa.String(length=50), nullable=True), - sa.Column("location_id", sa.Integer(), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["location_id"], - ["samplelocation.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "grouplocation", - sa.Column("group_id", sa.Integer(), nullable=False), - sa.Column("location_id", sa.Integer(), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["group_id"], - ["group.id"], - ), - sa.ForeignKeyConstraint( - ["location_id"], - ["samplelocation.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "spring", - sa.Column("description", sa.String(length=255), nullable=True), - sa.Column("location_id", sa.Integer(), nullable=False), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["location_id"], - ["samplelocation.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "well", - sa.Column("location_id", sa.Integer(), nullable=False), - sa.Column("ose_pod_id", sa.String(length=50), nullable=True), - sa.Column("api_id", sa.String(length=50), nullable=True), - sa.Column("usgs_id", sa.String(length=50), nullable=True), - sa.Column("well_depth", sa.Float(), nullable=True), - sa.Column("hole_depth", sa.Float(), nullable=True), - sa.Column("well_type", sa.String(length=100), nullable=True), - sa.Column("casing_diameter", sa.Float(), nullable=True), - sa.Column("casing_depth", sa.Float(), nullable=True), - sa.Column("casing_description", sa.String(length=50), nullable=True), - sa.Column("construction_notes", sa.String(length=250), nullable=True), - sa.Column("formation_zone", sa.String(length=100), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["formation_zone"], - ["lexicon.term"], - ), - sa.ForeignKeyConstraint( - ["location_id"], - ["samplelocation.id"], - ), - sa.ForeignKeyConstraint( - ["well_type"], - ["lexicon.term"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "wellscreen", - sa.Column("well_id", sa.Integer(), nullable=False), - sa.Column("screen_depth_top", sa.Float(), nullable=False), - sa.Column("screen_depth_bottom", sa.Float(), nullable=False), - sa.Column("screen_type", sa.String(length=100), nullable=True), - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["screen_type"], - ["lexicon.term"], - ), - sa.ForeignKeyConstraint( - ["well_id"], - ["well.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("wellscreen") - op.drop_table("well") - op.drop_table("spring") - op.drop_table("grouplocation") - op.drop_table("equipment") - op.drop_table("assetlocation") - op.drop_index( - "idx_samplelocation_point", table_name="samplelocation", postgresql_using="gist" - ) - op.drop_table("samplelocation") - op.drop_table("contact") - op.drop_table("user") - op.drop_table("owner") - op.drop_table("lexicon") - op.drop_table("group") - op.drop_table("asset") - # ### end Alembic commands ### diff --git a/alembic/versions/7c913b9c9f0e_initial_migration.py b/alembic/versions/7c913b9c9f0e_initial_migration.py new file mode 100644 index 000000000..3d1076857 --- /dev/null +++ b/alembic/versions/7c913b9c9f0e_initial_migration.py @@ -0,0 +1,814 @@ +"""initial migration + +Revision ID: 7c913b9c9f0e +Revises: +Create Date: 2025-06-30 15:55:09.509518 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils +import geoalchemy2 +from sqlalchemy_searchable import sql_expressions + +# revision identifiers, used by Alembic. +revision: str = "7c913b9c9f0e" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "asset", + sa.Column("filename", sa.String(), nullable=True), + sa.Column("storage_service", sa.String(), nullable=True), + sa.Column("storage_path", sa.String(), nullable=True), + sa.Column("mime_type", sa.String(), nullable=True), + sa.Column("size", sa.Integer(), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_asset_search_vector", + "asset", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "contact", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("email", sa.String(length=100), nullable=True), + sa.Column("phone", sa.String(length=20), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index( + "ix_contact_search_vector", + "contact", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "group", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "lexicon_category", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "lexicon_term", + sa.Column("term", sa.String(length=100), nullable=False), + sa.Column("definition", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("term"), + ) + op.create_table( + "owner", + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index( + "ix_owner_search_vector", + "owner", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "pub_author", + sa.Column("name", sa.String(), nullable=False), + sa.Column("affiliation", sa.String(), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_pub_author_search_vector", + "pub_author", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geochronology_age", + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("age", sa.Float(), nullable=False), + sa.Column("age_error", sa.Float(), nullable=True), + sa.Column("method", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["method"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "lexicon_term_category_association", + sa.Column("lexicon_term", sa.String(length=100), nullable=False), + sa.Column("category_name", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["category_name"], ["lexicon_category.name"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["lexicon_term"], ["lexicon_term.term"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "lexicon_triple", + sa.Column("subject", sa.String(length=100), nullable=False), + sa.Column("predicate", sa.String(length=100), nullable=False), + sa.Column("object_", sa.String(length=100), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["object_"], ["lexicon_term.term"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["subject"], ["lexicon_term.term"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "owner_contact_association", + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["contact_id"], + ["contact.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["owner.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pub_author_contact_association", + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["author_id"], ["pub_author.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["contact_id"], ["contact.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("author_id", "contact_id"), + ) + op.create_table( + "publication", + sa.Column("title", sa.Text(), nullable=False), + sa.Column("abstract", sa.Text(), nullable=True), + sa.Column("doi", sa.String(), nullable=True), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("publisher", sa.String(), nullable=True), + sa.Column("url", sa.String(), nullable=True), + sa.Column("publication_type", sa.String(length=100), nullable=False), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["publication_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("doi"), + ) + op.create_index( + "ix_publication_search_vector", + "publication", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "sample_location", + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("visible", sa.Boolean(), nullable=False), + sa.Column( + "point", + geoalchemy2.types.Geometry( + geometry_type="POINT", + srid=4326, + from_text="ST_GeomFromEWKT", + name="geometry", + nullable=False, + ), + nullable=False, + ), + sa.Column("owner_id", sa.Integer(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["owner_id"], ["owner.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # op.create_index('idx_sample_location_point', 'sample_location', ['point'], unique=False, postgresql_using='gist') + op.create_table( + "asset_location_association", + sa.Column("asset_id", sa.Integer(), nullable=False), + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["asset_id"], ["asset.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["location_id"], ["sample_location.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "equipment", + sa.Column("equipment_type", sa.String(length=50), nullable=True), + sa.Column("model", sa.String(length=50), nullable=True), + sa.Column("serial_no", sa.String(length=50), nullable=True), + sa.Column("date_installed", sa.DateTime(), nullable=True), + sa.Column("date_removed", sa.DateTime(), nullable=True), + sa.Column("recording_interval", sa.Integer(), nullable=True), + sa.Column("equipment_notes", sa.String(length=50), nullable=True), + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["location_id"], ["sample_location.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "group_location_association", + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["group_id"], ["group.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["location_id"], ["sample_location.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pub_author_publication_association", + sa.Column("publication_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=False), + sa.Column("author_order", sa.Integer(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["author_id"], ["pub_author.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["publication_id"], ["publication.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("publication_id", "author_id"), + ) + op.create_table( + "spring", + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["location_id"], ["sample_location.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "well", + sa.Column("location_id", sa.Integer(), nullable=False), + sa.Column("ose_pod_id", sa.String(length=50), nullable=True), + sa.Column("api_id", sa.String(length=50), nullable=True), + sa.Column("usgs_id", sa.String(length=50), nullable=True), + sa.Column("well_depth", sa.Float(), nullable=True), + sa.Column("hole_depth", sa.Float(), nullable=True), + sa.Column("well_type", sa.String(length=100), nullable=True), + sa.Column("casing_diameter", sa.Float(), nullable=True), + sa.Column("casing_depth", sa.Float(), nullable=True), + sa.Column("casing_description", sa.String(length=50), nullable=True), + sa.Column("construction_notes", sa.String(length=250), nullable=True), + sa.Column("formation_zone", sa.String(length=100), nullable=True), + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["formation_zone"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["location_id"], ["sample_location.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["well_type"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_well_search_vector", + "well", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "collaborative_network_well", + sa.Column("actively_monitored", sa.Boolean(), nullable=False), + sa.Column("well_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_sample_set", + sa.Column("well_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=128), nullable=True), + sa.Column("klass", sa.String(length=24), nullable=True), + sa.Column("type", sa.String(length=50), nullable=True), + sa.Column("porosity", sa.Integer(), nullable=True), + sa.Column("permeability", sa.Integer(), nullable=True), + sa.Column("density", sa.Integer(), nullable=True), + sa.Column("dst_tests", sa.Boolean(), nullable=True), + sa.Column("thin_section", sa.Boolean(), nullable=True), + sa.Column("geochron", sa.Boolean(), nullable=True), + sa.Column("geochem", sa.Boolean(), nullable=True), + sa.Column("geothermal", sa.Boolean(), nullable=True), + sa.Column("wholerock", sa.Boolean(), nullable=True), + sa.Column("paleontology", sa.Boolean(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_temperature_profile", + sa.Column("well_id", sa.Integer(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "water_chemistry_analysis_set", + sa.Column("well_id", sa.Integer(), nullable=True), + sa.Column("note", sa.String(length=255), nullable=True), + sa.Column("collection_timestamp", sa.DateTime(), nullable=False), + sa.Column("laboratory", sa.String(length=255), nullable=True), + sa.Column("collection_method", sa.String(length=100), nullable=True), + sa.Column("sample_type", sa.String(length=100), nullable=True), + sa.Column("visible", sa.Boolean(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["collection_method"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["sample_type"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "well_screen", + sa.Column("well_id", sa.Integer(), nullable=False), + sa.Column("screen_depth_top", sa.Float(), nullable=False), + sa.Column("screen_depth_bottom", sa.Float(), nullable=False), + sa.Column("screen_type", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["screen_type"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "well_timeseries", + sa.Column("well_id", sa.Integer(), nullable=False), + sa.Column("equipment_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "properties", + sa.JSON(), + nullable=True, + comment="JSONB column for storing additional properties", + ), + sa.ForeignKeyConstraint( + ["equipment_id"], ["equipment.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint(["well_id"], ["well.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_bottom_hole_temperature_header", + sa.Column("sample_set_id", sa.Integer(), nullable=True), + sa.Column("drill_fluid", sa.String(length=100), nullable=True), + sa.Column("fluid_salinity", sa.Float(), nullable=True), + sa.Column("fluid_resistivity", sa.Float(), nullable=True), + sa.Column("fluid_ph", sa.Float(), nullable=True), + sa.Column("fluid_level", sa.Float(), nullable=True), + sa.Column("fluid_viscosity", sa.Float(), nullable=True), + sa.Column("fluid_loss", sa.Float(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["drill_fluid"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["sample_set_id"], + ["geothermal_sample_set.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_temperature_profile_observation", + sa.Column("temperature_profile_id", sa.Integer(), nullable=True), + sa.Column("depth", sa.Float(), nullable=True), + sa.Column("depth_unit", sa.String(length=100), nullable=True), + sa.Column("temperature", sa.Float(), nullable=True), + sa.Column("temperature_unit", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["depth_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["temperature_profile_id"], + ["geothermal_temperature_profile.id"], + ), + sa.ForeignKeyConstraint( + ["temperature_unit"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_well_interval", + sa.Column("sample_set_id", sa.Integer(), nullable=True), + sa.Column("top_depth", sa.Float(), nullable=True), + sa.Column("bottom_depth", sa.Float(), nullable=True), + sa.Column("depth_unit", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["depth_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["sample_set_id"], + ["geothermal_sample_set.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "groundwater_level_observation", + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("value", sa.Float(), nullable=False), + sa.Column("unit", sa.String(), nullable=False), + sa.Column("data_quality", sa.String(length=100), nullable=True), + sa.Column("level_status", sa.String(length=100), nullable=True), + sa.Column("timeseries_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "properties", + sa.JSON(), + nullable=True, + comment="JSONB column for storing additional properties", + ), + sa.Column("quality_control_status", sa.String(length=100), nullable=True), + sa.Column("quality_control_notes", sa.Text(), nullable=True), + sa.Column("quality_control_timestamp", sa.DateTime(), nullable=True), + sa.Column("quality_control_user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["data_quality"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["level_status"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["quality_control_status"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["quality_control_user_id"], ["user.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["timeseries_id"], ["well_timeseries.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "water_chemistry_analysis", + sa.Column("analysis_set_id", sa.Integer(), nullable=True), + sa.Column("value", sa.Float(), nullable=True), + sa.Column("unit", sa.String(length=100), nullable=True), + sa.Column("uncertainty", sa.Float(), nullable=True), + sa.Column("method", sa.String(length=100), nullable=True), + sa.Column("analyte", sa.String(length=100), nullable=False), + sa.Column("analysis_timestamp", sa.DateTime(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["analysis_set_id"], + ["water_chemistry_analysis_set.id"], + ), + sa.ForeignKeyConstraint( + ["analyte"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["unit"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_bottom_hole_temperature", + sa.Column("header_id", sa.Integer(), nullable=True), + sa.Column("depth", sa.Float(), nullable=True), + sa.Column("depth_unit", sa.String(length=100), nullable=True), + sa.Column("temperature", sa.Float(), nullable=True), + sa.Column("temperature_unit", sa.String(length=100), nullable=True), + sa.Column("hours_since_circulation", sa.Float(), nullable=True), + sa.Column("date_measured", sa.DateTime(), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["depth_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["header_id"], + ["geothermal_bottom_hole_temperature_header.id"], + ), + sa.ForeignKeyConstraint( + ["temperature_unit"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_heat_flow", + sa.Column("interval_id", sa.Integer(), nullable=True), + sa.Column("gradient", sa.Float(), nullable=True), + sa.Column("gradient_unit", sa.String(length=100), nullable=True), + sa.Column("ka", sa.Float(), nullable=True), + sa.Column("ka_unit", sa.String(length=100), nullable=True), + sa.Column("kpr", sa.Float(), nullable=True), + sa.Column("kpr_unit", sa.String(length=100), nullable=True), + sa.Column("q", sa.Float(), nullable=True), + sa.Column("q_unit", sa.String(length=100), nullable=True), + sa.Column("pm", sa.Float(), nullable=True), + sa.Column("pm_unit", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["gradient_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["interval_id"], + ["geothermal_well_interval.id"], + ), + sa.ForeignKeyConstraint( + ["ka_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["kpr_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["pm_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["q_unit"], + ["lexicon_term.term"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "geothermal_thermal_conductivity", + sa.Column("interval_id", sa.Integer(), nullable=True), + sa.Column("conductivity", sa.Float(), nullable=True), + sa.Column("conductivity_unit", sa.String(length=100), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.ForeignKeyConstraint( + ["conductivity_unit"], + ["lexicon_term.term"], + ), + sa.ForeignKeyConstraint( + ["interval_id"], + ["geothermal_well_interval.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # op.drop_table('spatial_ref_sys') + + op.execute("CREATE EXTENSION IF NOT EXISTS postgis;") + op.execute(sql_expressions.statement) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.execute("DROP FUNCTION parse_websearch(regconfig, text);") + op.execute("DROP FUNCTION parse_websearch(text);") + + op.drop_table("geothermal_thermal_conductivity") + op.drop_table("geothermal_heat_flow") + op.drop_table("geothermal_bottom_hole_temperature") + op.drop_table("water_chemistry_analysis") + op.drop_table("groundwater_level_observation") + op.drop_table("geothermal_well_interval") + op.drop_table("geothermal_temperature_profile_observation") + op.drop_table("geothermal_bottom_hole_temperature_header") + op.drop_table("well_timeseries") + op.drop_table("well_screen") + op.drop_table("water_chemistry_analysis_set") + op.drop_table("geothermal_temperature_profile") + op.drop_table("geothermal_sample_set") + op.drop_table("collaborative_network_well") + op.drop_index("ix_well_search_vector", table_name="well", postgresql_using="gin") + op.drop_table("well") + op.drop_table("spring") + op.drop_table("pub_author_publication_association") + op.drop_table("group_location_association") + op.drop_table("equipment") + op.drop_table("asset_location_association") + op.drop_index( + "idx_sample_location_point", + table_name="sample_location", + postgresql_using="gist", + ) + op.drop_table("sample_location") + op.drop_index( + "ix_publication_search_vector", table_name="publication", postgresql_using="gin" + ) + op.drop_table("publication") + op.drop_table("pub_author_contact_association") + op.drop_table("owner_contact_association") + op.drop_table("lexicon_triple") + op.drop_table("lexicon_term_category_association") + op.drop_table("geochronology_age") + op.drop_table("user") + op.drop_index( + "ix_pub_author_search_vector", table_name="pub_author", postgresql_using="gin" + ) + op.drop_table("pub_author") + op.drop_index("ix_owner_search_vector", table_name="owner", postgresql_using="gin") + op.drop_table("owner") + op.drop_table("lexicon_term") + op.drop_table("lexicon_category") + op.drop_table("group") + op.drop_index( + "ix_contact_search_vector", table_name="contact", postgresql_using="gin" + ) + op.drop_table("contact") + op.drop_index("ix_asset_search_vector", table_name="asset", postgresql_using="gin") + op.drop_table("asset") + # ### end Alembic commands ### diff --git a/routes/__init__.py b/api/__init__.py similarity index 100% rename from routes/__init__.py rename to api/__init__.py diff --git a/api/asset.py b/api/asset.py new file mode 100644 index 000000000..5e5852e11 --- /dev/null +++ b/api/asset.py @@ -0,0 +1,90 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +import os +from datetime import timedelta +from uuid import uuid4 + +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from db import get_db_session +from db.asset import Asset + +router = APIRouter(prefix="/asset", tags=["asset"]) +GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME") + + +def get_storage_bucket(): + from google.cloud import storage + + client = storage.Client() + bucket = client.bucket(GCS_BUCKET_NAME) + return bucket + + +@router.get("/{asset_id}") +async def get_asset( + asset_id: int, + database_session: Session = Depends(get_db_session), + bucket=Depends( + get_storage_bucket + ), # Assuming get_storage_bucket is defined elsewhere +): + """ + Retrieve an asset by its ID. + """ + sql = select(Asset).where(Asset.id == asset_id) + asset = database_session.scalars(sql).one_or_none() + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + + blob = bucket.blob(asset.storage_path) + asset.url = blob.generate_signed_url(expiration=timedelta(minutes=10), method="GET") + return asset + + +@router.post("/", status_code=201) +async def add_asset( + file: UploadFile = File(...), + database_session: Session = Depends(get_db_session), + bucket=Depends(get_storage_bucket), +): + file_id = str(uuid4()) + blob_name = f"uploads/{file_id}_{file.filename}" + blob = bucket.blob(blob_name) + + blob.upload_from_file(file.file, content_type=file.content_type) + + asset = Asset( + filename=file.filename, + storage_service="gcs", + storage_path=blob_name, + mime_type=file.content_type, + size=file.size, + ) + database_session.add(asset) + database_session.commit() + database_session.refresh(asset) + + return { + "id": asset.id, + "filename": asset.filename, + "url": f"https://storage.googleapis.com/{GCS_BUCKET_NAME}/{blob_name}", + } + + +# ============= EOF ============================================= diff --git a/api/author.py b/api/author.py new file mode 100644 index 000000000..860bc511d --- /dev/null +++ b/api/author.py @@ -0,0 +1,65 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db import get_db_session, adder +from db.publication import Publication, Author +from fastapi import APIRouter, Depends, HTTPException, status +from schemas.create.publication import CreatePublication +from schemas.response.publication import PublicationResponse +from services.publication_helper import add_publication +from sqlalchemy.orm import Session +from sqlalchemy import select + + +router = APIRouter( + prefix="/author", + tags=["author"], +) + + +@router.get( + "/{author_id}/publications", + response_model=list[PublicationResponse], +) +async def get_author_publications( + author_id: int, session: Session = Depends(get_db_session) +): + """ + Retrieve all publications for a specific author. + """ + sql = select(Author).where(Author.id == author_id) + author = session.scalars(sql).first() + return author.publications + + +# +# @router.post( +# "/author", response_model=PublicationResponse, status_code=status.HTTP_201_CREATED +# ) +# async def post_publication( +# publication_data: CreatePublication, # Replace with your actual schema +# session: Session = Depends( +# get_db_session +# ), # Assuming get_db is defined in dependencies.py +# ): +# """ +# Add a new publication. +# """ +# return add_publication(session, publication_data) +# +# # return adder(session, Publication, publication_data) + + +# ============= EOF ============================================= diff --git a/routes/base.py b/api/base.py similarity index 82% rename from routes/base.py rename to api/base.py index 99dc1b21d..c11c44e62 100644 --- a/routes/base.py +++ b/api/base.py @@ -15,52 +15,63 @@ # =============================================================================== from typing import List, Union +from constants import SRID_WGS84 from fastapi import APIRouter, Depends, status from fastapi_pagination.ext.sqlalchemy import paginate from geoalchemy2 import functions as geofunc +from services.people_helper import add_contact +from services.query_helper import ( + make_query, + simple_get_by_id, + simple_all_getter, + searchable_getter, +) +from services.validation.well import validate_screens from sqlalchemy import select, func from sqlalchemy.orm import Session from starlette.responses import FileResponse -from models import get_db_session, adder -from models.base import ( +from db import get_db_session, adder +from db.base import ( Well, SampleLocation, Group, - GroupLocation, + GroupLocationAssociation, Owner, Contact, WellScreen, Spring, Equipment, + OwnerContactAssociation, ) -from routes.geospatial_helper import create_shapefile -from routes.pagination import CustomPage -from routes.regex import QUERY_REGEX +from services.geospatial_helper import create_shapefile, make_within_wkt +from api.pagination import CustomPage from schemas.base_create import ( - CreateWell, + CreateSpring, + CreateEquipment, +) +from schemas.create.location import ( CreateLocation, CreateGroup, CreateGroupLocation, CreateOwner, CreateContact, - CreateScreenWell, - CreateSpring, - CreateEquipment, ) +from schemas.create.well import CreateWell, CreateWellScreen from schemas.base_get import GetWell, GetLocation from schemas.base_responses import ( - OwnerResponse, - SampleLocationResponse, - WellResponse, - GroupResponse, - ContactResponse, - WellScreenResponse, - GroupLocationResponse, SpringResponse, EquipmentResponse, +) +from schemas.response.well import ( + WellResponse, SampleLocationWellResponse, + WellScreenResponse, + GroupResponse, + ContactResponse, + OwnerResponse, ) +from schemas.response.location import SampleLocationResponse, GroupLocationResponse router = APIRouter( prefix="/base", @@ -102,7 +113,8 @@ def create_well(well_data: CreateWell, session: Session = Depends(get_db_session status_code=status.HTTP_201_CREATED, ) def create_wellscreen( - well_screen_data: CreateScreenWell, session: Session = Depends(get_db_session) + well_screen_data: CreateWellScreen = Depends(validate_screens), + session: Session = Depends(get_db_session), ): """ Create a new well screen in the database. @@ -131,7 +143,7 @@ def create_group_location( """ Create a new group location association in the database. """ - return adder(session, GroupLocation, group_location_data) + return adder(session, GroupLocationAssociation, group_location_data) @router.post( @@ -150,7 +162,10 @@ def create_owner(owner_data: CreateOwner, session: Session = Depends(get_db_sess def create_contact( contact_data: CreateContact, session: Session = Depends(get_db_session) ): - return adder(session, Contact, contact_data) + + return add_contact(session, contact_data) + + # return adder(session, Contact, contact_data) @router.post( @@ -179,43 +194,6 @@ def create_equipment( return adder(session, Equipment, equipment_data) -def make_query(table, query): - match = QUERY_REGEX.match(query) - column = match.group("field") - value = match.group("value") - operator = match.group("operator") - value = value.lower() - if value.startswith("'") and value.endswith("'"): - value = value[1:-1] - - if value == "true": - value = True - elif value == "false": - value = False - - def make_where(col, op, v): - if op == "like": - return col.like(v) - elif op == "between": - return col.between(*map(float, v.strip("[]").split(","))) - else: - return getattr(col, f"__{op}__")(v) - - if "." in column: - # Handle nested attributes - column_parts = column.split(".") - rel = getattr(table, column_parts[0]) - related_model = rel.property.mapper.class_ - related_column = getattr(related_model, column_parts[1]) - w = make_where(related_column, operator, value) - w = rel.any(w) - else: - column = getattr(table, column) - w = make_where(column, operator, value) - - return w - - # ==== Get ============================================ @router.get("/location/shapefile", summary="Get location as shapefile") async def get_location_shapefile( @@ -297,13 +275,14 @@ async def get_location( if query: sql = sql.where(make_query(SampleLocation, query)) elif nearby_point: - nearby_point = func.ST_GeomFromText(nearby_point) + nearby_point = func.ST_GeomFromText(nearby_point, SRID_WGS84) sql = sql.where( - func.ST_Distance(SampleLocation.point, nearby_point) <= nearby_distance_km + # func.ST_Distance(SampleLocation.point, nearby_point) <= nearby_distance_km + func.ST_Distance(nearby_point, SampleLocation.point) + <= nearby_distance_km ) elif within: - within = func.ST_GeomFromText(within) - sql = sql.where(func.ST_Within(SampleLocation.point, within)) + sql = make_within_wkt(sql, within) if expand == "well": sql = sql.outerjoin(Well) @@ -356,13 +335,24 @@ async def get_groups(session: Session = Depends(get_db_session)): @router.get("/owner", response_model=List[OwnerResponse], summary="Get owners") -async def get_owners(session: Session = Depends(get_db_session)): +async def get_owners( + search: str = None, # Optional search parameter + session: Session = Depends(get_db_session), +): """ Retrieve all owners from the database. """ - return simple_all_getter(session, Owner) - # result = db.execute(sql) - # return result.all() + if not search: + # If no search parameter, return all owners + return simple_all_getter(session, Owner) + + return searchable_getter( + session, + Owner, + search=search, + vector=Owner.search_vector | Contact.search_vector, + joins=[OwnerContactAssociation, Contact], + ) @router.get("/contact", response_model=List[ContactResponse], summary="Get contacts") @@ -394,7 +384,7 @@ async def get_group_locations(session: Session = Depends(get_db_session)): """ Retrieve all group locations from the database. """ - return simple_all_getter(session, GroupLocation) + return simple_all_getter(session, GroupLocationAssociation) @router.get( @@ -538,7 +528,9 @@ async def get_group_location_by_id( """ Retrieve a group location by ID from the database. """ - group_location = simple_get_by_id(session, GroupLocation, group_location_id) + group_location = simple_get_by_id( + session, GroupLocationAssociation, group_location_id + ) if not group_location: return {"message": "Group location not found"} return group_location @@ -559,31 +551,4 @@ async def get_contact_by_id( return contact -def simple_get_by_name(session, table, name): - """ - Helper function to get a record by name from the database. - """ - sql = select(table).where(table.name == name) - result = session.execute(sql) - return result.scalar_one_or_none() - - -def simple_get_by_id(session, table, item_id): - """ - Helper function to get a record by ID from the database. - """ - sql = select(table).where(table.id == item_id) - result = session.execute(sql) - return result.scalar_one_or_none() - - -def simple_all_getter(session, table): - """ - Helper function to get records from the database. - """ - sql = select(table) - result = session.execute(sql) - return result.scalars().all() - - # ============= EOF ============================================= diff --git a/api/chemisty.py b/api/chemisty.py new file mode 100644 index 000000000..403b66f32 --- /dev/null +++ b/api/chemisty.py @@ -0,0 +1,107 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from services.query_helper import make_query +from api.pagination import CustomPage +from db.base import SampleLocation, Well +from fastapi import APIRouter, Depends, status +from schemas.response.chemistry import ( + WaterChemistryAnalysisResponse, + WaterChemistryAnalysisSetResponse, +) +from services.geospatial_helper import make_within_wkt +from services.validation.chemistry import validate_analyte +from sqlalchemy.orm import Session +from sqlalchemy import select +from fastapi_pagination.ext.sqlalchemy import paginate +from db import adder, get_db_session +from db.chemistry import WaterChemistryAnalysis, WaterChemistryAnalysisSet +from schemas.create.chemistry import CreateWaterChemistryAnalysis, CreateAnalysisSet + +router = APIRouter( + prefix="/chemistry", +) + + +@router.get( + "/analysis_set", + response_model=CustomPage[WaterChemistryAnalysisSetResponse], + tags=["chemistry"], +) +async def get_chemistry_analysis_set( + query: str = None, within: str = None, session: Session = Depends(get_db_session) +): + """ + Retrieve chemistry analysis sets. + """ + sql = select(WaterChemistryAnalysisSet) + if within: + sql = sql.join(Well) + sql = sql.join(SampleLocation) + sql = make_within_wkt(sql, within) + + if query: + sql = sql.where(make_query(WaterChemistryAnalysisSet, query)) + + return paginate(conn=session, query=sql) + + +@router.get( + "/analysis", + response_model=CustomPage[WaterChemistryAnalysisResponse], + tags=["chemistry"], +) +async def get_chemistry_analysis( + query: str = None, within: str = None, session: Session = Depends(get_db_session) +): + """ + Retrieve chemistry analysis data. + """ + sql = select(WaterChemistryAnalysis) + if within: + sql = sql.join(WaterChemistryAnalysisSet) + sql = sql.join(Well) + sql = sql.join(SampleLocation) + sql = make_within_wkt(sql, within) + + if query: + sql = sql.where(make_query(WaterChemistryAnalysis, query)) + + return paginate(conn=session, query=sql) + + +# ====== POST =============== +@router.post("/analysis_set", status_code=status.HTTP_201_CREATED) +async def add_chemistry_analysis_set( + analysis_set_data: CreateAnalysisSet, session: Session = Depends(get_db_session) +): + """ + Add a set of new chemistry analyses. + """ + return adder(session, WaterChemistryAnalysisSet, analysis_set_data) + + +@router.post("/analysis", status_code=status.HTTP_201_CREATED, tags=["chemistry"]) +async def add_chemistry_analysis( + analysis_data: CreateWaterChemistryAnalysis = Depends(validate_analyte), + session: Session = Depends(get_db_session), +): + """ + Add a new chemistry analysis. + """ + return adder(session, WaterChemistryAnalysis, analysis_data) + + +# ============= EOF ============================================= diff --git a/routes/collabnet.py b/api/collabnet.py similarity index 92% rename from routes/collabnet.py rename to api/collabnet.py index 447c3bf8c..370671682 100644 --- a/routes/collabnet.py +++ b/api/collabnet.py @@ -20,11 +20,11 @@ from geoalchemy2 import functions as geofunc -from models import get_db_session, adder -from models.base import SampleLocation, Well -from models.collabnet import CollaborativeNetworkWell -from models.timeseries import GroundwaterLevelObservation, WellTimeseries -from schemas.collabnet import CreateCollaborativeNetworkWell +from db import get_db_session, adder +from db.base import SampleLocation, Well +from db.collabnet import CollaborativeNetworkWell +from db.timeseries import GroundwaterLevelObservation, WellTimeseries +from schemas.create.collabnet import CreateCollaborativeNetworkWell router = APIRouter(prefix="/collabnet", tags=["collabnet"]) diff --git a/routes/form.py b/api/form.py similarity index 85% rename from routes/form.py rename to api/form.py index da36a4c67..f1b205900 100644 --- a/routes/form.py +++ b/api/form.py @@ -15,17 +15,17 @@ # =============================================================================== from fastapi import APIRouter, Depends, status -from fastapi.responses import JSONResponse -from models import get_db_session -from models.base import SampleLocation, Owner, Contact, Well, Group, GroupLocation -from routes.base import simple_get_by_id, simple_get_by_name +from db import get_db_session +from db.base import SampleLocation, Owner, Well, Group from schemas.form import ( WellForm, WellFormResponse, GroundwaterLevelFormResponse, GroundwaterLevelForm, ) +from services.people_helper import add_contact +from services.query_helper import simple_get_by_name router = APIRouter(prefix="/form") @@ -60,11 +60,15 @@ async def well_form(form_data: WellForm, session=Depends(get_db_session)): existing_owner = simple_get_by_name(session, Owner, owner.name) if existing_owner is None: session.add(owner) + session.commit() + session.refresh(owner) else: owner = existing_owner - - cs = [Contact(**contact) for contact in contact_data if contact is not None] - owner.contacts.extend(cs) + # + # cs = [Contact(**contact) for contact in contact_data if contact is not None] + # owner.contacts.extend(cs) + for ci in contact_data: + add_contact(session, ci, owner=owner) # add well to the database well_data = data.get("well", None) diff --git a/api/geochronology.py b/api/geochronology.py new file mode 100644 index 000000000..f1f2a4f45 --- /dev/null +++ b/api/geochronology.py @@ -0,0 +1,52 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db.geochronology import GeochronologyAge +from fastapi import APIRouter, Depends, status +from db import get_db_session, adder +from schemas.create.geochronology import CreateGeochronologyAge +from sqlalchemy.orm import Session +from sqlalchemy import select + +router = APIRouter(prefix="/geochronology", tags=["geochronology"]) + + +@router.post("/age", tags=["geochronology"], status_code=status.HTTP_201_CREATED) +async def create_age( + age: CreateGeochronologyAge, session: Session = Depends(get_db_session) +): + """ + Create a new geochronology age entry. + """ + # Placeholder for actual implementation + # return {"message": "Geochronology age created successfully.", "data": age} + return adder(session, GeochronologyAge, age) + + +@router.get("/age", tags=["geochronology"]) +async def get_geochronology_age( + method: str = "arar", session: Session = Depends(get_db_session) +): + """ + Retrieve geochronology age data. + """ + sql = select(GeochronologyAge) + return session.scalar(sql).all() + + # Placeholder for actual implementation + # return {"message": "Geochronology age data retrieved successfully."} + + +# ============= EOF ============================================= diff --git a/routes/geothermal.py b/api/geothermal.py similarity index 97% rename from routes/geothermal.py rename to api/geothermal.py index 0d57206f1..211fb2f62 100644 --- a/routes/geothermal.py +++ b/api/geothermal.py @@ -16,8 +16,8 @@ from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session -from models import get_db_session, adder -from models.geothermal import ( +from db import get_db_session, adder +from db.geothermal import ( GeothermalTemperatureProfile, GeothermalTemperatureProfileObservation, GeothermalBottomHoleTemperature, @@ -27,15 +27,15 @@ GeothermalSampleSet, GeothermalBottomHoleTemperatureHeader, ) -from schemas.geothermal import ( +from schemas.create.geothermal import ( CreateTemperatureProfile, CreateTemperatureProfileObservation, + CreateGeothermalSampleSet, + CreateBottomHoleTemperatureHeader, CreateBottomHoleTemperature, CreateGeothermalInterval, - CreateHeatFlow, CreateThermalConductivity, - CreateGeothermalSampleSet, - CreateBottomHoleTemperatureHeader, + CreateHeatFlow, ) router = APIRouter(prefix="/geothermal", tags=["geothermal"]) diff --git a/api/lexicon.py b/api/lexicon.py new file mode 100644 index 000000000..339a4a4b0 --- /dev/null +++ b/api/lexicon.py @@ -0,0 +1,97 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from fastapi import APIRouter, Depends +from fastapi import status +from db import get_db_session +from db.lexicon import Lexicon, Category, TermCategoryAssociation, LexiconTriple +from schemas.response.lexicon import LexiconTermResponse, LexiconCategoryResponse +from schemas.create.lexicon import ( + CreateLexiconTerm, + CreateLexiconCategory, + CreateTriple, +) +from services.lexicon import add_lexicon_term +from sqlalchemy import select + + +router = APIRouter( + prefix="/lexicon", +) + + +@router.post( + "/category/add", + status_code=status.HTTP_201_CREATED, + response_model=LexiconCategoryResponse, +) +def add_category(category_data: CreateLexiconCategory, session=Depends(get_db_session)): + """ + Endpoint to add a category to the lexicon. + """ + data = category_data.model_dump() + name = data["name"] + description = data.get("description", "") + + category = Category(name=name, description=description) + session.add(category) + session.commit() + return category + + +@router.post( + "/add", + summary="Add term", + response_model=LexiconTermResponse, + status_code=status.HTTP_201_CREATED, +) +def add_term(term_data: CreateLexiconTerm, session=Depends(get_db_session)): + """ + Endpoint to add a term to the lexicon. + """ + data = term_data.model_dump() + return add_lexicon_term(session, data["term"], data["definition"], data["category"]) + + +@router.post( + "/triple/add", + summary="Add triple", + status_code=status.HTTP_201_CREATED, +) +def add_triple(triple_data: CreateTriple, session=Depends(get_db_session)): + triple_data = triple_data.model_dump() + subject = triple_data["subject"] + predicate = triple_data["predicate"] + object_ = triple_data["object_"] + + if isinstance(subject, dict): + add_lexicon_term( + session, subject["term"], subject["definition"], subject["category"] + ) + subject = subject["term"] + + if isinstance(object_, dict): + add_lexicon_term( + session, object_["term"], object_["definition"], object_["category"] + ) + object_ = object_["term"] + + triple = LexiconTriple(subject=subject, predicate=predicate, object_=object_) + session.add(triple) + session.commit() + return triple + + +# ============= EOF ============================================= diff --git a/routes/pagination.py b/api/pagination.py similarity index 100% rename from routes/pagination.py rename to api/pagination.py diff --git a/api/publication.py b/api/publication.py new file mode 100644 index 000000000..4b665778b --- /dev/null +++ b/api/publication.py @@ -0,0 +1,48 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db import get_db_session, adder +from db.publication import Publication +from fastapi import APIRouter, Depends, HTTPException, status +from schemas.create.publication import CreatePublication +from schemas.response.publication import PublicationResponse +from services.publication_helper import add_publication +from sqlalchemy.orm import Session + + +router = APIRouter( + prefix="/publication", + tags=["publication"], +) + + +@router.post( + "/add", response_model=PublicationResponse, status_code=status.HTTP_201_CREATED +) +async def post_publication( + publication_data: CreatePublication, # Replace with your actual schema + session: Session = Depends( + get_db_session + ), # Assuming get_db is defined in dependencies.py +): + """ + Add a new publication. + """ + return add_publication(session, publication_data) + + # return adder(session, Publication, publication_data) + + +# ============= EOF ============================================= diff --git a/routes/timeseries.py b/api/timeseries.py similarity index 90% rename from routes/timeseries.py rename to api/timeseries.py index 5a97739d1..94e138b7c 100644 --- a/routes/timeseries.py +++ b/api/timeseries.py @@ -17,11 +17,11 @@ from fastapi import APIRouter, Depends, status -from models import get_db_session -from models.timeseries import WellTimeseries, GroundwaterLevelObservation -from schemas.timeseries import ( +from db import get_db_session +from db.timeseries import WellTimeseries, GroundwaterLevelObservation +from schemas.response.timeseries import WellTimeseriesResponse +from schemas.create.timeseries import ( CreateWellTimeseries, - WellTimeseriesResponse, CreateGroundwaterLevelObservation, ) diff --git a/constants.py b/constants.py new file mode 100644 index 000000000..2944f549f --- /dev/null +++ b/constants.py @@ -0,0 +1,18 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +SRID_WGS84 = 4326 +# ============= EOF ============================================= diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 000000000..8e546ddc2 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,17 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +# ============= EOF ============================================= diff --git a/app.py b/core/app.py similarity index 67% rename from app.py rename to core/app.py index 98e596c9a..22a859dff 100644 --- a/app.py +++ b/core/app.py @@ -17,10 +17,12 @@ from typing import AsyncGenerator from fastapi import FastAPI +from sqlalchemy import text +from sqlalchemy.exc import DatabaseError -from models import sqlalchemy_sessionmaker, engine, Base -from models.lexicon import Lexicon -from settings import settings +from db import engine, Base, get_db_session +from services.lexicon import add_lexicon_term +from .settings import settings def init_db(): @@ -33,29 +35,38 @@ def init_db(): def init_lexicon(): - with open("lexicon.json") as f: + with open("./core/lexicon.json") as f: import json default_lexicon = json.load(f) # populate lexicon - with sqlalchemy_sessionmaker() as s: - for term_dict in default_lexicon: - s.add(Lexicon(**term_dict)) - s.commit() + + session = next(get_db_session()) + + for term_dict in default_lexicon: + try: + add_lexicon_term( + session, + term_dict["term"], + term_dict["definition"], + term_dict["category"], + ) + except DatabaseError: + session.rollback() def create_superuser(): from admin.user import User - with sqlalchemy_sessionmaker() as s: - user = User( - username="admin", - password="admin", - is_superuser=True, - ) - s.add(user) - s.commit() + session = next(get_db_session()) + user = User( + username="admin", + password="admin", + is_superuser=True, + ) + session.add(user) + session.commit() @asynccontextmanager @@ -63,9 +74,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """ Application lifespan event handler to initialize the database and lexicon. """ - if settings.get_enum("MODE") == "production": - pass - else: + if settings.get_enum("MODE") == "development": init_db() create_superuser() init_lexicon() diff --git a/core/lexicon.json b/core/lexicon.json new file mode 100644 index 000000000..339f640ec --- /dev/null +++ b/core/lexicon.json @@ -0,0 +1,55 @@ +[ + {"term": "TDS", "definition": "Total Dissolved Solids", "category": "water_chemistry"}, + {"term": "Na", "definition": "Sodium", "category": "water_chemistry"}, + {"term": "Cl", "definition": "Chloride", "category": "water_chemistry"}, + {"term": "Ca", "definition": "Calcium", "category": "water_chemistry"}, + {"term": "Mg", "definition": "Magnesium", "category": "water_chemistry"}, + {"term": "SO4", "definition": "Sulfate", "category": "water_chemistry"}, + {"term": "HCO3", "definition": "Bicarbonate", "category": "water_chemistry"}, + + {"term": "ft", "definition": "feet", "category": "unit"}, + {"term": "ftbgs", "definition": "feet below ground surface", "category": "unit"}, + {"term": "F", "definition": "Fahrenheit", "category": "unit"}, + {"term": "mg/L", "definition": "Milligrams per Liter", "category": "unit"}, + {"term": "mW/mΒ²", "definition": "milliwatts per square meter", "category": "unit"}, + {"term": "W/mΒ²", "definition": "watts per square meter", "category": "unit"}, + {"term": "W/mΒ·K", "definition": "watts per meter Kelvin", "category": "unit"}, + {"term": "mΒ²/s", "definition": "square meters per second", "category": "unit"}, + + {"term": "Exploration", "definition": "Exploration well", "category": "well_type"}, + {"term": "Monitoring", "definition": "Monitoring", "category": "well_type"}, + {"term": "Production", "definition": "Production", "category": "well_type"}, + {"term": "Injection", "definition": "Injection", "category": "well_type"}, + + + {"term": "PVC", "definition": "Polyvinyl Chloride", "category": "casing_material"}, + {"term": "Steel", "definition": "Steel", "category": "casing_material"}, + {"term": "Concrete", "definition": "Concrete", "category": "casing_material"}, + + {"term": "Provisional", "definition": "Provisional quality control status", "category": "quality_control_status"}, + {"term": "Approved", "definition": "Approved quality control status", "category": "quality_control_status"}, + {"term": "Rejected", "definition": "Rejected quality control status", "category": "quality_control_status"}, + + {"term": "mud", "definition": "drilling mud", "category": "drilling_fluid"}, + + {"term": "Ar/Ar", "definition": "Ar40/Ar39 geochronology", "category": "geochronology"}, + {"term": "AFT", "definition": "apatite fission track", "category": "geochronology"}, + {"term": "K/Ar", "definition": "Potassium-Argon dating", "category": "geochronology"}, + {"term": "U/Th", "definition": "Uranium/Thorium dating", "category": "geochronology"}, + {"term": "Rb/Sr", "definition": "Rubidium-Strontium dating", "category": "geochronology"}, + {"term": "U/Pb", "definition": "Uranium/Lead dating", "category": "geochronology"}, + {"term": "Lu/Hf", "definition": "Lutetium-Hafnium dating", "category": "geochronology"}, + {"term": "Re/Os", "definition": "Rhenium-Osmium dating", "category": "geochronology"}, + {"term": "Sm/Nd", "definition": "Samarium-Neodymium dating", "category": "geochronology"}, + + {"term": "Map", "definition": "Map", "category": "publication_type"}, + {"term": "Report", "definition": "Report", "category": "publication_type"}, + {"term": "Dataset", "definition": "Dataset", "category": "publication_type"}, + {"term": "Model", "definition": "Model", "category": "publication_type"}, + {"term": "Software", "definition": "Software", "category": "publication_type"}, + {"term": "Paper", "definition": "Paper", "category": "publication_type"}, + {"term": "Thesis", "definition": "Thesis", "category": "publication_type"}, + {"term": "Book", "definition": "Book", "category": "publication_type"}, + {"term": "Conference", "definition": "Conference", "category": "publication_type"}, + {"term": "Webpage", "definition": "Webpage", "category": "publication_type"} +] \ No newline at end of file diff --git a/settings.py b/core/settings.py similarity index 93% rename from settings.py rename to core/settings.py index 61651d87a..f978e5c68 100644 --- a/settings.py +++ b/core/settings.py @@ -19,7 +19,7 @@ class Settings: def __init__(self): - self.mode = os.getenv("MODE", "development") # Default mode + self.mode = os.getenv("MODE", "") # Default mode def get_enum(self, name: str): if name == "MODE": diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 000000000..5b1f93e44 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,247 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +import asyncio +import os +import re + +from sqlalchemy import ( + create_engine, + Column, + Integer, + DateTime, + func, + JSON, + desc, + cast, + Text, +) +from sqlalchemy.dialects.postgresql import REGCONFIG +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import ( + declarative_base, + sessionmaker, + declared_attr, +) +from sqlalchemy.util import await_only +from sqlalchemy_searchable import ( + make_searchable, + inspect_search_vectors, + search_manager, +) + +driver = os.environ.get("DB_DRIVER", "") + + +async def get_async_engine(): + """ + Asynchronous database session generator. + """ + connector = await create_async_connector() + + def asyncify_connection(): + from sqlalchemy.dialects.postgresql.asyncpg import ( + AsyncAdapt_asyncpg_connection, + ) + + instance_name = os.environ.get("CLOUD_SQL_INSTANCE_NAME") + user = os.environ.get("CLOUD_SQL_USER") + password = os.environ.get("CLOUD_SQL_PASSWORD") + database = os.environ.get("CLOUD_SQL_DATABASE") + + connection = connector.connect_async( + instance_name, + "asyncpg", + db=database, + password=password, + user=user, + ) + + return AsyncAdapt_asyncpg_connection( + engine.dialect.dbapi, + await_only(connection), + prepared_statement_cache_size=100, + ) + + return create_async_engine( + "postgresql+asyncpg://", + echo=True, + creator=asyncify_connection, + ) + + +if driver == "cloudsql": + from google.cloud.sql.connector import Connector, create_async_connector + + def init_connection_pool(connector): + instance_name = os.environ.get("CLOUD_SQL_INSTANCE_NAME") + user = os.environ.get("CLOUD_SQL_USER") + password = os.environ.get("CLOUD_SQL_PASSWORD") + database = os.environ.get("CLOUD_SQL_DATABASE") + + def getconn(): + conn = connector.connect( + instance_name, # The Cloud SQL instance name + "pg8000", + user=user, + password=password, + db=database, + ip_type="public", + ) + return conn + + engine = create_engine( + "postgresql+pg8000://", + creator=getconn, + echo=False, + ) + return engine + + connector = Connector() + engine = init_connection_pool(connector) + + async_engine = asyncio.run(get_async_engine()) + +else: + # if driver == "sqlite": + # name = os.environ.get("DB_NAME", "development.db") + # url = f"sqlite:///{name}" + # elif driver == "postgres": + password = os.environ.get("POSTGRES_PASSWORD", "") + host = os.environ.get("POSTGRES_HOST", "localhost") + port = os.environ.get("POSTGRES_PORT", "5432") + user = os.environ.get("POSTGRES_USER", "postgres") + name = os.environ.get("POSTGRES_DB", "postgres") + + auth = f"{user}:{password}@" if user and password else "" + port_part = f":{port}" if port else "" + url = f"postgresql+pg8000://{auth}{host}{port_part}/{name}" + # else: + # url = "sqlite:///./development.db" + + engine = create_engine( + url, + # echo=True, + plugins=["geoalchemy2"], + ) + + async_engine = create_async_engine( + url.replace("postgresql+pg8000", "postgresql+asyncpg"), + plugins=["geoalchemy2"], + ) + # if "postgresql" not in url: + # + # def on_connect(dbapi_connection, connection_record): + # """ + # Event listener to load SpatiaLite on connection. + # """ + # load_spatialite(dbapi_connection) + # + # cursor = dbapi_connection.cursor() + # cursor.execute("PRAGMA foreign_keys=ON") + # cursor.close() + # + # listen(engine, "connect", on_connect) + + +async_database_sessionmaker = async_sessionmaker(async_engine) +database_sessionmaker = sessionmaker(engine, expire_on_commit=False) + + +def get_db_session(): + session = database_sessionmaker() + try: + yield session + finally: + session.close() + + +Base = declarative_base() +make_searchable(Base.metadata) + + +def adder(session, table, model, **kwargs): + """ + Helper function to add a new record to the database. + """ + md = model.model_dump() + if kwargs: + md.update(kwargs) + + obj = table(**md) + session.add(obj) + session.commit() + return obj + + +class AuditMixin: + @declared_attr + def created_at(self): + return Column(DateTime, nullable=False, server_default=func.now()) + + +def search(query, search_query, vector=None, regconfig=None, sort=True): + if not search_query.strip(): + return query + + if vector is None: + entity = query.column_descriptions[0]["entity"] + search_vectors = inspect_search_vectors(entity) + vector = search_vectors[0] + + if regconfig is None: + regconfig = search_manager.options["regconfig"] + + query = query.filter( + vector.op("@@")( + func.parse_websearch(cast(regconfig, REGCONFIG), cast(search_query, Text)) + ) + ) + if sort: + query = query.order_by( + desc( + func.ts_rank_cd(vector, func.parse_websearch(cast(search_query, Text))) + ) + ) + + return query.params(term=search_query) + + +def pascal_to_snake(name): + return re.sub(r"(?" @@ -33,7 +33,7 @@ class GeothermalSampleSet(Base, AutoBaseMixin): __tablename__ = "geothermal_sample_set" - well_id = mapped_column(Integer, ForeignKey("well.id")) + well_id = mapped_column(Integer, ForeignKey("well.id", ondelete="CASCADE")) name = mapped_column(String(128)) klass = mapped_column(String(24)) @@ -72,7 +72,9 @@ class GeothermalBottomHoleTemperatureHeader(Base, AutoBaseMixin): sample_set_id = mapped_column(Integer, ForeignKey("geothermal_sample_set.id")) - drill_fluid = mapped_column(String(100), ForeignKey("lexicon.term"), default="mud") + drill_fluid = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="mud" + ) fluid_salinity = mapped_column( Float ) # Assuming salinity is stored as a float (e.g., in g/L) @@ -101,13 +103,15 @@ class GeothermalBottomHoleTemperature(Base, AutoBaseMixin): Integer, ForeignKey("geothermal_bottom_hole_temperature_header.id") ) depth = mapped_column(Float) - depth_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="ft") + depth_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="ft" + ) temperature = mapped_column( Float ) # Assuming temperature is stored as a float (e.g., in degrees Celsius) temperature_unit = mapped_column( - String(100), ForeignKey("lexicon.term"), default="F" + String(100), ForeignKey("lexicon_term.term"), default="F" ) hours_since_circulation = mapped_column(Float) date_measured = mapped_column(DateTime) @@ -125,13 +129,15 @@ class GeothermalTemperatureProfileObservation(Base, AutoBaseMixin): Integer, ForeignKey("geothermal_temperature_profile.id") ) depth = mapped_column(Float) - depth_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="ft") + depth_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="ft" + ) temperature = mapped_column( Float ) # Assuming temperature is stored as a float (e.g., in degrees Celsius) temperature_unit = mapped_column( - String(100), ForeignKey("lexicon.term"), default="F" + String(100), ForeignKey("lexicon_term.term"), default="F" ) def __repr__(self): @@ -150,7 +156,9 @@ class GeothermalWellInterval(Base, AutoBaseMixin): sample_set_id = mapped_column(Integer, ForeignKey("geothermal_sample_set.id")) top_depth = mapped_column(Float) bottom_depth = mapped_column(Float) - depth_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="ft") + depth_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="ft" + ) def __repr__(self): return ( @@ -167,7 +175,7 @@ class GeothermalThermalConductivity(Base, AutoBaseMixin): interval_id = mapped_column(Integer, ForeignKey("geothermal_well_interval.id")) conductivity = mapped_column(Float) # Assuming conductivity is stored as a float conductivity_unit = mapped_column( - String(100), ForeignKey("lexicon.term"), default="W/mΒ·K" + String(100), ForeignKey("lexicon_term.term"), default="W/mΒ·K" ) def __repr__(self): @@ -185,20 +193,28 @@ class GeothermalHeatFlow(Base, AutoBaseMixin): interval_id = mapped_column(Integer, ForeignKey("geothermal_well_interval.id")) gradient = mapped_column(Float) # Assuming gradient is stored as a float gradient_unit = mapped_column( - String(100), ForeignKey("lexicon.term"), default="mW/mΒ²" + String(100), ForeignKey("lexicon_term.term"), default="mW/mΒ²" ) ka = mapped_column(Float) # Assuming ka is stored as a float (thermal diffusivity) - ka_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="mΒ²/s") + ka_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="mΒ²/s" + ) kpr = mapped_column( Float ) # Assuming kpr is stored as a float (thermal conductivity) - kpr_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="W/mΒ·K") + kpr_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="W/mΒ·K" + ) q = mapped_column(Float) # Assuming q is stored as a float (heat flow) - q_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="mW/mΒ²") + q_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="mW/mΒ²" + ) pm = mapped_column(Float) # Assuming pm is stored as a float (thermal power) - pm_unit = mapped_column(String(100), ForeignKey("lexicon.term"), default="W/mΒ²") + pm_unit = mapped_column( + String(100), ForeignKey("lexicon_term.term"), default="W/mΒ²" + ) # ============= EOF ============================================= diff --git a/db/lexicon.py b/db/lexicon.py new file mode 100644 index 000000000..05e4f6961 --- /dev/null +++ b/db/lexicon.py @@ -0,0 +1,104 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy import String, Integer, ForeignKey +from sqlalchemy.orm import mapped_column, relationship + +from db import AutoBaseMixin, Base + + +class Lexicon(Base, AutoBaseMixin): + """ + Lexicon model for storing terms and their definitions. + This model can be extended to include additional fields as needed. + """ + + __tablename__ = "lexicon_term" + term = mapped_column(String(100), unique=True, nullable=False) + definition = mapped_column(String(255), nullable=False) + + # category_id = mapped_column(Integer, nullable=False) + # category = mapped_column(String(255), nullable=True) + + def __repr__(self): + return f"" + + +class Category(Base, AutoBaseMixin): + """ + Model for storing categories of terms. + This can be used to group terms into different categories. + """ + + __tablename__ = "lexicon_category" + name = mapped_column(String(100), unique=True, nullable=False) + description = mapped_column(String(255), nullable=True) + + # terms = relationship( + # "lexicon", + # backref="category", + # cascade="all, delete-orphan", + # lazy="dynamic" + # ) + def __repr__(self): + return f"" + + +class TermCategoryAssociation(Base, AutoBaseMixin): + """ + Model for linking terms to categories. + This can be used to create a many-to-many relationship between terms and categories. + """ + + __tablename__ = "lexicon_term_category_association" + + lexicon_term = mapped_column( + String(100), ForeignKey("lexicon_term.term", ondelete="CASCADE"), nullable=False + ) + category_name = mapped_column( + String(255), + ForeignKey("lexicon_category.name", ondelete="CASCADE"), + nullable=False, + ) + + term = relationship("Lexicon") + category = relationship("Category") + + def __repr__(self): + return f"" + + +class LexiconTriple(Base, AutoBaseMixin): + """ + Model for storing triples in the lexicon. + This can be used to represent relationships between terms. + """ + + subject = mapped_column( + String(100), ForeignKey("lexicon_term.term", ondelete="CASCADE"), nullable=False + ) + predicate = mapped_column(String(100), nullable=False) + object_ = mapped_column( + String(100), ForeignKey("lexicon_term.term", ondelete="CASCADE"), nullable=False + ) + + subject_term = relationship("Lexicon", foreign_keys=[subject]) + object_term = relationship("Lexicon", foreign_keys=[object_]) + + def __repr__(self): + return f"" + + +# ============= EOF ============================================= diff --git a/db/publication.py b/db/publication.py new file mode 100644 index 000000000..9175b5b7d --- /dev/null +++ b/db/publication.py @@ -0,0 +1,114 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy_utils import TSVectorType + +from db import AutoBaseMixin, Base, AuditMixin +from sqlalchemy import Column, Integer, String, Text, Date, ForeignKey, Table, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.ext.associationproxy import association_proxy + + +class Publication(Base, AutoBaseMixin): + + title = Column(Text, nullable=False) + abstract = Column(Text) + doi = Column(String, unique=True) + year = Column(Integer) + # publication_type_id = Column(Integer, ForeignKey('publication_types.id')) + publisher = Column(String) + url = Column(String) + + publication_type = Column( + String(100), ForeignKey("lexicon_term.term"), nullable=False + ) + # publication_type = relationship("PublicationType") + # authors = relationship( + # "Author", + # secondary=publication_authors, + # order_by=publication_authors.c.author_order, + # ) + # topics = relationship("Topic", secondary=publication_topics) + author_associations = relationship( + "AuthorPublicationAssociation", + back_populates="publication", + cascade="all, delete-orphan", + ) + authors = association_proxy("author_associations", "author") + + search_vector = Column(TSVectorType("title", "abstract", "doi", "publisher", "url")) + + +class Author(Base, AutoBaseMixin): + __tablename__ = "pub_author" + name = Column(String, nullable=False) + # id = Column(Integer, primary_key=True) + # first_name = Column(String, nullable=False) + # last_name = Column(String, nullable=False) + # orcid = Column(String, unique=True) + # email = Column(String) + affiliation = Column(String) + + publication_associations = relationship( + "AuthorPublicationAssociation", + back_populates="author", + cascade="all, delete-orphan", + ) + publications = association_proxy("publication_associations", "publication") + + contact_associations = relationship( + "AuthorContactAssociation", + back_populates="author", + cascade="all, delete-orphan", + ) + contacts = association_proxy("author_associations", "contact") + + search_vector = Column(TSVectorType("name", "affiliation")) + + +class AuthorContactAssociation(Base, AuditMixin): + __tablename__ = "pub_author_contact_association" + author_id = Column( + Integer, + ForeignKey("pub_author.id", ondelete="CASCADE"), + nullable=False, + primary_key=True, + ) + contact_id = Column( + Integer, + ForeignKey("contact.id", ondelete="CASCADE"), + nullable=False, + primary_key=True, + ) + + author = relationship("Author", back_populates="contact_associations") + contact = relationship("Contact", back_populates="author_associations") + + +class AuthorPublicationAssociation(Base, AuditMixin): + __tablename__ = "pub_author_publication_association" + publication_id = Column( + ForeignKey("publication.id", ondelete="CASCADE"), primary_key=True + ) + author_id = Column( + ForeignKey("pub_author.id", ondelete="CASCADE"), primary_key=True + ) + author_order = Column(Integer, nullable=False) + + publication = relationship("Publication", back_populates="author_associations") + author = relationship("Author", back_populates="publication_associations") + + +# ============= EOF ============================================= diff --git a/models/timeseries.py b/db/timeseries.py similarity index 82% rename from models/timeseries.py rename to db/timeseries.py index acfac5259..883102b7d 100644 --- a/models/timeseries.py +++ b/db/timeseries.py @@ -14,11 +14,9 @@ # limitations under the License. # =============================================================================== from sqlalchemy import DateTime, Float, String, Integer, ForeignKey, Text, func -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import declared_attr, relationship -from sqlalchemy.testing.schema import mapped_column +from sqlalchemy.orm import declared_attr, relationship, mapped_column -from models import AutoBaseMixin, Base, PropertiesMixin +from db import AutoBaseMixin, Base, PropertiesMixin class TimeseriesMixin: @@ -35,7 +33,7 @@ class QCMixin: @declared_attr def quality_control_status(self): return mapped_column( - String(100), ForeignKey("lexicon.term"), default="Provisional" + String(100), ForeignKey("lexicon_term.term"), default="Provisional" ) @declared_attr @@ -49,7 +47,7 @@ def quality_control_timestamp(self): @declared_attr def quality_control_user_id(self): return mapped_column( - Integer, ForeignKey("user.id", ondelete="SET NULL"), nullable=True + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=True ) @@ -69,20 +67,20 @@ class WellTimeseries(Base, TimeseriesMixin, AutoBaseMixin, PropertiesMixin): class GroundwaterLevelObservation(Base, AutoBaseMixin, PropertiesMixin, QCMixin): """ """ + # __table_args__ = {"timescaledb_hypertable": {"time_column_name": "timestamp"}} + # Define common fields for observations here timestamp = mapped_column(DateTime, nullable=False) value = mapped_column(Float, nullable=False) - unit = mapped_column( - String, nullable=False, default="ftbgs" - ) # Default unit is meters + unit = mapped_column(String, nullable=False, default="ftbgs") - data_quality = mapped_column(String(100), ForeignKey("lexicon.term")) - level_status = mapped_column(String(100), ForeignKey("lexicon.term")) + data_quality = mapped_column(String(100), ForeignKey("lexicon_term.term")) + level_status = mapped_column(String(100), ForeignKey("lexicon_term.term")) timeseries_id = mapped_column( "timeseries_id", Integer, - ForeignKey("welltimeseries.id", ondelete="CASCADE"), + ForeignKey("well_timeseries.id", ondelete="CASCADE"), nullable=False, ) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..51b1f8c5c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,222 @@ +# =========================================================================== +# Tools +ARG GO_VERSION=1.22.4 +FROM golang:${GO_VERSION}-alpine AS tools + +ENV TOOLS_VERSION 0.8.1 + +RUN apk update && apk add --no-cache git gcc musl-dev \ + && go install github.com/timescale/timescaledb-tune/cmd/timescaledb-tune@latest \ + && go install github.com/timescale/timescaledb-parallel-copy/cmd/timescaledb-parallel-copy@latest +# =========================================================================== + +#ARG BASE_IMAGE=postgres:17-alpine3.22 +FROM postgres:17-alpine3.22 + +#LABEL maintainer="PostGIS Project - https://postgis.net" \ +# org.opencontainers.image.description="PostGIS 3.5.2+dfsg-1.pgdg110+1 spatial database extension with PostgreSQL 17 bullseye" \ +# org.opencontainers.image.source="https://github.com/postgis/docker-postgis" + +ENV POSTGIS_MAJOR 3 +ENV POSTGIS_VERSION 3.5.2+dfsg-1.pgdg110+1 + +ARG ALPINE_VERSION=3.21 +ARG CLANG_VERSION=19 +ARG PG_MAJOR=17 +ARG PG_VERSION=17 +ARG PG_MAJOR_VERSION=17 +ARG PGVECTOR_VERSION='v0.7.2' + + +#LABEL maintainer="PostGIS Project - https://postgis.net" \ +# org.opencontainers.image.description="PostGIS 3.5.3 spatial database extension with PostgreSQL 17 Alpine" \ +# org.opencontainers.image.source="https://github.com/postgis/docker-postgis" + +ENV POSTGIS_VERSION 3.5.3 +ENV POSTGIS_SHA256 44222ed2b8f742ffc1ceb429b09ebb484c7880f9ba27bf7b6b197346cdd25437 + +RUN set -eux \ + && apk add --no-cache --virtual .fetch-deps \ + ca-certificates \ + openssl \ + tar \ + \ + && wget -O postgis.tar.gz "https://github.com/postgis/postgis/archive/${POSTGIS_VERSION}.tar.gz" \ + && echo "${POSTGIS_SHA256} *postgis.tar.gz" | sha256sum -c - \ + && mkdir -p /usr/src/postgis \ + && tar \ + --extract \ + --file postgis.tar.gz \ + --directory /usr/src/postgis \ + --strip-components 1 \ + && rm postgis.tar.gz \ + \ + && apk add --no-cache --virtual .build-deps \ + \ + gdal-dev \ + geos-dev \ + proj-dev \ + proj-util \ + sfcgal-dev \ + \ + # The upstream variable, '$DOCKER_PG_LLVM_DEPS' contains + # the correct versions of 'llvm-dev' and 'clang' for the current version of PostgreSQL. + # This improvement has been discussed in https://github.com/docker-library/postgres/pull/1077 + $DOCKER_PG_LLVM_DEPS \ + \ + autoconf \ + automake \ + cunit-dev \ + file \ + g++ \ + gcc \ + gettext-dev \ + git \ + json-c-dev \ + libtool \ + libxml2-dev \ + make \ + pcre2-dev \ + perl \ + protobuf-c-dev \ + \ +# build PostGIS - with Link Time Optimization (LTO) enabled + && cd /usr/src/postgis \ + && gettextize \ + && ./autogen.sh \ + && ./configure \ + --enable-lto \ + && make -j$(nproc) \ + && make install \ + \ +# This section is for refreshing the proj data for the regression tests. +# It serves as a workaround for an issue documented at https://trac.osgeo.org/postgis/ticket/5316 +# This increases the Docker image size by about 1 MB. + && projsync --system-directory --file ch_swisstopo_CHENyx06_ETRS \ + && projsync --system-directory --file us_noaa_eshpgn \ + && projsync --system-directory --file us_noaa_prvi \ + && projsync --system-directory --file us_noaa_wmhpgn \ +# This section performs a regression check. + && mkdir /tempdb \ + && chown -R postgres:postgres /tempdb \ + && su postgres -c 'pg_ctl -D /tempdb init' \ + && su postgres -c 'pg_ctl -D /tempdb -c -l /tmp/logfile -o '-F' start ' \ + && cd regress \ + && make -j$(nproc) check RUNTESTFLAGS="--extension --verbose" PGUSER=postgres \ + \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_raster;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_sfcgal;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; --needed for postgis_tiger_geocoder "' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS address_standardizer;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS address_standardizer_data_us;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;"' \ + && su postgres -c 'psql -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"' \ + && su postgres -c 'psql -t -c "SELECT version();"' >> /_pgis_full_version.txt \ + && su postgres -c 'psql -t -c "SELECT PostGIS_Full_Version();"' >> /_pgis_full_version.txt \ + && su postgres -c 'psql -t -c "\dx"' >> /_pgis_full_version.txt \ + \ + && su postgres -c 'pg_ctl -D /tempdb --mode=immediate stop' \ + && rm -rf /tempdb \ + && rm -rf /tmp/logfile \ + && rm -rf /tmp/pgis_reg \ +# add .postgis-rundeps + && apk add --no-cache --virtual .postgis-rundeps \ + \ + gdal \ + geos \ + proj \ + sfcgal \ + \ + json-c \ + libstdc++ \ + pcre2 \ + protobuf-c \ + \ + # ca-certificates: for accessing remote raster files + # fix https://github.com/postgis/docker-postgis/issues/307 + ca-certificates \ +# clean + && cd / \ + && rm -rf /usr/src/postgis \ + && apk del .fetch-deps .build-deps \ +# At the end of the build, we print the collected information +# from the '/_pgis_full_version.txt' file. This is for experimental and internal purposes. + && cat /_pgis_full_version.txt + + +RUN mkdir -p /docker-entrypoint-initdb.d +COPY ./postgis.d/initdb-postgis.sh /docker-entrypoint-initdb.d/10_postgis.sh +COPY ./postgis.d/update-postgis.sh /usr/local/bin + +#=========================================================================== +# TimescaleDB +ARG OSS_ONLY + +#LABEL maintainer="Timescale https://www.timescale.com" + + +RUN set -ex; \ + echo "https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/community/" >> /etc/apk/repositories; \ + apk update; \ + if [ "$PG_MAJOR_VERSION" -ge 16 ] ; then \ + apk add --no-cache postgresql${PG_VERSION}-plpython3; \ + fi + + + +RUN set -ex; \ + apk update; \ + apk add --no-cache --virtual .vector-deps \ + postgresql${PG_VERSION}-dev \ + git \ + build-base \ + clang${CLANG_VERSION} \ + llvm${CLANG_VERSION}-dev \ + llvm${CLANG_VERSION}; \ + git clone --branch ${PGVECTOR_VERSION} https://github.com/pgvector/pgvector.git /build/pgvector; \ + cd /build/pgvector; \ + make; \ + make install; \ + apk del .vector-deps; + +COPY timescaledb.d/* /docker-entrypoint-initdb.d/ +COPY --from=tools /go/bin/* /usr/local/bin/ +#COPY --from=oldversions /usr/local/lib/postgresql/timescaledb-*.so /usr/local/lib/postgresql/ +#COPY --from=oldversions /usr/local/share/postgresql/extension/timescaledb--*.sql /usr/local/share/postgresql/extension/ + +ARG TS_VERSION +RUN set -ex \ + && apk add --no-cache --virtual .fetch-deps \ + ca-certificates \ + git \ + openssl \ + openssl-dev \ + tar \ + && mkdir -p /build/ \ + && git clone https://github.com/timescale/timescaledb /build/timescaledb \ + \ + && apk add --no-cache --virtual .build-deps \ + coreutils \ + dpkg-dev dpkg \ + gcc \ + krb5-dev \ + libc-dev \ + make \ + cmake \ + util-linux-dev \ + \ + # Build current version \ + && cd /build/timescaledb && rm -fr build \ + && git checkout ${TS_VERSION} \ + && ./bootstrap -DCMAKE_BUILD_TYPE=RelWithDebInfo -DREGRESS_CHECKS=OFF -DTAP_CHECKS=OFF -DGENERATE_DOWNGRADE_SCRIPT=ON -DWARNINGS_AS_ERRORS=OFF -DPROJECT_INSTALL_METHOD="docker"${OSS_ONLY} \ + && cd build && make install \ + && cd ~ \ + \ + && if [ "${OSS_ONLY}" != "" ]; then rm -f $(pg_config --pkglibdir)/timescaledb-tsl-*.so; fi \ + && apk del .fetch-deps .build-deps \ + && rm -rf /build \ + && sed -r -i "s/[#]*\s*(shared_preload_libraries)\s*=\s*'(.*)'/\1 = 'timescaledb,\2'/;s/,'/'/" /usr/local/share/postgresql/postgresql.conf.sample + + +LABEL org.opencontainers.image.source="https://github.com/DataIntegrationGroup/NMSampleLocations" diff --git a/docker/postgis.d/initdb-postgis.sh b/docker/postgis.d/initdb-postgis.sh new file mode 100644 index 000000000..eb5626334 --- /dev/null +++ b/docker/postgis.d/initdb-postgis.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +# Perform all actions as $POSTGRES_USER +export PGUSER="$POSTGRES_USER" + +# Create the 'template_postgis' template db +"${psql[@]}" <<- 'EOSQL' +CREATE DATABASE template_postgis IS_TEMPLATE true; +EOSQL + +# Load PostGIS into both template_database and $POSTGRES_DB +for DB in template_postgis "$POSTGRES_DB"; do + echo "Loading PostGIS extensions into $DB" + "${psql[@]}" --dbname="$DB" <<-'EOSQL' + CREATE EXTENSION IF NOT EXISTS postgis; + CREATE EXTENSION IF NOT EXISTS postgis_topology; + -- Reconnect to update pg_setting.resetval + -- See https://github.com/postgis/docker-postgis/issues/288 + \c + CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; + CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder; +EOSQL +done \ No newline at end of file diff --git a/docker/postgis.d/update-postgis.sh b/docker/postgis.d/update-postgis.sh new file mode 100644 index 000000000..ff4b2e7cd --- /dev/null +++ b/docker/postgis.d/update-postgis.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +set -e + +# Perform all actions as $POSTGRES_USER +export PGUSER="$POSTGRES_USER" + +POSTGIS_VERSION="${POSTGIS_VERSION%%+*}" + +# Load PostGIS into both template_database and $POSTGRES_DB +for DB in template_postgis "$POSTGRES_DB" "${@}"; do + echo "Updating PostGIS extensions '$DB' to $POSTGIS_VERSION" + psql --dbname="$DB" -c " + -- Upgrade PostGIS (includes raster) + CREATE EXTENSION IF NOT EXISTS postgis VERSION '$POSTGIS_VERSION'; + ALTER EXTENSION postgis UPDATE TO '$POSTGIS_VERSION'; + + -- Upgrade Topology + CREATE EXTENSION IF NOT EXISTS postgis_topology VERSION '$POSTGIS_VERSION'; + ALTER EXTENSION postgis_topology UPDATE TO '$POSTGIS_VERSION'; + + -- Install Tiger dependencies in case not already installed + CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; + -- Upgrade US Tiger Geocoder + CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder VERSION '$POSTGIS_VERSION'; + ALTER EXTENSION postgis_tiger_geocoder UPDATE TO '$POSTGIS_VERSION'; + " +done \ No newline at end of file diff --git a/docker/timescaledb.d/000_install_timescaledb.sh b/docker/timescaledb.d/000_install_timescaledb.sh new file mode 100644 index 000000000..38823021f --- /dev/null +++ b/docker/timescaledb.d/000_install_timescaledb.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +create_sql=`mktemp` + +if [ -z "${POSTGRESQL_CONF_DIR:-}" ]; then + POSTGRESQL_CONF_DIR=${PGDATA} +fi + +cat <${create_sql} +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; +EOF + +TS_TELEMETRY='basic' +if [ "${TIMESCALEDB_TELEMETRY:-}" == "off" ]; then + TS_TELEMETRY='off' + + # We delete the job as well to ensure that we do not spam the + # log with other messages related to the Telemetry job. + cat <>${create_sql} +SELECT alter_job(1,scheduled:=false); +EOF +fi + +echo "timescaledb.telemetry_level=${TS_TELEMETRY}" >> ${POSTGRESQL_CONF_DIR}/postgresql.conf + +if [ -z "${POSTGRESQL_PASSWORD:-}" ]; then + POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-} +fi +export PGPASSWORD="$POSTGRESQL_PASSWORD" + +# create extension timescaledb in initial databases +psql -U "${POSTGRES_USER}" postgres -f ${create_sql} +psql -U "${POSTGRES_USER}" template1 -f ${create_sql} + +if [ "${POSTGRES_DB:-postgres}" != 'postgres' ]; then + psql -U "${POSTGRES_USER}" "${POSTGRES_DB}" -f ${create_sql} +fi \ No newline at end of file diff --git a/docker/timescaledb.d/001_timescaledb_tune.sh b/docker/timescaledb.d/001_timescaledb_tune.sh new file mode 100644 index 000000000..95cc6db94 --- /dev/null +++ b/docker/timescaledb.d/001_timescaledb_tune.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +NO_TS_TUNE=${NO_TS_TUNE:-""} +TS_TUNE_MEMORY=${TS_TUNE_MEMORY:-""} +TS_TUNE_NUM_CPUS=${TS_TUNE_NUM_CPUS:-""} +TS_TUNE_MAX_CONNS=${TS_TUNE_MAX_CONNS:-""} +TS_TUNE_MAX_BG_WORKERS=${TS_TUNE_MAX_BG_WORKERS:-""} +TS_TUNE_WAL_DISK_SIZE=${TS_TUNE_WAL_DISK_SIZE:-""} + +if [ ! -z "${NO_TS_TUNE:-}" ]; then + # The user has explicitly requested not to run timescaledb-tune; exit this script + exit 0 +fi + + +if [ -z "${POSTGRESQL_CONF_DIR:-}" ]; then + POSTGRESQL_CONF_DIR=${PGDATA} +fi + +if [ -z "${TS_TUNE_MEMORY:-}" ]; then + # See if we can get the container's total allocated memory from the cgroups metadata + # Try with cgroups v2 first. + if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + TS_TUNE_MEMORY=$(cat /sys/fs/cgroup/memory.max) + case ${TS_TUNE_MEMORY} in + max) + TS_TUNE_MEMORY="" + ;; + *) + TS_CGROUPS_MAX_MEM=true + ;; + esac + # cgroups v2 is not available, try with cgroups v1 + elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + TS_TUNE_MEMORY=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) + TS_CGROUPS_MAX_MEM=true + fi + if [ "${TS_CGROUPS_MAX_MEM:-false}" != "false" ]; then + if [ "${TS_TUNE_MEMORY}" = "18446744073709551615" ]; then + # Bash seems to error out for numbers greater than signed 64-bit, + # so if the value of limit_in_bytes is the 64-bit UNSIGNED max value + # we should just bail out and hope timescaledb-tune can figure this + # out. If we don't, the next comparison is likely going to fail + # or it might store a negative value which will crash later. + TS_TUNE_MEMORY="" + fi + + FREE_KB=$(grep MemTotal: /proc/meminfo | awk '{print $2}') + FREE_BYTES=$(( ${FREE_KB} * 1024 )) + if [ ${TS_TUNE_MEMORY} -gt ${FREE_BYTES} ]; then + # Something weird is going on if the cgroups memory limit exceeds the total available + # amount of system memory reported by "free", which is the total amount of memory available on the host. + # Most likely, it is this issue: https://github.com/moby/moby/issues/18087 (if no limit is + # set, the max limit is set to the max 64 bit integer). In this case, we just leave + # TS_TUNE_MEMORY blank and let timescaledb-tune derive the memory itself using syscalls. + TS_TUNE_MEMORY="" + else + # Convert the bytes to MB so it plays nicely with timescaledb-tune + TS_TUNE_MEMORY="$(echo ${TS_TUNE_MEMORY} | awk '{print int($1 / 1024 / 1024)}')MB" + fi + fi +fi + +if [ -z "${TS_TUNE_NUM_CPUS:-}" ]; then + # See if we can get the container's available CPUs from the cgroups metadata + # Try with cgroups v2 first. + if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + TS_TUNE_NUM_CPUS=$(cat /sys/fs/cgroup/cpu.max | awk '{print $1}') + if [ "${TS_TUNE_NUM_CPUS}" = "max" ]; then + TS_TUNE_NUM_CPUS="" + else + TS_TUNE_NUM_CPUS_PERIOD=$(cat /sys/fs/cgroup/cpu.max | awk '{print $2}') + fi + # cgroups v2 is not available, try with cgroups v1 + elif [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ]; then + TS_TUNE_NUM_CPUS=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us) + if [ "${TS_TUNE_NUM_CPUS}" = "-1" ]; then + TS_TUNE_NUM_CPUS="" + else + TS_TUNE_NUM_CPUS_PERIOD=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us) + fi + fi + + if [ -n "${TS_TUNE_NUM_CPUS}" ]; then + if [ "${TS_TUNE_NUM_CPUS_PERIOD}" != "100000" ]; then + # Detecting cpu via cgroups with modified duration is not supported + TS_TUNE_NUM_CPUS="" + else + # Determine (integer) number of CPUs, rounding up + if [ $(( ${TS_TUNE_NUM_CPUS} % 100000 )) -eq 0 ]; then + TS_TUNE_NUM_CPUS=$(( ${TS_TUNE_NUM_CPUS} / 100000 )) + else + TS_TUNE_NUM_CPUS=$(( ( ${TS_TUNE_NUM_CPUS} / 100000 ) + 1 )) + fi + fi + fi +fi + +if [ ! -z "${TS_TUNE_MEMORY:-}" ]; then + TS_TUNE_MEMORY_FLAGS=--memory="${TS_TUNE_MEMORY}" +fi + +if [ ! -z "${TS_TUNE_NUM_CPUS:-}" ]; then + TS_TUNE_NUM_CPUS_FLAGS=--cpus=${TS_TUNE_NUM_CPUS} +fi + +if [ ! -z "${TS_TUNE_MAX_CONNS:-}" ]; then + TS_TUNE_MAX_CONNS_FLAGS=--max-conns=${TS_TUNE_MAX_CONNS} +fi + +if [ ! -z "${TS_TUNE_MAX_BG_WORKERS:-}" ]; then + TS_TUNE_MAX_BG_WORKERS_FLAGS=--max-bg-workers=${TS_TUNE_MAX_BG_WORKERS} +fi + +if [ ! -z "${TS_TUNE_WAL_DISK_SIZE:-}" ]; then + TS_TUNE_WAL_DISK_SIZE_FLAGS=--wal-disk-size="${TS_TUNE_WAL_DISK_SIZE}" +fi + +if [ ! -z "${PG_MAJOR}" ]; then + TS_TUNE_PG_VERSION=--pg-version=${PG_MAJOR} +fi + +/usr/local/bin/timescaledb-tune --quiet --yes --conf-path="${POSTGRESQL_CONF_DIR}/postgresql.conf" ${TS_TUNE_MEMORY_FLAGS} ${TS_TUNE_NUM_CPUS_FLAGS} ${TS_TUNE_MAX_CONNS_FLAGS} ${TS_TUNE_MAX_BG_WORKERS_FLAGS} ${TS_TUNE_WAL_DISK_SIZE_FLAGS} ${TS_TUNE_PG_VERSION} \ No newline at end of file diff --git a/geoserver_iac/.terraform.lock.hcl b/geoserver_iac/.terraform.lock.hcl new file mode 100644 index 000000000..0c3c756b6 --- /dev/null +++ b/geoserver_iac/.terraform.lock.hcl @@ -0,0 +1,62 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = ">= 6.0.0, ~> 6.50, < 7.0.0" + hashes = [ + "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} + +provider "registry.terraform.io/hashicorp/google-beta" { + version = "6.50.0" + constraints = ">= 6.0.0, < 7.0.0" + hashes = [ + "h1:P2GiUJM1frlPtBViwKn1A9V2dVBdGuWcX80w9TdH8ZE=", + "zh:18b442bd0a05321d39dda1e9e3f1bdede4e61bc2ac62cc7a67037a3864f75101", + "zh:2e387c51455862828bec923a3ec81abf63a4d998da470cf00e09003bda53d668", + "zh:3942e708fa84ebe54996086f4b1398cb747fe19cbcd0be07ace528291fb35dee", + "zh:496287dd48b34ae6197cb1f887abeafd07c33f389dbe431bb01e24846754cfdd", + "zh:6eca885419969ce5c2a706f34dce1f10bde9774757675f2d8a92d12e5a1be390", + "zh:710dbef826c3fe7f76f844dae47937e8e4c1279dd9205ec4610be04cf3327244", + "zh:777ebf44b24bfc7bdbf770dc089f1a72f143b4718fdedb8c6bd75983115a1ec2", + "zh:9c8703bba37b8c7ad857efc3513392c5a096c519397c1cb822d7612f38e4262f", + "zh:c4f1d3a73de2702277c99d5348ad6d374705bcfdd367ad964ff4cfd2cf06c281", + "zh:eca8df11af3f5a948492d5b8b5d01b4ec705aad10bc30ec1524205508ae28393", + "zh:f41e7fd5f2628e8fd6b8ea136366923858f54428d1729898925469b862c275c2", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" + constraints = ">= 2.1.0" + hashes = [ + "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", + ] +} diff --git a/geoserver_iac/main.tf b/geoserver_iac/main.tf new file mode 100644 index 000000000..cdb36d621 --- /dev/null +++ b/geoserver_iac/main.tf @@ -0,0 +1,172 @@ +locals { + prefix = "geoserver" + vm_name = "geoserver-proto-01" + instance_group = "geoserver-ig" + service_account = "geoserver-vm" + tags = ["geoserver", "allow-health-check"] +} + +resource "google_project_service" "compute" { + service = "compute.googleapis.com" +} + +resource "google_project_service" "storage" { + service = "storage.googleapis.com" +} + +resource "google_compute_firewall" "ssh_admin" { + name = "geoserver-allow-ssh-admin" + network = var.network_name + direction = "INGRESS" + source_ranges = [var.admin_ssh_cidr] + target_tags = ["geoserver"] + + allow { + protocol = "tcp" + ports = ["22"] + } +} + +resource "google_compute_instance" "geoserver" { + name = local.vm_name + zone = var.zone + machine_type = var.machine_type + tags = local.tags + + boot_disk { + initialize_params { + image = var.instance_image + size = var.boot_disk_size_gb + } + } + + network_interface { + network = var.network_name + access_config {} + } + + service_account { + email = google_service_account.geoserver_vm.email + scopes = ["cloud-platform"] + } + + metadata_startup_script = templatefile("${path.module}/startup-geoserver.sh.tpl", { + domain_name = var.domain_name + geoserver_data_bucket = var.geoserver_data_bucket + geoserver_data_mount_point = var.geoserver_data_mount_point + geoserver_data_only_dir = var.geoserver_data_only_dir + geoserver_data_read_only = var.geoserver_data_read_only + geoserver_image = var.geoserver_image + geoserver_community_extensions = var.geoserver_community_extensions + geoserver_stable_extensions = var.geoserver_stable_extensions + surveys_bucket = var.surveys_bucket + surveys_container_mount_point = var.surveys_container_mount_point + surveys_mount_point = var.surveys_mount_point + surveys_only_dir = var.surveys_only_dir + }) + + depends_on = [ + google_project_service.compute, + google_project_service.storage, + google_storage_bucket_iam_member.geoserver_data_viewer, + google_storage_bucket_iam_member.geoserver_surveys_viewer, + ] +} + +resource "google_compute_instance_group" "geoserver" { + name = local.instance_group + zone = var.zone + + named_port { + name = "http" + port = 8080 + } +} + +resource "google_compute_instance_group_membership" "geoserver" { + instance_group = google_compute_instance_group.geoserver.name + instance = google_compute_instance.geoserver.self_link + zone = var.zone +} + +resource "google_project_service" "servicenetworking" { + service = "servicenetworking.googleapis.com" + disable_on_destroy = false +} + +data "google_compute_network" "default" { + name = var.network_name +} + +resource "google_service_account" "geoserver_vm" { + account_id = local.service_account + display_name = "GeoServer VM service account" +} + +resource "google_storage_bucket_iam_member" "geoserver_data_viewer" { + bucket = var.geoserver_data_bucket + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.geoserver_vm.email}" + depends_on = [google_project_service.storage] +} + +resource "google_storage_bucket_iam_member" "geoserver_surveys_viewer" { + count = var.surveys_bucket != "" ? 1 : 0 + bucket = var.surveys_bucket + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.geoserver_vm.email}" + depends_on = [google_project_service.storage] +} + +resource "google_project_iam_member" "geoserver_vm_log_writer" { + project = var.project_id + role = "roles/logging.logWriter" + member = "serviceAccount:${google_service_account.geoserver_vm.email}" +} + +module "https_lb" { + source = "terraform-google-modules/lb-http/google" + version = "~> 12.0" + + name = local.prefix + project = var.project_id + + ssl = true + managed_ssl_certificate_domains = [var.domain_name] + https_redirect = true + + firewall_networks = [var.network_name] + target_tags = ["allow-health-check"] + + backends = { + default = { + description = "GeoServer backend" + + protocol = "HTTP" + port = 8080 + port_name = "http" + timeout_sec = 30 + enable_cdn = false + + health_check = { + request_path = "/geoserver/index.html" + port = 8080 + } + + log_config = { + enable = true + sample_rate = 1.0 + } + + groups = [ + { + group = google_compute_instance_group.geoserver.self_link + } + ] + + iap_config = { + enable = false + } + } + } +} diff --git a/geoserver_iac/outputs.tf b/geoserver_iac/outputs.tf new file mode 100644 index 000000000..139b8b509 --- /dev/null +++ b/geoserver_iac/outputs.tf @@ -0,0 +1,23 @@ +output "load_balancer_ip" { + value = module.https_lb.external_ip +} + +output "geoserver_url" { + value = "https://${var.domain_name}/geoserver" +} + +output "geoserver_data_bucket" { + value = var.geoserver_data_bucket +} + +output "geoserver_data_mount_prefix" { + value = var.geoserver_data_only_dir +} + +output "surveys_bucket" { + value = var.surveys_bucket +} + +output "surveys_mount_prefix" { + value = var.surveys_only_dir +} diff --git a/geoserver_iac/startup-geoserver.sh.tpl b/geoserver_iac/startup-geoserver.sh.tpl new file mode 100644 index 000000000..f7415ba6e --- /dev/null +++ b/geoserver_iac/startup-geoserver.sh.tpl @@ -0,0 +1,162 @@ +#!/bin/bash +set -euxo pipefail + +export DEBIAN_FRONTEND=noninteractive + +BUCKET_NAME="${geoserver_data_bucket}" +MOUNT_POINT="${geoserver_data_mount_point}" +ONLY_DIR="${geoserver_data_only_dir}" +READ_ONLY="${geoserver_data_read_only}" +SURVEYS_BUCKET="${surveys_bucket}" +SURVEYS_MOUNT_POINT="${surveys_mount_point}" +SURVEYS_ONLY_DIR="${surveys_only_dir}" +SURVEYS_CONTAINER_MOUNT_POINT="${surveys_container_mount_point}" +MOUNT_MODE="rw" +FSTAB_OPTIONS="_netdev,allow_other,implicit_dirs,x-systemd.requires=network-online.target" +DOCKER_VOLUME_SUFFIX="" +GEOSERVER_DATA_MOUNT_PRESENT="false" +SURVEYS_MOUNT_PRESENT="false" + +wait_for_apt() { + while fuser /var/lib/dpkg/lock >/dev/null 2>&1 \ + || fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + sleep 5 + done +} + +apt_retry() { + local attempt=1 + local max_attempts=12 + + while true; do + wait_for_apt + + if "$@"; then + return 0 + fi + + if [ "$attempt" -ge "$max_attempts" ]; then + return 1 + fi + + attempt=$((attempt + 1)) + sleep 5 + done +} + +retry() { + local attempt=1 + local max_attempts="$${RETRY_MAX_ATTEMPTS:-5}" + + while true; do + if "$@"; then + return 0 + fi + + if [ "$attempt" -ge "$max_attempts" ]; then + return 1 + fi + + attempt=$((attempt + 1)) + sleep 5 + done +} + +ensure_bucket_mount() { + local bucket_name="$1" + local mount_point="$2" + local only_dir="$3" + local read_only="$4" + local mount_mode="rw" + local fstab_options="_netdev,allow_other,implicit_dirs,x-systemd.requires=network-online.target" + + mkdir -p "$mount_point" + + if [ -n "$only_dir" ]; then + fstab_options="$${fstab_options},only_dir=$${only_dir}" + fi + + if [ "$read_only" = "true" ]; then + mount_mode="ro" + fi + + if ! grep -q "^[^#].*[[:space:]]$${mount_point}[[:space:]]gcsfuse[[:space:]]" /etc/fstab; then + echo "$${bucket_name} $${mount_point} gcsfuse $${mount_mode},$${fstab_options} 0 0" >> /etc/fstab + fi + + mountpoint -q "$mount_point" || mount "$mount_point" +} + +apt_retry apt-get update +apt_retry apt-get install -y ca-certificates curl gnupg lsb-release docker.io fuse + +rm -f /usr/share/keyrings/cloud.google.asc +curl https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + | tee /usr/share/keyrings/cloud.google.asc >/dev/null + +echo "deb [signed-by=/usr/share/keyrings/cloud.google.asc] https://packages.cloud.google.com/apt gcsfuse-$(lsb_release -c -s) main" \ + > /etc/apt/sources.list.d/gcsfuse.list + +apt_retry apt-get update +apt_retry apt-get install -y gcsfuse + +systemctl enable docker +systemctl restart docker + +if ! grep -q "^user_allow_other$" /etc/fuse.conf; then + echo "user_allow_other" >> /etc/fuse.conf +fi + +if [ "$READ_ONLY" = "true" ]; then + MOUNT_MODE="ro" + DOCKER_VOLUME_SUFFIX=":ro" +fi + +ensure_bucket_mount "$BUCKET_NAME" "$MOUNT_POINT" "$ONLY_DIR" "$READ_ONLY" + +if [ -f "$MOUNT_POINT/global.xml" ]; then + GEOSERVER_DATA_MOUNT_PRESENT="true" +fi + +if [ -n "$SURVEYS_BUCKET" ]; then + ensure_bucket_mount "$SURVEYS_BUCKET" "$SURVEYS_MOUNT_POINT" "$SURVEYS_ONLY_DIR" "true" + SURVEYS_MOUNT_PRESENT="true" +fi + +docker rm -f geoserver || true +retry docker pull ${geoserver_image} + +COMMUNITY_EXTENSIONS="${geoserver_community_extensions}" +STABLE_EXTENSIONS="${geoserver_stable_extensions}" + +docker_args=( + -d + --name geoserver + --restart unless-stopped + -p 8080:8080 + -e PROXY_BASE_URL="https://${domain_name}/geoserver" + -e EXTRA_JAVA_OPTS="-DGEOSERVER_CSRF_WHITELIST=${domain_name}" +) + +if [ -n "$COMMUNITY_EXTENSIONS" ] || [ -n "$STABLE_EXTENSIONS" ]; then + docker_args+=(-e INSTALL_EXTENSIONS=true) +fi + +if [ -n "$STABLE_EXTENSIONS" ]; then + docker_args+=(-e STABLE_EXTENSIONS="$STABLE_EXTENSIONS") +fi + +if [ -n "$COMMUNITY_EXTENSIONS" ]; then + docker_args+=(-e COMMUNITY_EXTENSIONS="$COMMUNITY_EXTENSIONS") +fi + +if [ "$GEOSERVER_DATA_MOUNT_PRESENT" = "true" ]; then + docker_args+=(-v "$${MOUNT_POINT}:/opt/geoserver_data$${DOCKER_VOLUME_SUFFIX}") +fi + +if [ "$SURVEYS_MOUNT_PRESENT" = "true" ]; then + docker_args+=(-v "$${SURVEYS_MOUNT_POINT}:$${SURVEYS_CONTAINER_MOUNT_POINT}:ro") +fi + +docker run "$${docker_args[@]}" ${geoserver_image} diff --git a/geoserver_iac/terraform.tfvars b/geoserver_iac/terraform.tfvars new file mode 100644 index 000000000..0c72cb058 --- /dev/null +++ b/geoserver_iac/terraform.tfvars @@ -0,0 +1,20 @@ +project_id = "waterdatainitiative-271000" +region = "us-west4" +zone = "us-west4-a" +domain_name = "geoserver.newmexicowaterdata.org" +network_name = "default" +machine_type = "e2-standard-4" +boot_disk_size_gb = 30 +instance_image = "projects/debian-cloud/global/images/family/debian-12" +admin_ssh_cidr = "129.138.163.237/32" +geoserver_image = "docker.osgeo.org/geoserver:2.28.0" +geoserver_community_extensions = "" +geoserver_stable_extensions = "ogcapi-features" +geoserver_data_bucket = "nmbgmr-geophysics-data" +geoserver_data_mount_point = "/mnt/disks/geoserver-data" +geoserver_data_only_dir = "geoserver_data" +geoserver_data_read_only = false +surveys_bucket = "nmbgmr-geophysics-data" +surveys_mount_point = "/mnt/disks/geoserver-surveys" +surveys_only_dir = "surveys" +surveys_container_mount_point = "/opt/geoserver_data/surveys" diff --git a/geoserver_iac/variables.tf b/geoserver_iac/variables.tf new file mode 100644 index 000000000..7ee1b0bc9 --- /dev/null +++ b/geoserver_iac/variables.tf @@ -0,0 +1,116 @@ +variable "project_id" { + type = string + description = "GCP project ID" +} + +variable "region" { + type = string + description = "GCP region" + default = "us-west4" +} + +variable "zone" { + type = string + description = "GCP zone" + default = "us-west4-a" +} + +variable "domain_name" { + type = string + description = "Public DNS name for GeoServer" + default = "geoserver.newmexicowaterdata.org" +} + +variable "network_name" { + type = string + description = "VPC network name" + default = "default" +} + +variable "machine_type" { + type = string + description = "Compute Engine machine type" + default = "e2-standard-4" +} + +variable "boot_disk_size_gb" { + type = number + description = "Boot disk size in GB" + default = 30 +} + +variable "instance_image" { + type = string + description = "Compute Engine boot image for the GeoServer VM" + default = "projects/debian-cloud/global/images/family/debian-12" +} + +variable "geoserver_image" { + type = string + description = "GeoServer container image" + default = "docker.osgeo.org/geoserver:2.28.0" +} + +variable "geoserver_community_extensions" { + type = string + description = "Comma-separated GeoServer community extensions to install at startup" + default = "" +} + +variable "geoserver_stable_extensions" { + type = string + description = "Comma-separated GeoServer stable extensions to install at startup (e.g. ogcapi-features)" + default = "ogcapi-features" +} + +variable "geoserver_data_bucket" { + type = string + description = "Existing GCS bucket name that backs the GeoServer data directory mount" +} + +variable "geoserver_data_mount_point" { + type = string + description = "Host mount point for the GeoServer data bucket" + default = "/mnt/disks/geoserver-data" +} + +variable "geoserver_data_only_dir" { + type = string + description = "Optional prefix within the bucket to mount as the GeoServer data directory" + default = "data_dir" +} + +variable "geoserver_data_read_only" { + type = bool + description = "Whether the GeoServer data bucket should be mounted read-only" + default = false +} + +variable "surveys_bucket" { + type = string + description = "Optional GCS bucket name containing survey assets GeoServer should read" + default = "" +} + +variable "surveys_mount_point" { + type = string + description = "Host mount point for the surveys bucket" + default = "/mnt/disks/geoserver-surveys" +} + +variable "surveys_only_dir" { + type = string + description = "Optional prefix within the surveys bucket to mount for GeoServer access" + default = "" +} + +variable "surveys_container_mount_point" { + type = string + description = "Container path where the surveys bucket should be exposed to GeoServer" + default = "/opt/geoserver_data/surveys" +} + +variable "admin_ssh_cidr" { + type = string + description = "Admin public IP in CIDR notation, for example 203.0.113.10/32" +} diff --git a/geoserver_iac/versions.tf b/geoserver_iac/versions.tf new file mode 100644 index 000000000..135b0eb8e --- /dev/null +++ b/geoserver_iac/versions.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.5.0" + + backend "gcs" { + bucket = "waterdatainitiative-271000-tfstate-geoserver" + prefix = "geoserver/prod" + } + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.50" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 6.50" + } + } +} + +provider "google" { + project = var.project_id + region = var.region + zone = var.zone +} + +provider "google-beta" { + project = var.project_id + region = var.region + zone = var.zone +} \ No newline at end of file diff --git a/lexicon.json b/lexicon.json deleted file mode 100644 index f85a7222b..000000000 --- a/lexicon.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - {"term": "ft", "definition": "feet", "category": "unit"}, - {"term": "ftbgs", "definition": "feet below ground surface", "category": "unit"}, - {"term": "F", "definition": "Fahrenheit", "category": "unit"}, - {"term": "mg/L", "definition": "Milligrams per Liter", "category": "unit"}, - {"term": "TDS", "definition": "Total Dissolved Solids", "category": "water_quality"}, - {"term": "mW/mΒ²", "definition": "milliwatts per square meter", "category": "unit"}, - {"term": "W/mΒ²", "definition": "watts per square meter", "category": "unit"}, - {"term": "W/mΒ·K", "definition": "watts per meter Kelvin", "category": "unit"}, - {"term": "mΒ²/s", "definition": "square meters per second", "category": "unit"}, - - {"term": "Exploration", "definition": "Exploration well", "category": "well_type"}, - {"term": "Monitoring", "definition": "Monitoring", "category": "well_type"}, - {"term": "Production", "definition": "Production", "category": "well_type"}, - {"term": "Injection", "definition": "Injection", "category": "well_type"}, - - {"term": "PVC", "definition": "Polyvinyl Chloride", "category": "casing_material"}, - {"term": "Steel", "definition": "Steel", "category": "casing_material"}, - {"term": "Concrete", "definition": "Concrete", "category": "casing_material"}, - - {"term": "Provisional", "definition": "Provisional quality control status", "category": "quality_control_status"}, - {"term": "Approved", "definition": "Approved quality control status", "category": "quality_control_status"}, - {"term": "Rejected", "definition": "Rejected quality control status", "category": "quality_control_status"}, - - {"term": "mud", "definition": "drilling mud", "category": "drilling_fluid"} -] \ No newline at end of file diff --git a/main.py b/main.py index b2b9c60b9..4fcc1cd3e 100644 --- a/main.py +++ b/main.py @@ -8,19 +8,21 @@ from starlette.middleware.cors import CORSMiddleware -from app import app +from core.app import app from fastadmin import fastapi_app as admin_app -from routes.base import router as base_router -from routes.form import router as form_router -from routes.timeseries import router as timeseries_router -from routes.lexicon import router as lexicon_router -from routes.chemisty import router as chemistry_router -from routes.geothermal import router as geothermal_router -from routes.collabnet import router as collabnet_router - -app.mount("/admin", admin_app) +from api.base import router as base_router +from api.form import router as form_router +from api.timeseries import router as timeseries_router +from api.lexicon import router as lexicon_router +from api.chemisty import router as chemistry_router +from api.geothermal import router as geothermal_router +from api.collabnet import router as collabnet_router +from api.geochronology import router as geochronology_router +from api.publication import router as publication_router +from api.author import router as author_router +from api.asset import router as asset_router app.include_router(base_router) app.include_router(form_router) @@ -29,6 +31,16 @@ app.include_router(chemistry_router) app.include_router(geothermal_router) app.include_router(collabnet_router) +app.include_router(geochronology_router) +app.include_router(publication_router) +app.include_router(author_router) +app.include_router(asset_router) + +from admin.user import * +from admin.base import * + +app.mount("/admin", admin_app) + app.add_middleware( CORSMiddleware, @@ -38,12 +50,9 @@ allow_headers=["*"], ) +# setup pagination add_pagination(app) -# import all the admin models -from admin.user import UserModelAdmin -from admin.base import SampleLocationsAdmin, WellAdmin - if __name__ == "__main__": import uvicorn diff --git a/manage.py b/manage.py new file mode 100644 index 000000000..e9d556be1 --- /dev/null +++ b/manage.py @@ -0,0 +1,26 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from core.app import init_lexicon + +import click + + +@click.command() +def initialize_lexicon(): + init_lexicon() + + +# ============= EOF ============================================= diff --git a/models/__init__.py b/models/__init__.py deleted file mode 100644 index 4222e840f..000000000 --- a/models/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -# =============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import os - -from geoalchemy2 import load_spatialite -from sqlalchemy import create_engine, Column, Integer, DateTime, func, JSON -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.event import listen -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from sqlalchemy.orm import declarative_base, sessionmaker, declared_attr - -if os.environ.get("SPATIALITE_LIBRARY_PATH") is None: - os.environ["SPATIALITE_LIBRARY_PATH"] = "/opt/homebrew/lib/mod_spatialite.dylib" - -# engine = create_async_engine( -# "sqlite+aiosqlite:///./development.db", -# echo=True, -# plugins=['geoalchemy2'], -# ) - -engine = create_engine( - "sqlite:///./development.db", - # echo=True, - plugins=["geoalchemy2"], -) - - -def on_connect(dbapi_connection, connection_record): - """ - Event listener to load SpatiaLite on connection. - """ - load_spatialite(dbapi_connection) - - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - -listen(engine, "connect", on_connect) - - -# sqlalchemy_sessionmaker = async_sessionmaker(engine, expire_on_commit=False) -sqlalchemy_sessionmaker = sessionmaker(engine, expire_on_commit=False) - - -async def get_db_session(): - session = sqlalchemy_sessionmaker() - yield session - session.close() - - -Base = declarative_base() - - -def adder(session, table, model, **kwargs): - """ - Helper function to add a new record to the database. - """ - md = model.model_dump() - if kwargs: - md.update(kwargs) - - obj = table(**md) - session.add(obj) - session.commit() - return obj - - -class AutoBaseMixin: - @declared_attr - def __tablename__(self): - return self.__name__.lower() - - @declared_attr - def id(self): - return Column(Integer, primary_key=True, autoincrement=True) - - @declared_attr - def created_at(self): - return Column(DateTime, nullable=False, server_default=func.now()) - - @declared_attr - def updated_at(self): - return Column( - DateTime, - nullable=False, - server_default=func.now(), - server_onupdate=func.now(), - ) - - -class PropertiesMixin: - @declared_attr - def properties(self): - return Column( - "properties", - JSON, - nullable=True, - comment="JSONB column for storing additional properties", - ) - - -# ============= EOF ============================================= diff --git a/pyproject.toml b/pyproject.toml index 98c52f55f..4b9a42875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,26 @@ requires-python = ">=3.13" dependencies = [ "aiosqlite>=0.21.0", "alembic>=1.16.1", + "asyncpg>=0.30.0", "bcrypt>=4.3.0", + "cloud-sql-python-connector>=1.18.2", + "email-validator>=2.2.0", "fastadmin>=0.2.22", "fastapi>=0.115.12", "fastapi-pagination>=0.13.2", "geoalchemy2>=0.17.1", + "google-cloud-storage>=3.1.1", "greenlet>=3.2.2", + "gunicorn>=23.0.0", "httpx>=0.28.1", + "pg8000>=1.31.2", + "phonenumbers>=9.0.7", + "pillow>=11.2.1", + "psycopg2>=2.9.10", "pyshp>=2.3.1", + "python-multipart>=0.0.20", "shapely>=2.1.1", + "sqlalchemy-searchable>=2.1.0", "uvicorn>=0.34.3", ] [tool.alembic] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..35e72d84b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,80 @@ +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.13 +aiosignal==1.3.2 +aiosqlite==0.21.0 +alembic==1.16.1 +annotated-types==0.7.0 +anyio==4.9.0 +asgiref==3.8.1 +asn1crypto==1.5.1 +asyncpg==0.30.0 +attrs==25.3.0 +bcrypt==4.3.0 +cachetools==5.5.2 +certifi==2025.4.26 +cffi==1.17.1 +charset-normalizer==3.4.2 +click==8.2.1 +cloud-sql-python-connector==1.18.2 +cryptography==45.0.4 +dnspython==2.7.0 +email-validator==2.2.0 +fastadmin==0.2.22 +fastapi==0.115.12 +fastapi-pagination==0.13.2 +frozenlist==1.7.0 +geoalchemy2==0.17.1 +google-api-core==2.25.1 +google-auth==2.40.3 +google-cloud-core==2.4.3 +google-cloud-storage==3.1.1 +google-crc32c==1.7.1 +google-resumable-media==2.7.2 +googleapis-common-protos==1.70.0 +greenlet==3.2.2 +gunicorn==23.0.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.10 +iniconfig==2.1.0 +mako==1.3.10 +markupsafe==3.0.2 +multidict==6.6.3 +numpy==2.3.0 +packaging==25.0 +pg8000==1.31.2 +phonenumbers==9.0.7 +pillow==11.3.0 +pluggy==1.6.0 +propcache==0.3.2 +proto-plus==1.26.1 +protobuf==6.31.1 +psycopg2==2.9.10 +pyasn1==0.6.1 +pyasn1-modules==0.4.2 +pycparser==2.22 +pydantic==2.11.5 +pydantic-core==2.33.2 +pygments==2.19.1 +pyjwt==2.10.1 +pyshp==2.3.1 +pytest==8.4.0 +python-dateutil==2.9.0.post0 +python-multipart==0.0.20 +requests==2.32.4 +rsa==4.9.1 +scramp==1.4.5 +shapely==2.1.1 +six==1.17.0 +sniffio==1.3.1 +sqlalchemy==2.0.41 +sqlalchemy-searchable==2.1.0 +sqlalchemy-utils==0.41.2 +starlette==0.46.2 +typing-extensions==4.14.0 +typing-inspection==0.4.1 +urllib3==2.5.0 +uvicorn==0.34.3 +yarl==1.20.1 diff --git a/routes/chemisty.py b/routes/chemisty.py deleted file mode 100644 index d511911f8..000000000 --- a/routes/chemisty.py +++ /dev/null @@ -1,63 +0,0 @@ -# =============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from fastapi import APIRouter, Depends, status -from sqlalchemy.orm import Session - -from models import adder, get_db_session -from models.chemistry import WaterChemistryAnalysis, WaterChemistryAnalysisSet -from schemas.chemistry_create import CreateWaterChemistryAnalysis, CreateAnalysisSet - -router = APIRouter( - prefix="/chemistry", -) - - -@router.get("/analysis", tags=["chemistry"]) -async def get_chemistry_analysis(): - """ - Retrieve chemistry analysis data. - """ - # Placeholder for actual implementation - return {"message": "Chemistry analysis data retrieved successfully."} - - -# ====== POST =============== -@router.post("/analysis_set", status_code=status.HTTP_201_CREATED) -async def add_chemistry_analysis_set( - analysis_set_data: CreateAnalysisSet, session: Session = Depends(get_db_session) -): - """ - Add a set of new chemistry analyses. - """ - # Placeholder for actual implementation - # return {"message": "Chemistry analysis set added successfully.", "data": analysis_data} - return adder(session, WaterChemistryAnalysisSet, analysis_set_data) - - -@router.post("/analysis", status_code=status.HTTP_201_CREATED, tags=["chemistry"]) -async def add_chemistry_analysis( - analysis_data: CreateWaterChemistryAnalysis, - session: Session = Depends(get_db_session), -): - """ - Add a new chemistry analysis. - """ - # Placeholder for actual implementation - # return {"message": "Chemistry analysis added successfully.", "data": analysis_data} - return adder(session, WaterChemistryAnalysis, analysis_data) - - -# ============= EOF ============================================= diff --git a/schemas/base_create.py b/schemas/base_create.py index e6503d42e..19bbddb08 100644 --- a/schemas/base_create.py +++ b/schemas/base_create.py @@ -15,107 +15,9 @@ # =============================================================================== from datetime import datetime -from pydantic import model_validator - from schemas import ORMBaseModel -class CreateLocation(ORMBaseModel): - """ - Schema for creating a sample location. - """ - - name: str - description: str | None = None - point: str = "POINT(0 0)" # Default to a point at the origin - visible: bool = False - - -class CreateWell(ORMBaseModel): - """ - Schema for creating a well. - """ - - location_id: int - api_id: str | None = None - ose_pod_id: str | None = None - well_type: str | None = None - well_depth: float | None = None # in feet - construction_notes: str | None = None - - -class CreateScreenWell(ORMBaseModel): - """ - Schema for creating a well screen. - """ - - well_id: int - screen_depth_bottom: float - screen_depth_top: float - screen_type: str | None = None - - @model_validator(mode="after") - def validate_screen_type(self): - if self.screen_type is not None: - valid_screen_types = [ - "PVC", - ] # todo: get valid screen types from database - if self.screen_type not in valid_screen_types: - raise ValueError( - f"Invalid screen_type: {self.screen_type}. " - f"Valid options are: {', '.join(valid_screen_types)}." - ) - return self - - # validate that screen depth bottom is greater than top - @model_validator(mode="after") - def check_depths(self): - if self.screen_depth_bottom < self.screen_depth_top: - raise ValueError( - "screen_depth_bottom must be greater than screen_depth_top" - ) - return self - - -class CreateGroup(ORMBaseModel): - """ - Schema for creating a group. - """ - - name: str - - -class CreateGroupLocation(ORMBaseModel): - """ - Schema for creating a group location. - """ - - group_id: int - location_id: int - - -class CreateOwner(ORMBaseModel): - """ - Schema for creating an owner. - """ - - name: str - description: str | None = None - - -class CreateContact(ORMBaseModel): - """ - Schema for creating a contact. - """ - - owner_id: int - - name: str | None = None - description: str | None = None - email: str | None = None - phone: str | None = None - - class CreateSpring(ORMBaseModel): """ Schema for creating a spring. diff --git a/schemas/base_responses.py b/schemas/base_responses.py index 25c9d81bf..2754164d2 100644 --- a/schemas/base_responses.py +++ b/schemas/base_responses.py @@ -13,112 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import Annotated, List - -from geoalchemy2 import WKBElement -from geoalchemy2.shape import to_shape -from pydantic import ConfigDict, computed_field, field_validator from schemas import ORMBaseModel -class SampleLocationResponse(ORMBaseModel): - """ - Response schema for sample location details. - """ - - id: int - name: str | None = None - description: str | None = None - point: str - - @field_validator("point", mode="before") - def point_to_wkt(cls, value): - if isinstance(value, WKBElement): - return to_shape(value).wkt - - # If the value is a string, assume it's already in WKT format - if isinstance(value, str): - return value - - -class WellResponse(ORMBaseModel): - """ - Response schema for well details. - """ - - id: int - location_id: int - api_id: str | None = None - ose_pod_id: str | None = None - usgs_id: str | None = None - construction_notes: str | None = None - - # Additional fields can be added as needed - - -class SampleLocationWellResponse(SampleLocationResponse): - """ - Response schema for sample location with well details. - """ - - well: List[WellResponse] = [] # List of wells associated with the sample location - - -class GroupResponse(ORMBaseModel): - """ - Response schema for group details. - """ - - id: int - name: str - description: str | None = None - - -class ContactResponse(ORMBaseModel): - """ - Response schema for contact details. - """ - - id: int - name: str - email: str | None = None - phone: str | None = None - - -class OwnerResponse(ORMBaseModel): - """ - Response schema for owner details. - """ - - id: int - name: str - contacts: List[ContactResponse] = [] # List of contacts associated with the owner - # email: str | None = None - # phone: str | None = None - - -class WellScreenResponse(ORMBaseModel): - """ - Response schema for well screen details. - """ - - id: int - well_id: int - screen_depth_bottom: float - screen_depth_top: float - - -class GroupLocationResponse(ORMBaseModel): - """ - Response schema for group location details. - """ - - id: int - group_id: int - location_id: int - - class SpringResponse(ORMBaseModel): """ Response schema for spring details. diff --git a/schemas/create/__init__.py b/schemas/create/__init__.py new file mode 100644 index 000000000..8e546ddc2 --- /dev/null +++ b/schemas/create/__init__.py @@ -0,0 +1,17 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +# ============= EOF ============================================= diff --git a/schemas/create/asset.py b/schemas/create/asset.py new file mode 100644 index 000000000..934056a0c --- /dev/null +++ b/schemas/create/asset.py @@ -0,0 +1,21 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from typing import Any + +from pydantic import BaseModel + + +# ============= EOF ============================================= diff --git a/schemas/chemistry_create.py b/schemas/create/chemistry.py similarity index 100% rename from schemas/chemistry_create.py rename to schemas/create/chemistry.py diff --git a/schemas/collabnet.py b/schemas/create/collabnet.py similarity index 100% rename from schemas/collabnet.py rename to schemas/create/collabnet.py diff --git a/schemas/create/geochronology.py b/schemas/create/geochronology.py new file mode 100644 index 000000000..1c0095176 --- /dev/null +++ b/schemas/create/geochronology.py @@ -0,0 +1,30 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from pydantic import BaseModel + + +class CreateGeochronologyAge(BaseModel): + """ + Schema for creating a geochronology age entry. + """ + + location_id: int + age: float + age_error: float + method: str + + +# ============= EOF ============================================= diff --git a/schemas/geothermal.py b/schemas/create/geothermal.py similarity index 100% rename from schemas/geothermal.py rename to schemas/create/geothermal.py diff --git a/schemas/create/lexicon.py b/schemas/create/lexicon.py new file mode 100644 index 000000000..52ffc1162 --- /dev/null +++ b/schemas/create/lexicon.py @@ -0,0 +1,55 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from pydantic import BaseModel + + +class CreateLexiconTerm(BaseModel): + """ + Pydantic model for creating a lexicon term. + This model can be extended to include additional fields as needed. + """ + + term: str + definition: str + category: str | int | None = None + + +class CreateLexiconCategory(BaseModel): + """ + Pydantic model for creating a lexicon category. + This model can be extended to include additional fields as needed. + """ + + name: str + description: str | None = None + + +class CreateTriple(BaseModel): + """ + Pydantic model for creating a triple. + This model can be extended to include additional fields as needed. + """ + + subject: CreateLexiconTerm + predicate: str + object_: CreateLexiconTerm + + # class Config: + # allow_population_by_field_name = True + # fields = {"object_": "object"} + + +# ============= EOF ============================================= diff --git a/schemas/create/location.py b/schemas/create/location.py new file mode 100644 index 000000000..fcb43fe44 --- /dev/null +++ b/schemas/create/location.py @@ -0,0 +1,111 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from schemas import ORMBaseModel +from pydantic import field_validator +import re +import phonenumbers +from phonenumbers import NumberParseException +from email_validator import validate_email, EmailNotValidError + + +class CreateLocation(ORMBaseModel): + """ + Schema for creating a sample location. + """ + + name: str + description: str | None = None + point: str = "POINT(0 0)" # Default to a point at the origin + visible: bool = False + + +class CreateGroup(ORMBaseModel): + """ + Schema for creating a group. + """ + + name: str + + +class CreateGroupLocation(ORMBaseModel): + """ + Schema for creating a group location. + """ + + group_id: int + location_id: int + + +class CreateOwner(ORMBaseModel): + """ + Schema for creating an owner. + """ + + name: str + description: str | None = None + + +class CreateContact(ORMBaseModel): + """ + Schema for creating a contact. + """ + + owner_id: int + + name: str | None = None + description: str | None = None + email: str | None = None + phone: str | None = None + + @field_validator("phone", mode="before") + @classmethod + def validate_phone(cls, phone_number_str): + region = "US" + try: + parsed_number = phonenumbers.parse(phone_number_str, region) + if phonenumbers.is_valid_number(parsed_number): + # You can also format the number if needed + formatted_number = phonenumbers.format_number( + parsed_number, phonenumbers.PhoneNumberFormat.E164 + ) + return formatted_number + else: + raise ValueError(f"Invalid phone number. {phone_number_str}") + except NumberParseException as e: + raise ValueError(f"Invalid phone number. {phone_number_str}") + + @field_validator("email") + @classmethod + def validate_email(cls, email): + # try: + # Check that the email address is valid. Turn on check_deliverability + # for first-time validations like on account creation pages (but not + # login pages). + emailinfo = validate_email(email, check_deliverability=False) + + # After this point, use only the normalized form of the email address, + # especially before going to a database query. + email = emailinfo.normalized + return email + # except EmailNotValidError as e: + # if v is not None: + # # Basic email validation + # if not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", v): + # raise ValueError(f"Invalid email format. {v}") + # return v + + +# ============= EOF ============================================= diff --git a/schemas/create/publication.py b/schemas/create/publication.py new file mode 100644 index 000000000..5a369d2ac --- /dev/null +++ b/schemas/create/publication.py @@ -0,0 +1,33 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from pydantic import BaseModel + + +class CreatePublication(BaseModel): + """ + Schema for creating a new publication. + """ + + title: str + authors: list[str] + year: int + doi: str | None = None + url: str | None = None + + publication_type: str + + +# ============= EOF ============================================= diff --git a/schemas/timeseries.py b/schemas/create/timeseries.py similarity index 88% rename from schemas/timeseries.py rename to schemas/create/timeseries.py index 88178e411..3ffc86e9e 100644 --- a/schemas/timeseries.py +++ b/schemas/create/timeseries.py @@ -17,9 +17,8 @@ from pydantic import BaseModel -from schemas import ORMBaseModel - +# ============= EOF ============================================= class CreateWellTimeseries(BaseModel): name: str description: str | None = None @@ -30,12 +29,3 @@ class CreateGroundwaterLevelObservation(BaseModel): timestamp: datetime # ISO 8601 format value: float timeseries_id: int - - -class WellTimeseriesResponse(ORMBaseModel): - name: str - description: str | None = None - well_id: int - - -# ============= EOF ============================================= diff --git a/schemas/create/well.py b/schemas/create/well.py new file mode 100644 index 000000000..025cdf028 --- /dev/null +++ b/schemas/create/well.py @@ -0,0 +1,66 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from pydantic import model_validator +from schemas import ORMBaseModel + + +class CreateWell(ORMBaseModel): + """ + Schema for creating a well. + """ + + location_id: int + api_id: str | None = None + ose_pod_id: str | None = None + well_type: str | None = None + well_depth: float | None = None # in feet + construction_notes: str | None = None + + +class CreateWellScreen(ORMBaseModel): + """ + Schema for creating a well screen. + """ + + well_id: int + screen_depth_bottom: float + screen_depth_top: float + screen_type: str | None = None + + @model_validator(mode="after") + def validate_screen_type(self): + if self.screen_type is not None: + valid_screen_types = [ + "PVC", + ] # todo: get valid screen types from database + if self.screen_type not in valid_screen_types: + raise ValueError( + f"Invalid screen_type: {self.screen_type}. " + f"Valid options are: {', '.join(valid_screen_types)}." + ) + return self + + # validate that screen depth bottom is greater than top + @model_validator(mode="after") + def check_depths(self): + if self.screen_depth_bottom < self.screen_depth_top: + raise ValueError( + "screen_depth_bottom must be greater than screen_depth_top" + ) + return self + + +# ============= EOF ============================================= diff --git a/schemas/form.py b/schemas/form.py index a52f93feb..38b6c4ea9 100644 --- a/schemas/form.py +++ b/schemas/form.py @@ -18,7 +18,8 @@ from pydantic import BaseModel -from schemas.base_responses import SampleLocationResponse, OwnerResponse +from schemas.response.well import OwnerResponse +from schemas.response.location import SampleLocationResponse class WFLocation(BaseModel): diff --git a/schemas/response/__init__.py b/schemas/response/__init__.py new file mode 100644 index 000000000..8e546ddc2 --- /dev/null +++ b/schemas/response/__init__.py @@ -0,0 +1,17 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +# ============= EOF ============================================= diff --git a/schemas/response/chemistry.py b/schemas/response/chemistry.py new file mode 100644 index 000000000..84739b0e0 --- /dev/null +++ b/schemas/response/chemistry.py @@ -0,0 +1,33 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from schemas import ORMBaseModel + + +class WaterChemistryAnalysisSetResponse(ORMBaseModel): + well_id: int + laboratory: str + # collection_timestamp: str # ISO 8601 format + # analyses: list # List of WaterChemistryAnalysisResponse objects + # id: int # Unique identifier for the analysis set + + +class WaterChemistryAnalysisResponse(ORMBaseModel): + analysis_set_id: int + value: float + unit: str + + +# ============= EOF ============================================= diff --git a/schemas/lexicon.py b/schemas/response/lexicon.py similarity index 84% rename from schemas/lexicon.py rename to schemas/response/lexicon.py index d71311c7d..db7dee13e 100644 --- a/schemas/lexicon.py +++ b/schemas/response/lexicon.py @@ -13,14 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from pydantic import BaseModel - from schemas import ORMBaseModel -class CreateLexiconTerm(BaseModel): +class LexiconTermResponse(ORMBaseModel): """ - Pydantic model for creating a lexicon term. + Pydantic model for the response of a lexicon term. This model can be extended to include additional fields as needed. """ @@ -29,15 +27,16 @@ class CreateLexiconTerm(BaseModel): category: str | None = None -class LexiconTermResponse(ORMBaseModel): +class LexiconCategoryResponse(ORMBaseModel): """ - Pydantic model for the response of a lexicon term. + Pydantic model for the response of a lexicon category. This model can be extended to include additional fields as needed. """ - term: str - definition: str - category: str | None = None + id: int + name: str + description: str | None = None + # terms: list[LexiconTermResponse] | None = None # ============= EOF ============================================= diff --git a/routes/lexicon.py b/schemas/response/location.py similarity index 51% rename from routes/lexicon.py rename to schemas/response/location.py index e811d4bdf..6d905bb43 100644 --- a/routes/lexicon.py +++ b/schemas/response/location.py @@ -13,34 +13,40 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends -from fastapi import status -from models import get_db_session -from models.lexicon import Lexicon -from schemas.lexicon import CreateLexiconTerm, LexiconTermResponse - -router = APIRouter( - prefix="/lexicon", -) - - -@router.post( - "/add", - summary="Add term", - response_model=LexiconTermResponse, - status_code=status.HTTP_201_CREATED, -) -def add_term(term_data: CreateLexiconTerm, session=Depends(get_db_session)): +from geoalchemy2 import WKBElement +from geoalchemy2.shape import to_shape +from pydantic import field_validator +from schemas import ORMBaseModel + + +class SampleLocationResponse(ORMBaseModel): + """ + Response schema for sample location details. + """ + + id: int + name: str | None = None + description: str | None = None + point: str + + @field_validator("point", mode="before") + def point_to_wkt(cls, value): + if isinstance(value, WKBElement): + return to_shape(value).wkt + + # If the value is a string, assume it's already in WKT format + if isinstance(value, str): + return value + + +class GroupLocationResponse(ORMBaseModel): """ - Endpoint to add a term to the lexicon. + Response schema for group location details. """ - # Implementation for adding a term goes here - data = term_data.model_dump() - term = Lexicon(**data) - session.add(term) - session.commit() - return term + id: int + group_id: int + location_id: int # ============= EOF ============================================= diff --git a/models/lexicon.py b/schemas/response/publication.py similarity index 58% rename from models/lexicon.py rename to schemas/response/publication.py index 797de3889..fff906f6d 100644 --- a/models/lexicon.py +++ b/schemas/response/publication.py @@ -13,24 +13,32 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import String -from sqlalchemy.orm import mapped_column +from pydantic import BaseModel -from models import AutoBaseMixin, Base - -class Lexicon(Base, AutoBaseMixin): +class AuthorResponse(BaseModel): """ - Lexicon model for storing terms and their definitions. - This model can be extended to include additional fields as needed. + Schema for the response of an author. """ - term = mapped_column(String(100), unique=True, nullable=False) - definition = mapped_column(String(255), nullable=False) - category = mapped_column(String(255), nullable=True) + id: int + name: str + email: str | None = None + affiliation: str | None = None + + +class PublicationResponse(BaseModel): + """ + Schema for the response of a publication. + """ - def __repr__(self): - return f"" + id: int + title: str + authors: list[AuthorResponse] + year: int + doi: str | None = None + url: str | None = None + publication_type: str # ============= EOF ============================================= diff --git a/schemas/response/timeseries.py b/schemas/response/timeseries.py new file mode 100644 index 000000000..dd15213ae --- /dev/null +++ b/schemas/response/timeseries.py @@ -0,0 +1,23 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from schemas import ORMBaseModel + + +# ============= EOF ============================================= +class WellTimeseriesResponse(ORMBaseModel): + name: str + description: str | None = None + well_id: int diff --git a/schemas/response/well.py b/schemas/response/well.py new file mode 100644 index 000000000..8555e5188 --- /dev/null +++ b/schemas/response/well.py @@ -0,0 +1,89 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from typing import List + +from schemas import ORMBaseModel +from schemas.response.location import SampleLocationResponse + + +class WellResponse(ORMBaseModel): + """ + Response schema for well details. + """ + + id: int + location_id: int + api_id: str | None = None + ose_pod_id: str | None = None + usgs_id: str | None = None + construction_notes: str | None = None + + # Additional fields can be added as needed + + +class SampleLocationWellResponse(SampleLocationResponse): + """ + Response schema for sample location with well details. + """ + + well: List[WellResponse] = [] # List of wells associated with the sample location + + +class WellScreenResponse(ORMBaseModel): + """ + Response schema for well screen details. + """ + + id: int + well_id: int + screen_depth_bottom: float + screen_depth_top: float + + +class GroupResponse(ORMBaseModel): + """ + Response schema for group details. + """ + + id: int + name: str + description: str | None = None + + +class ContactResponse(ORMBaseModel): + """ + Response schema for contact details. + """ + + id: int + name: str + email: str | None = None + phone: str | None = None + + +class OwnerResponse(ORMBaseModel): + """ + Response schema for owner details. + """ + + id: int + name: str + contacts: List[ContactResponse] = [] # List of contacts associated with the owner + # email: str | None = None + # phone: str | None = None + + +# ============= EOF ============================================= diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 000000000..8e546ddc2 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,17 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +# ============= EOF ============================================= diff --git a/routes/geospatial_helper.py b/services/geospatial_helper.py similarity index 76% rename from routes/geospatial_helper.py rename to services/geospatial_helper.py index 5f176d10b..86a0c751d 100644 --- a/routes/geospatial_helper.py +++ b/services/geospatial_helper.py @@ -14,11 +14,16 @@ # limitations under the License. # =============================================================================== import shapefile + +import constants +from db.base import SampleLocation +from geoalchemy2.functions import ST_GeomFromText, ST_Within from geoalchemy2.shape import to_shape from shapely.wkt import loads as wkt_loads +from sqlalchemy import Select -def create_shapefile(locations, filename="locations.shp"): +def create_shapefile(locations: list, filename: str = "locations.shp"): # Create a point shapefile with shapefile.Writer(filename, shapeType=shapefile.POINT) as shp: shp.field("name", "C") @@ -32,4 +37,9 @@ def create_shapefile(locations, filename="locations.shp"): shp.record(loc.name) +def make_within_wkt(sql: Select, wkt: str) -> Select: + within = ST_GeomFromText(wkt, constants.SRID_WGS84) # Assuming WGS84 (SRID 4326) + return sql.where(ST_Within(SampleLocation.point, within)) + + # ============= EOF ============================================= diff --git a/services/lexicon.py b/services/lexicon.py new file mode 100644 index 000000000..52c828c75 --- /dev/null +++ b/services/lexicon.py @@ -0,0 +1,61 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db.lexicon import Category, Lexicon, TermCategoryAssociation +from sqlalchemy.orm import Session +from sqlalchemy import select + + +def add_lexicon_term(session: Session, term: str, definition: str, category: str | int): + """ + Add a term to the lexicon with its definition and category. + + """ + + if isinstance(category, str): + sql = select(Category).where(Category.name == category) + dbcategory = session.scalars(sql).one_or_none() + if dbcategory is None: + # Create a new category if it does not exist + dbcategory = Category(name=category) + session.add(dbcategory) + session.commit() + session.flush() + else: + dbcategory = session.get(Category, category) + + # Check if the term already exists + sql = select(Lexicon).where(Lexicon.term == term) + existing_term = session.scalars(sql).one_or_none() + if existing_term is not None: + return existing_term + + term = Lexicon(term=term, definition=definition) + session.add(term) + + if dbcategory is not None: + link = TermCategoryAssociation() + + link.category = dbcategory + link.term = term + + session.add(link) + + session.commit() + + return term + + +# ============= EOF ============================================= diff --git a/services/people_helper.py b/services/people_helper.py new file mode 100644 index 000000000..66bd22052 --- /dev/null +++ b/services/people_helper.py @@ -0,0 +1,58 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db.base import Contact, Owner, OwnerContactAssociation +from schemas.create.location import CreateContact +from sqlalchemy.orm import Session + + +def add_contact( + session: Session, + contact_data: CreateContact | dict, + owner: Owner = None, + owner_id: int = None, +): + """ + Add a new contact to the database. + """ + + if isinstance(contact_data, CreateContact): + contact_data = contact_data.model_dump() + + if owner is None: + if owner_id is None: + owner_id = contact_data.pop("owner_id") + + owner = session.get(Owner, owner_id) + + if owner is None: + raise ValueError(f"Owner with ID {owner_id} does not exist.") + + contact = Contact(**contact_data) + + session.add(contact) + session.commit() + session.refresh(contact) + + owner_contact_association = OwnerContactAssociation() + owner_contact_association.owner_id = owner.id + owner_contact_association.contact_id = contact.id + session.add(owner_contact_association) + session.commit() + + return contact + + +# ============= EOF ============================================= diff --git a/services/publication_helper.py b/services/publication_helper.py new file mode 100644 index 000000000..ec45a90b3 --- /dev/null +++ b/services/publication_helper.py @@ -0,0 +1,52 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db.publication import Author, Publication, AuthorPublicationAssociation +from schemas.create.publication import CreatePublication +from sqlalchemy.orm import Session +from sqlalchemy import select + + +def add_publication(session: Session, publication_data: CreatePublication): + + publication_data = publication_data.model_dump() + authors = publication_data.pop("authors", []) + + associations = [] + with session.no_autoflush: + for i, a in enumerate(authors): + if isinstance(a, str): + sql = select(Author).where(Author.name == a) + dbauthor = session.scalars(sql).first() + if dbauthor is None: + dbauthor = Author(name=a) + session.add(dbauthor) + + elif isinstance(a, int): + dbauthor = session.get(Author, a) + + assoc = AuthorPublicationAssociation(author=dbauthor, author_order=i) + associations.append(assoc) + # print('dbauthors', dbauthors) + + publication = Publication(**publication_data) + session.add(publication) + publication.author_associations = associations + + session.commit() + return publication + + +# ============= EOF ============================================= diff --git a/services/query_helper.py b/services/query_helper.py new file mode 100644 index 000000000..3a776eb64 --- /dev/null +++ b/services/query_helper.py @@ -0,0 +1,125 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy import select, Float, Integer +from db import search as search_func +from services.regex import QUERY_REGEX + + +def to_bool(value: str) -> bool | str: + """Convert a string to a boolean.""" + if isinstance(value, bool): + return value + if value.lower() in ("true", "1", "yes"): + return True + elif value.lower() in ("false", "0", "no"): + return False + + return value + + +def make_where(col, op: str, v: str): + + if op == "like": + return col.like(v) + elif op == "between": + return col.between(*map(float, v.strip("[]").split(","))) + else: + + def cast_value(col, val): + if isinstance(col.type, Float): + val = float(val) + elif isinstance(col.type, Integer): + val = int(val) + return val + + return getattr(col, f"__{op}__")(cast_value(col, v)) + + +def make_query(table, query: str): + # ensure the length of the query is reasonable + if len(query) > 1000: + raise ValueError("Query is too long") + + match = QUERY_REGEX.match(query) + column = match.group("field") + value = match.group("value") + operator = match.group("operator") + + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # Convert boolean strings to actual booleans + value = to_bool(value) + + if "." in column: + # Handle nested attributes + column_parts = column.split(".") + rel = getattr(table, column_parts[0]) + related_model = rel.property.mapper.class_ + related_column = getattr(related_model, column_parts[1]) + w = make_where(related_column, operator, value) + w = rel.any(w) + else: + column = getattr(table, column) + w = make_where(column, operator, value) + + return w + + +# ============= EOF ============================================= +def simple_get_by_name(session, table, name): + """ + Helper function to get a record by name from the database. + """ + sql = select(table).where(table.name == name) + result = session.execute(sql) + return result.scalar_one_or_none() + + +def simple_get_by_id(session, table, item_id): + """ + Helper function to get a record by ID from the database. + """ + sql = select(table).where(table.id == item_id) + result = session.execute(sql) + return result.scalar_one_or_none() + + +def simple_all_getter(session, table): + """ + Helper function to get records from the database. + """ + sql = select(table) + return session.scalars(sql).all() + # result = session.execute(sql) + # return result.scalars().all() + + +def searchable_getter(session, table, search, vector=None, joins=None): + if vector is None: + vector = getattr(table, "search_vector", None) + + q = select(table) + if joins: + for join in joins: + q = q.join(join) + + q = search_func( + q, + search, + vector=vector, + ) + return session.scalars(q).all() diff --git a/routes/regex.py b/services/regex.py similarity index 85% rename from routes/regex.py rename to services/regex.py index 54e85a974..27aa07c63 100644 --- a/routes/regex.py +++ b/services/regex.py @@ -23,13 +23,13 @@ """ import re - QUERY_REGEX = re.compile( - r"(?P[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)?)\s+" + r"(?P[a-zA-Z_]+(?:\.[a-zA-Z_]+)?)\s+" r"(?Peq|ne|gt|lt|ge|le|like|between)\s+" - r"(?P'[^']*'|" + r"(?P'[^']{0,256}'|" r"true|" - r"false|\d+(\.\d+)?|" - r"\[\s*\d+(\.\d+)?\s*,\s*\d+(\.\d+)?\s*\])" + r"false|" + r"\d{1,10}(\.\d{1,10})?|" + r"\[\s*\d{1,10}(\.\d{1,10})?\s*,\s*\d{1,10}(\.\d{1,10})?\s*\])" ) # ============= EOF ============================================= diff --git a/services/validation/__init__.py b/services/validation/__init__.py new file mode 100644 index 000000000..bfd613b8e --- /dev/null +++ b/services/validation/__init__.py @@ -0,0 +1,42 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy import select + +from db import get_db_session +from db.lexicon import Lexicon, Category, TermCategoryAssociation + + +def get_category(category: str) -> list: + """ + Fetches the categories from the database. + + Returns: + list: A list of categories. + """ + + session = next(get_db_session()) + + sql = select(Lexicon) + sql = sql.join(TermCategoryAssociation) + sql = sql.join(Category) + sql = sql.filter(Category.name == category) + + categories = [lex.term for lex in session.scalars(sql).all()] + + return categories + + +# ============= EOF ============================================= diff --git a/services/validation/chemistry.py b/services/validation/chemistry.py new file mode 100644 index 000000000..b242da9a9 --- /dev/null +++ b/services/validation/chemistry.py @@ -0,0 +1,35 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db import database_sessionmaker +from db.lexicon import Lexicon +from schemas.create.chemistry import CreateWaterChemistryAnalysis +from services.validation import get_category + + +async def validate_analyte(analysis_data: CreateWaterChemistryAnalysis): + # get valid analytes from the database + valid_analytes = get_category("water_chemistry") + + if analysis_data.analyte not in valid_analytes: + raise ValueError( + f"Invalid analyte: {analysis_data.analyte}. " + f"Valid options are: {', '.join(valid_analytes)}." + ) + + return analysis_data + + +# ============= EOF ============================================= diff --git a/services/validation/well.py b/services/validation/well.py new file mode 100644 index 000000000..f2fca6943 --- /dev/null +++ b/services/validation/well.py @@ -0,0 +1,46 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db import database_sessionmaker, get_db_session +from db.lexicon import Lexicon +from schemas.create.well import CreateWellScreen +from services.validation import get_category +from fastapi import Depends +from sqlalchemy.orm import Session + + +async def validate_screens(well_screen_data: CreateWellScreen): + """ + Validate well screen data before creating a new well screen. + This function can be extended to include more complex validation logic. + """ + # Here you can add any additional validation logic if needed + # session = database_sessionmaker() + # with session: + # get valid screen types from the database + valid_screen_types = get_category("casing_material") + if ( + well_screen_data.screen_type + and well_screen_data.screen_type not in valid_screen_types + ): + raise ValueError( + f"Invalid screen_type: {well_screen_data.screen_type}. " + f"Valid options are: {', '.join(valid_screen_types)}." + ) + + return well_screen_data + + +# ============= EOF ============================================= diff --git a/tests/__init__.py b/tests/__init__.py index 41132f53b..9d30ab00a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,10 +14,14 @@ # limitations under the License. # =============================================================================== from fastapi.testclient import TestClient +from sqlalchemy.orm import configure_mappers -from app import init_lexicon +from core.app import init_lexicon from main import app -from models import Base, engine +from db import engine +from db.base import Base + +configure_mappers() Base.metadata.drop_all(engine) Base.metadata.create_all(engine) @@ -25,4 +29,5 @@ init_lexicon() client = TestClient(app) + # ============= EOF ============================================= diff --git a/tests/data/riochama.png b/tests/data/riochama.png new file mode 100644 index 000000000..33a6846d4 Binary files /dev/null and b/tests/data/riochama.png differ diff --git a/tests/mvp.py b/tests/mvp.py new file mode 100644 index 000000000..7d2d06460 --- /dev/null +++ b/tests/mvp.py @@ -0,0 +1,148 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + + +def test_add_location_minimum(): + location = { + "name": "Test Location 1", + "point": "POINT(10.1 10.1)", + "visible": True, + } + + +def test_add_location_all(): + location = { + "name": "Test Location 1", + "point": "POINT(10.1 10.1)", + "description": "this is a test location", + "visible": True, + } + + +def test_add_well_minimum(): + well = { + "location_id": 1, + "well_type": "Monitoring", + } + + +def test_add_well_all(): + well = { + "location_id": 1, + "api_id": "1001-0001", + "ose_pod_id": "RA-0001", + "well_type": "Monitoring", + "well_depth": 100.0, + "hole_depth": 100.0, + "casing_diameter": 10.0, + "casing_depth": 20.0, + "casing_description": "foo bar", + "formation_zone": "San Andres", + "construction_notes": "this is a test of notes", + } + + +def test_add_well_screen_minimum(): + well_screen = { + "well_id": 1, + "screen_depth_top": 100.0, + "screen_depth_bottom": 120.0, + } + + +def test_add_well_screen_all(): + well_screen = { + "well_id": 1, + "screen_depth_top": 100.0, + "screen_depth_bottom": 120.0, + "screen_type": "PVC", + } + + +def test_add_owner_with_contacts(): + owner = { + "name": "The Doe's", + "contact": [ + {"name": "John Doe", "phone": "123-456-7890", "email": "foo@gmail.com"}, + { + "name": "Jane Doe", + "phone": "913-356-7890", + "email": "jane@gmail.com", + }, + ], + } + + +def test_add_owner_without_contacts(): + owner = {"name": "Alice Bob"} + + +def test_add_asset(): + asset = { + "filename": "foo.png", + "storage_service": "gcs", + "storage_path": "gs://...", + "mime_type": "image/png", + "size": 100, + } + + +# ============== optional ? ============= +def test_add_lexicon(): + formation = { + "term": "San Andres", + "definition": "Some sandstone unit", + "category": "Formations", + } + + unit = { + "term": "TDS", + "definition": "Total Dissolved Solids", + "category": "water_chemistry", + } + + +def test_add_lexicon_triple(): + subject = { + "term": "MG-030", + "definition": "magdalena well", + "category": "location_identifier", + } + predicate = "same_as" + object_ = { + "term": "USGS1234", + "definition": "magdalena well", + "category": "location_identifier", + } + + +def test_add_lexicon_triple_existing_subject(): + subject = "TDS" + predicate = "same_as" + object_ = { + "term": "Total Dissolved Solids", + "definition": "all the solids dissolved in sample", + "category": "water_chemistry", + } + + +def test_add_lexicon_triple_existing(): + subject = "TDS" + predicate = "same_as" + object_ = "Total Dissolved Solids" + + +# ============= EOF ============================================= diff --git a/tests/test_asset.py b/tests/test_asset.py new file mode 100644 index 000000000..ecc42d33a --- /dev/null +++ b/tests/test_asset.py @@ -0,0 +1,89 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +import shutil + +from api.asset import get_storage_bucket +from core.app import app +from tests import client +import pytest +import os +import glob + + +# @pytest.fixture(scope="module", autouse=True) +# def cleanup(): +# """ +# Fixture to clean up after tests. +# This can be used to delete any assets created during the tests. +# """ +# yield +# depot = DepotManager.get() +# for asset in depot.list(): +# depot.delete(asset) +class MockBlob: + def upload_from_file(self, *args, **kwargs): + pass + + def generate_signed_url(self, *args, **kwargs): + return "https://storage.googleapis.com/mock-bucket/mock-asset" + + +class MockStorageBucket: + def blob(self, *args, **kwargs): + return MockBlob() + + +def mock_storage_bucket(): + return MockStorageBucket() + + +app.dependency_overrides = { + get_storage_bucket: mock_storage_bucket, +} + + +def test_add_asset(): + path = "tests/data/riochama.png" + + with open(path, "rb") as file: + response = client.post( + "/asset", + files={"file": ("riochama.png", file, "image/png")}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["filename"] == "riochama.png" + url = data["url"] + assert url.startswith("https://storage.googleapis.com/") + + +def test_get_asset(): + response = client.get("/asset/1") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert data["filename"] == "riochama.png" + assert data["url"] == "https://storage.googleapis.com/mock-bucket/mock-asset" + + +def test_get_asset_not_found(): + response = client.get("/asset/9999") + assert response.status_code == 404 + assert response.json() == {"detail": "Asset not found"} + + +# ============= EOF ============================================= diff --git a/tests/test_base.py b/tests/test_base.py index 3ecaba09a..3ff283ba3 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -146,12 +146,23 @@ def test_add_group_location(): def test_add_owner(): + response = client.post("/base/owner", json={"name": "Test Owner"}) assert response.status_code == 201 data = response.json() assert "id" in data assert data["name"] == "Test Owner" + for i in range(1, 4): + response = client.post( + "/base/owner", + json={"name": f"Test Owner {i}"}, + ) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert data["name"] == f"Test Owner {i}" + def test_add_contact(): response = client.post( @@ -159,15 +170,32 @@ def test_add_contact(): json={ "owner_id": 1, "name": "Test Contact", - "email": "fasdfasdf", - "phone": "999-999-9999", + "email": "fasdfasdf@gmail.com", + "phone": "+12345678901", }, ) assert response.status_code == 201 data = response.json() assert "id" in data assert data["name"] == "Test Contact" - assert data["email"] == "fasdfasdf" + assert data["email"] == "fasdfasdf@gmail.com" + + for i in range(2, 5): + response = client.post( + "/base/contact", + json={ + "owner_id": i, + "name": f"Test Contact {i}", + "email": f"foo{i}@gmail.com", + "phone": f"+1234567890{i}", + }, + ) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert data["name"] == f"Test Contact {i}" + assert data["email"] == f"foo{i}@gmail.com" + assert data["phone"] == f"+1234567890{i}" # GET tests ====================================================== @@ -340,5 +368,5 @@ def test_item_get_contact(): data = response.json() assert data["id"] == 1 assert data["name"] == "Test Contact" - assert data["email"] == "fasdfasdf" - assert data["phone"] == "999-999-9999" + assert data["email"] == "fasdfasdf@gmail.com" + assert data["phone"] == "+12345678901" diff --git a/tests/test_chemistry.py b/tests/test_chemistry.py index 70719b7d6..3b5be253e 100644 --- a/tests/test_chemistry.py +++ b/tests/test_chemistry.py @@ -16,6 +16,7 @@ from tests import client +# add =================== def test_add_analysis_set(): response = client.post( "/chemistry/analysis_set", @@ -31,6 +32,20 @@ def test_add_analysis_set(): assert data["well_id"] == 1 assert data["laboratory"] == "Test Lab" + response = client.post( + "/chemistry/analysis_set", + json={ + "well_id": 2, + "laboratory": "Test Lab1", + "collection_timestamp": "2025-01-01T12:00:00", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["well_id"] == 2 + assert data["laboratory"] == "Test Lab1" + def test_add_analysis(): @@ -50,5 +65,89 @@ def test_add_analysis(): assert data["unit"] == "mg/L" assert data["analyte"] == "TDS" + response = client.post( + "/chemistry/analysis", + json={ + "analysis_set_id": 2, + "value": 8.0, + "unit": "mg/L", + "analyte": "Na", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["analysis_set_id"] == 2 + assert data["value"] == 8.0 + assert data["unit"] == "mg/L" + assert data["analyte"] == "Na" + + +# get =================== +def test_get_chemistry_analysis_set(): + response = client.get("/chemistry/analysis_set") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 2 + item = data["items"][0] + assert item["well_id"] == 1 + assert item["laboratory"] == "Test Lab" + + +def test_get_chemistry_analysis(): + response = client.get("/chemistry/analysis") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 2 + item = data["items"][0] + assert item["analysis_set_id"] == 1 + assert item["value"] == 7.0 + assert item["unit"] == "mg/L" + + +def test_geospatial_chemistry_analysis_set(): + response = client.get( + "/chemistry/analysis_set", + params={ + "within": "POLYGON((10.0 10.0, 20.0 10.0, 20.0 20.0, 10.0 20.0, 10.0 10.0))" + }, + ) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 + + +def test_geospatial_chemistry_analysis(): + response = client.get( + "/chemistry/analysis", + params={ + "within": "POLYGON((10.0 10.0, 20.0 10.0, 20.0 20.0, 10.0 20.0, 10.0 10.0))" + }, + ) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 + + +def test_query_chemistry_analysis_set(): + response = client.get( + "/chemistry/analysis_set", params={"query": "laboratory eq 'Test Lab1'"} + ) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 # Assuming both sets match the query + + +def test_query_chemistry_analysis(): + response = client.get("/chemistry/analysis", params={"query": "analyte eq 'Na'"}) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 # Assuming both analyses match the query + # ============= EOF ============================================= diff --git a/tests/test_collabnet.py b/tests/test_collabnet.py index 36c837f0d..5b6b863b0 100644 --- a/tests/test_collabnet.py +++ b/tests/test_collabnet.py @@ -17,10 +17,10 @@ import pytest -from models import get_db_session, sqlalchemy_sessionmaker -from models.base import SampleLocation, Well -from models.collabnet import CollaborativeNetworkWell -from models.timeseries import WellTimeseries, GroundwaterLevelObservation +from db import get_db_session, database_sessionmaker +from db.base import SampleLocation, Well +from db.collabnet import CollaborativeNetworkWell +from db.timeseries import WellTimeseries, GroundwaterLevelObservation from tests import client @@ -47,7 +47,7 @@ def test_collabnet_wells(): @pytest.fixture(scope="function") def add_timeseries_data(): """Fixture to add timeseries data for testing.""" - session = sqlalchemy_sessionmaker() + session = next(get_db_session()) wts = WellTimeseries(well_id=2) session.add(wts) for i in range(10): diff --git a/tests/test_form.py b/tests/test_form.py index d4760fbf7..69d2fac53 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -34,7 +34,7 @@ def test_well_form(): } ], "owner": { - "name": "Test Owner", + "name": "The Doe's", "contact": [ {"name": "John Doe", "phone": "123-456-7890", "email": "foo@gmail.com"}, { @@ -55,16 +55,17 @@ def test_well_form(): owner = data.get("owner", None) assert owner is not None - assert owner.get("name") == "Test Owner" + assert owner.get("name") == "The Doe's" contacts = owner.get("contacts", []) - assert len(contacts) == 3 - assert contacts[0].get("name") == "Test Contact" - assert contacts[0].get("phone") == "999-999-9999" + assert len(contacts) == 2 + + assert contacts[0].get("name") == "John Doe" + assert contacts[0].get("phone") == "123-456-7890" # Ensure the second contact is also correct - assert contacts[2].get("name") == "Jane Doe" - assert contacts[2].get("phone") == "913-356-7890" + assert contacts[1].get("name") == "Jane Doe" + assert contacts[1].get("phone") == "913-356-7890" # ============= EOF ============================================= diff --git a/tests/test_geochronology.py b/tests/test_geochronology.py new file mode 100644 index 000000000..241d96629 --- /dev/null +++ b/tests/test_geochronology.py @@ -0,0 +1,29 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +from tests import client + + +def test_add_geochronology_age(): + response = client.post( + "/geochronology/age", + json={"location_id": 1, "age": 100.0, "age_error": 5.0, "method": "U/Pb"}, + ) + + assert response.status_code == 201 + + +# ============= EOF ============================================= diff --git a/tests/test_lexicon.py b/tests/test_lexicon.py index 7dec2c8f9..c4e3503dc 100644 --- a/tests/test_lexicon.py +++ b/tests/test_lexicon.py @@ -13,9 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from services.validation import get_category +from tests import client -from tests import client +def test_add_lexicon_category(): + name = "Test Category" + description = "This is a test category." + + response = client.post( + "/lexicon/category/add", + json={"name": name, "description": description}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == name + assert data["description"] == description def test_add_lexicon_term(): @@ -32,7 +46,40 @@ def test_add_lexicon_term(): data = response.json() assert data["term"] == term assert data["definition"] == definition - assert data["category"] == category + + +def test_get_category(): + items = get_category("casing_material") + assert isinstance(items, list) + + +def test_add_triple(): + subject = { + "term": "MG-030", + "definition": "magdalena well", + "category": "location_identifier", + } + predicate = "same_as" + object_ = { + "term": "USGS1234", + "definition": "magdalena well", + "category": "location_identifier", + } + + response = client.post( + "/lexicon/triple/add", + json={ + "subject": subject, + "predicate": predicate, + "object_": object_, + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["subject"] == subject["term"] + assert data["predicate"] == predicate + assert data["object_"] == object_["term"] # ============= EOF ============================================= diff --git a/tests/test_people.py b/tests/test_people.py new file mode 100644 index 000000000..c58a7ef72 --- /dev/null +++ b/tests/test_people.py @@ -0,0 +1,78 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from tests import client + + +def test_phone_validation_fail(): + for phone in [ + "definitely not a phone", + # "1234567890", + # "123-456-7890", + # "123-456-78901", + # "123-4567-890", + "123-456-789a", + "123-456-7890x1234", + "123.456.7890", + "(123) 456-7890", + ]: + + response = client.post( + "/base/contact", + json={ + "owner_id": 2, + "name": "Test Contact 2", + "email": "fasdfasdf@gmail.com", + "phone": phone, + }, + ) + assert response.status_code == 422 + + +def test_email_validation_fail(): + + for email in [ + "", + "invalid-email", + "invalid@domain", + "invalid@domain.", + "@domain.com", + ]: + response = client.post( + "/base/contact", + json={ + "owner_id": 1, + "name": "Test Contact2", + "email": email, + "phone": "+12345678901", + }, + ) + assert response.status_code == 422, f"Failed for email: {email}" + + +def test_phone_validation_success(): + response = client.post( + "/base/contact", + json={ + "owner_id": 2, + "name": "Contact X", + "email": "foobar@gmail.com", + "phone": "+12345678901", + }, + ) + assert response.status_code == 201 + + +# ============= EOF ============================================= diff --git a/tests/test_publication.py b/tests/test_publication.py new file mode 100644 index 000000000..5457bd9b2 --- /dev/null +++ b/tests/test_publication.py @@ -0,0 +1,61 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from tests import client + + +def test_add_publication(): + response = client.post( + "/publication/add", + json={ + "title": "Test Publication", + "authors": ["Author One", "Author Two"], + "year": 2025, + "doi": "10.1000/testdoi", + "url": "http://example.com/test-publication", + "publication_type": "Thesis", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Test Publication" + assert data["year"] == 2025 + assert data["doi"] == "10.1000/testdoi" + assert data["url"] == "http://example.com/test-publication" + assert data["publication_type"] == "Thesis" + # Ensure that the authors are correctly formatted + assert isinstance(data["authors"], list) + assert len(data["authors"]) == 2 + assert data["authors"][0]["name"] == "Author One" + assert data["authors"][1]["name"] == "Author Two" + + +def test_get_authors_publications(): + response = client.get("/author/1/publications") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + pub = data[0] + assert pub["title"] == "Test Publication" + + # # Check if the first author has a name field + # if data: + # assert "name" in data[0] + # assert isinstance(data[0]["name"], str) + + +# ============= EOF ============================================= diff --git a/tests/test_regex.py b/tests/test_regex.py index 32030038e..2b7498e7a 100644 --- a/tests/test_regex.py +++ b/tests/test_regex.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from routes.regex import QUERY_REGEX +from services.regex import QUERY_REGEX def test_query_regex_eq(): diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 000000000..d96f5c1a3 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,137 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy import select, func, desc, cast, Text +from sqlalchemy.dialects.postgresql import REGCONFIG + + +from db import database_sessionmaker, get_db_session, search +from db.base import Owner, Contact, OwnerContactAssociation + + +from tests import client + + +def test_search_query(): + session = next(get_db_session()) + + query = search(select(Owner), "Test") + owner = session.scalars(query).first() + assert owner is not None + session.close() + + +# +def test_search_query_no_results(): + session = next(get_db_session()) + + query = search(select(Owner), "NonExistentOwner") + owner = session.scalars(query).first() + assert owner is None + session.close() + + +def test_search_owner_by_contact_name(): + session = next(get_db_session()) + + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "Test Contact", + vector=vector, + ) + owner = session.scalars(query).first() + assert owner is not None + session.close() + + +def test_search_owner_by_contact_name_no_results(): + session = next(get_db_session()) + + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "NonExistentContact", + vector=vector, + ) + owner = session.scalars(query).first() + assert owner is None + session.close() + + +def test_search_owner_by_contact_phonenumber(): + session = next(get_db_session()) + + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "+12345678901", + vector=vector, + ) + contact = session.scalars(query).first() + assert contact is not None + session.close() + + +def test_search_owner_by_contact_phonenumber_no_results(): + session = next(get_db_session()) + + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "NonExistentPhoneNumber", + vector=vector, + ) + contact = session.scalars(query).first() + assert contact is None + session.close() + + +def test_search_owner_by_phonelike(): + session = next(get_db_session()) + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "+12%", + vector=vector, + ) + contact = session.scalars(query).first() + assert contact is not None + session.close() + + +def test_search_owner_by_phonelike_no_results(): + session = next(get_db_session()) + vector = Contact.search_vector + query = search( + select(Owner).join(OwnerContactAssociation).join(Contact), + "NonExistentPhone%", + vector=vector, + ) + contact = session.scalars(query).first() + assert contact is None + session.close() + + +# API =========================================================== +def test_search_owner_by_contact_name_api(): + response = client.get("/base/owner", params={"search": '"Contact X"'}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Test Owner 1" + + +# ============= EOF ============================================= diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 9b9cd2918..a2967d752 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -15,6 +15,11 @@ # =============================================================================== import datetime +from sqlalchemy import func, select, cast, Interval +from sqlalchemy_utils.types.range import intervals + +from db import get_db_session +from db.timeseries import GroundwaterLevelObservation from tests import client @@ -38,17 +43,66 @@ def test_add_well_observations(): "timestamp": datetime.datetime.now().isoformat(), "value": 10.5, "description": "Test observation for well.", - "timeseries_id": 1, + "timeseries_id": 2, }, { "timestamp": datetime.datetime.now().isoformat(), "value": 11.5, "description": "Test observation for well.", - "timeseries_id": 1, + "timeseries_id": 2, }, ], ) assert response.status_code == 201 +def test_timescale_db(): + + session = next(get_db_session()) + sql = func.first( + GroundwaterLevelObservation.value, GroundwaterLevelObservation.timestamp + ) + result = session.execute(sql).scalar() + assert result is not None, "Expected a result from the timescale DB query" + assert result == 10.5, "Expected 2 values in the result" + + +# +# +def test_timescale_db_histogram(): + session = next(get_db_session()) + sql = func.histogram(GroundwaterLevelObservation.value, 0, 100, 10) + result = session.execute(sql).scalar() + assert result is not None, "Expected a result from the timescale DB histogram query" + assert len(result) == 12, "Expected 11 buckets in the histogram" + + +def test_timescale_db_time_bucket(): + session = next(get_db_session()) + + sql = ( + select( + func.time_bucket( + cast("1 hour", Interval), GroundwaterLevelObservation.timestamp + ).label("bucket"), + func.avg(GroundwaterLevelObservation.value).label("avg_value"), + ) + .group_by("bucket") + .order_by("bucket") + ) + + results = session.execute(sql).all() + assert ( + results is not None + ), "Expected a result from the timescale DB time bucket query" + assert len(results) > 0, "Expected at least one result from the time bucket query" + + assert isinstance( + results[0][0], datetime.datetime + ), "Expected a datetime result from the time bucket query" + assert isinstance( + results[0][1], float + ), "Expected a datetime result from the time bucket query" + + # ============= EOF ============================================= diff --git a/uv.lock b/uv.lock index 1cb9249c4..66eab2e40 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,70 @@ version = 1 revision = 2 requires-python = ">=3.13" +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload-time = "2025-06-14T15:15:41.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/0f/db19abdf2d86aa1deec3c1e0e5ea46a587b97c07a16516b6438428b3a3f8/aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938", size = 694910, upload-time = "2025-06-14T15:14:30.604Z" }, + { url = "https://files.pythonhosted.org/packages/d5/81/0ab551e1b5d7f1339e2d6eb482456ccbe9025605b28eed2b1c0203aaaade/aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace", size = 472566, upload-time = "2025-06-14T15:14:32.275Z" }, + { url = "https://files.pythonhosted.org/packages/34/3f/6b7d336663337672d29b1f82d1f252ec1a040fe2d548f709d3f90fa2218a/aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb", size = 464856, upload-time = "2025-06-14T15:14:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/26/7f/32ca0f170496aa2ab9b812630fac0c2372c531b797e1deb3deb4cea904bd/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7", size = 1703683, upload-time = "2025-06-14T15:14:36.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/d5513624b33a811c0abea8461e30a732294112318276ce3dbf047dbd9d8b/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b", size = 1684946, upload-time = "2025-06-14T15:14:38Z" }, + { url = "https://files.pythonhosted.org/packages/37/72/4c237dd127827b0247dc138d3ebd49c2ded6114c6991bbe969058575f25f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177", size = 1737017, upload-time = "2025-06-14T15:14:39.951Z" }, + { url = "https://files.pythonhosted.org/packages/0d/67/8a7eb3afa01e9d0acc26e1ef847c1a9111f8b42b82955fcd9faeb84edeb4/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef", size = 1786390, upload-time = "2025-06-14T15:14:42.151Z" }, + { url = "https://files.pythonhosted.org/packages/48/19/0377df97dd0176ad23cd8cad4fd4232cfeadcec6c1b7f036315305c98e3f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103", size = 1708719, upload-time = "2025-06-14T15:14:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/61/97/ade1982a5c642b45f3622255173e40c3eed289c169f89d00eeac29a89906/aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da", size = 1622424, upload-time = "2025-06-14T15:14:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/00ad3eea004e1d07ccc406e44cfe2b8da5acb72f8c66aeeb11a096798868/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d", size = 1675447, upload-time = "2025-06-14T15:14:47.911Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fe/74e5ce8b2ccaba445fe0087abc201bfd7259431d92ae608f684fcac5d143/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041", size = 1707110, upload-time = "2025-06-14T15:14:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c4/39af17807f694f7a267bd8ab1fbacf16ad66740862192a6c8abac2bff813/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1", size = 1649706, upload-time = "2025-06-14T15:14:52.378Z" }, + { url = "https://files.pythonhosted.org/packages/38/e8/f5a0a5f44f19f171d8477059aa5f28a158d7d57fe1a46c553e231f698435/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1", size = 1725839, upload-time = "2025-06-14T15:14:54.617Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/81acc594c7f529ef4419d3866913f628cd4fa9cab17f7bf410a5c3c04c53/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911", size = 1759311, upload-time = "2025-06-14T15:14:56.597Z" }, + { url = "https://files.pythonhosted.org/packages/38/0d/aabe636bd25c6ab7b18825e5a97d40024da75152bec39aa6ac8b7a677630/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3", size = 1708202, upload-time = "2025-06-14T15:14:58.598Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/561ef2d8a223261683fb95a6283ad0d36cb66c87503f3a7dde7afe208bb2/aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd", size = 420794, upload-time = "2025-06-14T15:15:00.939Z" }, + { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload-time = "2025-06-14T15:15:02.858Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, +] + [[package]] name = "aiosqlite" version = "0.21.0" @@ -16,16 +80,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.16.1" +version = "1.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/89/bfb4fe86e3fc3972d35431af7bedbc60fa606e8b17196704a1747f7aa4c3/alembic-1.16.1.tar.gz", hash = "sha256:43d37ba24b3d17bc1eb1024fe0f51cd1dc95aeb5464594a02c6bb9ca9864bfa4", size = 1955006, upload-time = "2025-05-21T23:11:05.991Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563, upload-time = "2025-06-16T18:05:08.566Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/59/565286efff3692c5716c212202af61466480f6357c4ae3089d4453bff1f3/alembic-1.16.1-py3-none-any.whl", hash = "sha256:0cdd48acada30d93aa1035767d67dff25702f8de74d7c3919f2e8492c8db2e67", size = 242488, upload-time = "2025-05-21T23:11:07.783Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717, upload-time = "2025-06-16T18:05:10.27Z" }, ] [[package]] @@ -59,6 +123,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -109,13 +207,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] [[package]] @@ -130,6 +281,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "cloud-sql-python-connector" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "dnspython" }, + { name = "google-auth" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/99/4380be2f05bf2eef47120294b1809046d35d97e5f6bbbb1beb8af67e4cef/cloud_sql_python_connector-1.18.2.tar.gz", hash = "sha256:c3c51e6cba6e25834f8858588020bf8972dd3e44a01cfe320959bd97c40f6268", size = 42636, upload-time = "2025-06-10T14:32:28.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/cc/630a951a7bd06b9cea2134b7d96c29da66ff3ba38ecaf414d3614032a552/cloud_sql_python_connector-1.18.2-py3-none-any.whl", hash = "sha256:9b96f4d6484eb35317e09e9ac3678a67343fb8b13f00152378b1445bf6a46fdd", size = 49255, upload-time = "2025-05-20T20:44:44.96Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -139,6 +307,63 @@ 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 = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + [[package]] name = "fastadmin" version = "0.2.22" @@ -154,30 +379,73 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.12" +version = "0.115.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, ] [[package]] name = "fastapi-pagination" -version = "0.13.2" +version = "0.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/25/fc3c9ed9d99df279acdbf57da1a3a91ffceae9c087882cbe8a9cb1229b1e/fastapi_pagination-0.13.2.tar.gz", hash = "sha256:5e76f129aef706601b86114428ca3ff68715bfa3929bf4df8c7ed27561d7f661", size = 550389, upload-time = "2025-06-07T09:30:44.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/4c/e98a1665b6ac2e9e4ed98450e4e0ea48108f3bc52de517d9a70cc22761c2/fastapi_pagination-0.13.3.tar.gz", hash = "sha256:40c2383aff13a3a0e4a2742dfbf004572e88458cd8f338d85f90a27e07abab4a", size = 550898, upload-time = "2025-06-25T21:22:15.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/73/ef1ab892c2d189d8b6bd72325e9e710df6737c3b7976e12aa5749a56ea01/fastapi_pagination-0.13.3-py3-none-any.whl", hash = "sha256:e1b1cc7fa5c773c61087845ef8a73ed6b516071c057418698b9242461573f44e", size = 50986, upload-time = "2025-06-25T21:22:13.591Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/cb/cf2f10d4620b31a77705226c7292f39b4a191cef3485ea42561fc2e157d9/fastapi_pagination-0.13.2-py3-none-any.whl", hash = "sha256:d2ec66ffda5cd9c1d665521f3916b16ebbb15d5010a945449292540ef70c4d9a", size = 50404, upload-time = "2025-06-07T09:30:42.218Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] [[package]] @@ -193,29 +461,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/82/e83e0f74cba8bbff9c12b2dea4693adb0cf89204012da48433ebd8e8c4d8/GeoAlchemy2-0.17.1-py3-none-any.whl", hash = "sha256:29f41b67d3a52df47821b695d31dec8600747c6ef4de62ee69811bde481dd2ae", size = 77791, upload-time = "2025-02-17T09:41:02.7Z" }, ] +[[package]] +name = "google-api-core" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/b8/2b53838d2acd6ec6168fd284a990c76695e84c65deee79c9f3a4276f6b4f/google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53", size = 35861, upload-time = "2025-03-10T21:05:38.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e", size = 29348, upload-time = "2025-03-10T21:05:37.785Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/84/6afc2ffdf31f6247a6bab6ba070e073fb05e0fda56adf59ce52ac591a033/google_cloud_storage-3.1.1.tar.gz", hash = "sha256:f9c8f965cafd1d38509f8e2b070339e0e9e5bf050774653bf36213d4ea6104c0", size = 7668109, upload-time = "2025-06-18T11:06:52.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/4f/b922e919f6e1ea5905f1427fadf1a3f56a85e79e2b0037fec182f6b437dd/google_cloud_storage-3.1.1-py3-none-any.whl", hash = "sha256:ba7e6ae2be5a7a08742f001e23ec6a0c17d78c620f63bf8e0e7c2cbdddb407de", size = 175464, upload-time = "2025-06-18T11:06:51.043Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ae/87802e6d9f9d69adfaedfcfd599266bf386a54d0be058b532d04c794f76d/google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472", size = 14495, upload-time = "2025-03-26T14:29:13.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/72/b8d785e9184ba6297a8620c8a37cf6e39b81a8ca01bb0796d7cbb28b3386/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35", size = 30467, upload-time = "2025-03-26T14:36:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/5f18076968212067c4e8ea95bf3b69669f9fc698476e5f5eb97d5b37999f/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638", size = 30309, upload-time = "2025-03-26T15:06:15.318Z" }, + { url = "https://files.pythonhosted.org/packages/92/83/9228fe65bf70e93e419f38bdf6c5ca5083fc6d32886ee79b450ceefd1dbd/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb", size = 33133, upload-time = "2025-03-26T14:41:34.388Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ca/1ea2fd13ff9f8955b85e7956872fdb7050c4ace8a2306a6d177edb9cf7fe/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6", size = 32773, upload-time = "2025-03-26T14:41:35.19Z" }, + { url = "https://files.pythonhosted.org/packages/89/32/a22a281806e3ef21b72db16f948cad22ec68e4bdd384139291e00ff82fe2/google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db", size = 33475, upload-time = "2025-03-26T14:29:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/002975aff514e57fc084ba155697a049b3f9b52225ec3bc0f542871dd524/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3", size = 33243, upload-time = "2025-03-26T14:41:35.975Z" }, + { url = "https://files.pythonhosted.org/packages/61/cb/c585282a03a0cea70fcaa1bf55d5d702d0f2351094d663ec3be1c6c67c52/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9", size = 32870, upload-time = "2025-03-26T14:41:37.08Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099, upload-time = "2024-08-07T22:20:38.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251, upload-time = "2024-08-07T22:20:36.409Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "greenlet" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/c1/a82edae11d46c0d83481aacaa1e578fea21d94a1ef400afd734d47ad95ad/greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485", size = 185797, upload-time = "2025-05-09T19:47:35.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150, upload-time = "2025-05-09T14:50:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381, upload-time = "2025-05-09T15:24:12.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427, upload-time = "2025-05-09T15:24:51.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/49/6d79f58fa695b618654adac64e56aff2eeb13344dc28259af8f505662bb1/greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5", size = 645795, upload-time = "2025-05-09T15:29:26.673Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398, upload-time = "2025-05-09T14:53:36.61Z" }, - { url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795, upload-time = "2025-05-09T14:53:47.039Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976, upload-time = "2025-05-09T15:27:06.542Z" }, - { url = "https://files.pythonhosted.org/packages/c3/30/d0e88c1cfcc1b3331d63c2b54a0a3a4a950ef202fb8b92e772ca714a9221/greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708", size = 1145509, upload-time = "2025-05-09T14:54:02.223Z" }, - { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023, upload-time = "2025-05-09T14:53:24.157Z" }, - { url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911, upload-time = "2025-05-09T15:24:22.376Z" }, - { url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251, upload-time = "2025-05-09T15:24:52.205Z" }, - { url = "https://files.pythonhosted.org/packages/37/26/7db30868f73e86b9125264d2959acabea132b444b88185ba5c462cb8e571/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763", size = 632620, upload-time = "2025-05-09T15:29:28.051Z" }, - { url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851, upload-time = "2025-05-09T14:53:38.472Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718, upload-time = "2025-05-09T14:53:48.313Z" }, - { url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752, upload-time = "2025-05-09T15:27:08.217Z" }, - { url = "https://files.pythonhosted.org/packages/68/3b/3b97f9d33c1f2eb081759da62bd6162159db260f602f048bc2f36b4c453e/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51", size = 1125170, upload-time = "2025-05-09T14:54:04.082Z" }, - { url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899, upload-time = "2025-05-09T14:54:01.581Z" }, +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] [[package]] @@ -313,6 +691,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "multidict" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" }, + { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" }, + { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/5e/325b11f2222a549019cf2ef879c1f81f94a0d40ace3ef55cf529915ba6cc/multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e", size = 41683, upload-time = "2025-06-30T15:52:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/cf46e73f5d6e3c775cabd2a05976547f3f18b39bee06260369a42501f053/multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9", size = 45811, upload-time = "2025-06-30T15:52:40.207Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c9/2e3fe950db28fb7c62e1a5f46e1e38759b072e2089209bc033c2798bb5ec/multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600", size = 43056, upload-time = "2025-06-30T15:52:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" }, + { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" }, + { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" }, + { url = "https://files.pythonhosted.org/packages/22/d6/fdb3d0670819f2228f3f7d9af613d5e652c15d170c83e5f1c94fbc55a25b/multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e", size = 47812, upload-time = "2025-06-30T15:53:09.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d6/a9d2c808f2c489ad199723197419207ecbfbc1776f6e155e1ecea9c883aa/multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d", size = 53011, upload-time = "2025-06-30T15:53:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/b68001cba8188dd267590a111f9661b6256debc327137667e832bf5d66e8/multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb", size = 45254, upload-time = "2025-06-30T15:53:12.421Z" }, + { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, +] + [[package]] name = "nmsamplelocations" version = "0.1.0" @@ -320,15 +743,26 @@ source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, + { name = "asyncpg" }, { name = "bcrypt" }, + { name = "cloud-sql-python-connector" }, + { name = "email-validator" }, { name = "fastadmin" }, { name = "fastapi" }, { name = "fastapi-pagination" }, { name = "geoalchemy2" }, + { name = "google-cloud-storage" }, { name = "greenlet" }, + { name = "gunicorn" }, { name = "httpx" }, + { name = "pg8000" }, + { name = "phonenumbers" }, + { name = "pillow" }, + { name = "psycopg2" }, { name = "pyshp" }, + { name = "python-multipart" }, { name = "shapely" }, + { name = "sqlalchemy-searchable" }, { name = "uvicorn" }, ] @@ -341,15 +775,26 @@ dev = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.16.1" }, + { name = "asyncpg", specifier = ">=0.30.0" }, { name = "bcrypt", specifier = ">=4.3.0" }, + { name = "cloud-sql-python-connector", specifier = ">=1.18.2" }, + { name = "email-validator", specifier = ">=2.2.0" }, { name = "fastadmin", specifier = ">=0.2.22" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi-pagination", specifier = ">=0.13.2" }, { name = "geoalchemy2", specifier = ">=0.17.1" }, + { name = "google-cloud-storage", specifier = ">=3.1.1" }, { name = "greenlet", specifier = ">=3.2.2" }, + { name = "gunicorn", specifier = ">=23.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pg8000", specifier = ">=1.31.2" }, + { name = "phonenumbers", specifier = ">=9.0.7" }, + { name = "pillow", specifier = ">=11.2.1" }, + { name = "psycopg2", specifier = ">=2.9.10" }, { name = "pyshp", specifier = ">=2.3.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "shapely", specifier = ">=2.1.1" }, + { name = "sqlalchemy-searchable", specifier = ">=2.1.0" }, { name = "uvicorn", specifier = ">=0.34.3" }, ] @@ -358,32 +803,32 @@ dev = [{ name = "pytest", specifier = ">=8.4.0" }] [[package]] name = "numpy" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/db/8e12381333aea300890829a0a36bfa738cac95475d88982d538725143fd9/numpy-2.3.0.tar.gz", hash = "sha256:581f87f9e9e9db2cba2141400e160e9dd644ee248788d6f90636eeb8fd9260a6", size = 20382813, upload-time = "2025-06-07T14:54:32.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/fc/1d67f751fd4dbafc5780244fe699bc4084268bad44b7c5deb0492473127b/numpy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5754ab5595bfa2c2387d241296e0381c21f44a4b90a776c3c1d39eede13a746a", size = 20889633, upload-time = "2025-06-07T14:44:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/73ffdb69e5c3f19ec4530f8924c4386e7ba097efc94b9c0aff607178ad94/numpy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d11fa02f77752d8099573d64e5fe33de3229b6632036ec08f7080f46b6649959", size = 14151683, upload-time = "2025-06-07T14:44:28.847Z" }, - { url = "https://files.pythonhosted.org/packages/64/d5/06d4bb31bb65a1d9c419eb5676173a2f90fd8da3c59f816cc54c640ce265/numpy-2.3.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:aba48d17e87688a765ab1cd557882052f238e2f36545dfa8e29e6a91aef77afe", size = 5102683, upload-time = "2025-06-07T14:44:38.417Z" }, - { url = "https://files.pythonhosted.org/packages/12/8b/6c2cef44f8ccdc231f6b56013dff1d71138c48124334aded36b1a1b30c5a/numpy-2.3.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4dc58865623023b63b10d52f18abaac3729346a7a46a778381e0e3af4b7f3beb", size = 6640253, upload-time = "2025-06-07T14:44:49.359Z" }, - { url = "https://files.pythonhosted.org/packages/62/aa/fca4bf8de3396ddb59544df9b75ffe5b73096174de97a9492d426f5cd4aa/numpy-2.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:df470d376f54e052c76517393fa443758fefcdd634645bc9c1f84eafc67087f0", size = 14258658, upload-time = "2025-06-07T14:45:10.156Z" }, - { url = "https://files.pythonhosted.org/packages/1c/12/734dce1087eed1875f2297f687e671cfe53a091b6f2f55f0c7241aad041b/numpy-2.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:87717eb24d4a8a64683b7a4e91ace04e2f5c7c77872f823f02a94feee186168f", size = 16628765, upload-time = "2025-06-07T14:45:35.076Z" }, - { url = "https://files.pythonhosted.org/packages/48/03/ffa41ade0e825cbcd5606a5669962419528212a16082763fc051a7247d76/numpy-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fa264d56882b59dcb5ea4d6ab6f31d0c58a57b41aec605848b6eb2ef4a43e8", size = 15564335, upload-time = "2025-06-07T14:45:58.797Z" }, - { url = "https://files.pythonhosted.org/packages/07/58/869398a11863310aee0ff85a3e13b4c12f20d032b90c4b3ee93c3b728393/numpy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e651756066a0eaf900916497e20e02fe1ae544187cb0fe88de981671ee7f6270", size = 18360608, upload-time = "2025-06-07T14:46:25.687Z" }, - { url = "https://files.pythonhosted.org/packages/2f/8a/5756935752ad278c17e8a061eb2127c9a3edf4ba2c31779548b336f23c8d/numpy-2.3.0-cp313-cp313-win32.whl", hash = "sha256:e43c3cce3b6ae5f94696669ff2a6eafd9a6b9332008bafa4117af70f4b88be6f", size = 6310005, upload-time = "2025-06-07T14:50:13.138Z" }, - { url = "https://files.pythonhosted.org/packages/08/60/61d60cf0dfc0bf15381eaef46366ebc0c1a787856d1db0c80b006092af84/numpy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:81ae0bf2564cf475f94be4a27ef7bcf8af0c3e28da46770fc904da9abd5279b5", size = 12729093, upload-time = "2025-06-07T14:50:31.82Z" }, - { url = "https://files.pythonhosted.org/packages/66/31/2f2f2d2b3e3c32d5753d01437240feaa32220b73258c9eef2e42a0832866/numpy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8738baa52505fa6e82778580b23f945e3578412554d937093eac9205e845e6e", size = 9885689, upload-time = "2025-06-07T14:50:47.888Z" }, - { url = "https://files.pythonhosted.org/packages/f1/89/c7828f23cc50f607ceb912774bb4cff225ccae7131c431398ad8400e2c98/numpy-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39b27d8b38942a647f048b675f134dd5a567f95bfff481f9109ec308515c51d8", size = 20986612, upload-time = "2025-06-07T14:46:56.077Z" }, - { url = "https://files.pythonhosted.org/packages/dd/46/79ecf47da34c4c50eedec7511e53d57ffdfd31c742c00be7dc1d5ffdb917/numpy-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0eba4a1ea88f9a6f30f56fdafdeb8da3774349eacddab9581a21234b8535d3d3", size = 14298953, upload-time = "2025-06-07T14:47:18.053Z" }, - { url = "https://files.pythonhosted.org/packages/59/44/f6caf50713d6ff4480640bccb2a534ce1d8e6e0960c8f864947439f0ee95/numpy-2.3.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:b0f1f11d0a1da54927436505a5a7670b154eac27f5672afc389661013dfe3d4f", size = 5225806, upload-time = "2025-06-07T14:47:27.524Z" }, - { url = "https://files.pythonhosted.org/packages/a6/43/e1fd1aca7c97e234dd05e66de4ab7a5be54548257efcdd1bc33637e72102/numpy-2.3.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:690d0a5b60a47e1f9dcec7b77750a4854c0d690e9058b7bef3106e3ae9117808", size = 6735169, upload-time = "2025-06-07T14:47:38.057Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/f76f93b06a03177c0faa7ca94d0856c4e5c4bcaf3c5f77640c9ed0303e1c/numpy-2.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8b51ead2b258284458e570942137155978583e407babc22e3d0ed7af33ce06f8", size = 14330701, upload-time = "2025-06-07T14:47:59.113Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f5/4858c3e9ff7a7d64561b20580cf7cc5d085794bd465a19604945d6501f6c/numpy-2.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:aaf81c7b82c73bd9b45e79cfb9476cb9c29e937494bfe9092c26aece812818ad", size = 16692983, upload-time = "2025-06-07T14:48:24.196Z" }, - { url = "https://files.pythonhosted.org/packages/08/17/0e3b4182e691a10e9483bcc62b4bb8693dbf9ea5dc9ba0b77a60435074bb/numpy-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f420033a20b4f6a2a11f585f93c843ac40686a7c3fa514060a97d9de93e5e72b", size = 15641435, upload-time = "2025-06-07T14:48:47.712Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d5/463279fda028d3c1efa74e7e8d507605ae87f33dbd0543cf4c4527c8b882/numpy-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d344ca32ab482bcf8735d8f95091ad081f97120546f3d250240868430ce52555", size = 18433798, upload-time = "2025-06-07T14:49:14.866Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1e/7a9d98c886d4c39a2b4d3a7c026bffcf8fbcaf518782132d12a301cfc47a/numpy-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:48a2e8eaf76364c32a1feaa60d6925eaf32ed7a040183b807e02674305beef61", size = 6438632, upload-time = "2025-06-07T14:49:25.67Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ab/66fc909931d5eb230107d016861824f335ae2c0533f422e654e5ff556784/numpy-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ba17f93a94e503551f154de210e4d50c5e3ee20f7e7a1b5f6ce3f22d419b93bb", size = 12868491, upload-time = "2025-06-07T14:49:44.898Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e8/2c8a1c9e34d6f6d600c83d5ce5b71646c32a13f34ca5c518cc060639841c/numpy-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f14e016d9409680959691c109be98c436c6249eaf7f118b424679793607b5944", size = 9935345, upload-time = "2025-06-07T14:50:02.311Z" }, +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372, upload-time = "2025-06-21T12:28:33.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381, upload-time = "2025-06-21T12:19:04.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726, upload-time = "2025-06-21T12:19:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145, upload-time = "2025-06-21T12:19:34.782Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409, upload-time = "2025-06-21T12:19:45.228Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630, upload-time = "2025-06-21T12:20:06.544Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546, upload-time = "2025-06-21T12:20:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538, upload-time = "2025-06-21T12:20:54.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327, upload-time = "2025-06-21T12:21:21.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330, upload-time = "2025-06-21T12:25:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565, upload-time = "2025-06-21T12:25:26.444Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262, upload-time = "2025-06-21T12:25:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593, upload-time = "2025-06-21T12:21:51.664Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523, upload-time = "2025-06-21T12:22:13.583Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993, upload-time = "2025-06-21T12:22:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652, upload-time = "2025-06-21T12:22:33.629Z" }, + { url = "https://files.pythonhosted.org/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561, upload-time = "2025-06-21T12:22:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349, upload-time = "2025-06-21T12:23:20.53Z" }, + { url = "https://files.pythonhosted.org/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053, upload-time = "2025-06-21T12:23:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184, upload-time = "2025-06-21T12:24:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678, upload-time = "2025-06-21T12:24:21.596Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697, upload-time = "2025-06-21T12:24:40.644Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376, upload-time = "2025-06-21T12:24:56.884Z" }, ] [[package]] @@ -395,6 +840,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pg8000" +version = "1.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "scramp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d7/0554640cbe3e193184796bedb6de23f797c03958425176faf0e694c06eb0/pg8000-1.31.2.tar.gz", hash = "sha256:1ea46cf09d8eca07fe7eaadefd7951e37bee7fabe675df164f1a572ffb300876", size = 113513, upload-time = "2024-04-28T16:57:47.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/a0/2b30d52017c4ced8fc107386666ea7573954eb708bf66121f0229df05d41/pg8000-1.31.2-py3-none-any.whl", hash = "sha256:436c771ede71af4d4c22ba867a30add0bc5c942d7ab27fadbb6934a487ecc8f6", size = 54494, upload-time = "2024-04-28T16:57:44.431Z" }, +] + +[[package]] +name = "phonenumbers" +version = "9.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/3a/8dd1d5e15ac347db59a43fc623638d8088f9d511e7f8d8eec7d90b3abb4f/phonenumbers-9.0.8.tar.gz", hash = "sha256:16f03f2cf65b5eee99ed25827d810febcab92b5d76f977e425fcd2e4ca6d4865", size = 2297504, upload-time = "2025-06-25T05:41:38.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/2e/23c1eca9f73e332b947c66c431690672783cf49b8418aa650ce06bdf6115/phonenumbers-9.0.8-py2.py3-none-any.whl", hash = "sha256:53d357111c0ead0d6408ae443613b18d3a053431ca1ddf7e881457c0969afcf9", size = 2583252, upload-time = "2025-06-25T05:41:34.815Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -404,9 +918,115 @@ 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 = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, +] + +[[package]] +name = "protobuf" +version = "6.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" }, + { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" }, +] + +[[package]] +name = "psycopg2" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672, upload-time = "2024-10-16T11:24:54.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060, upload-time = "2025-01-04T20:09:15.28Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[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 = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + [[package]] name = "pydantic" -version = "2.11.5" +version = "2.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -414,9 +1034,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] [[package]] @@ -449,11 +1069,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +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/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { 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]] @@ -476,7 +1096,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.0" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -485,9 +1105,69 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[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-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "scramp" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/8f1b99c3f875f334ac782e173ec03c35c246ec7a94fc5dd85153bc1d8285/scramp-1.4.5.tar.gz", hash = "sha256:be3fbe774ca577a7a658117dca014e5d254d158cecae3dd60332dfe33ce6d78e", size = 16169, upload-time = "2024-04-13T12:35:19.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/9f/8b2f2749ccfbe4fcef08650896ac47ed919ff25b7ac57b7a1ae7da16c8c3/scramp-1.4.5-py3-none-any.whl", hash = "sha256:50e37c464fc67f37994e35bee4151e3d8f9320e9c204fca83a5d313c121bbbe7", size = 12781, upload-time = "2024-04-13T12:35:17.466Z" }, ] [[package]] @@ -517,6 +1197,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/f1/5e9b3ba5c7aa7ebfaf269657e728067d16a7c99401c7973ddf5f0cf121bd/shapely-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8cb8f17c377260452e9d7720eeaf59082c5f8ea48cf104524d953e5d36d4bdb7", size = 1723061, upload-time = "2025-05-19T11:04:40.082Z" }, ] +[[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 = "sniffio" version = "1.3.1" @@ -547,6 +1236,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, ] +[[package]] +name = "sqlalchemy-searchable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/d3/186a7d015516ec9bedb7a44b7161209e1659ddf9e75a2045fb2ac1f2b9de/sqlalchemy_searchable-2.1.0.tar.gz", hash = "sha256:89d120ed1a752d22e32b3f028f62cae571241ccce081df8d8a42e1fa9a53da93", size = 25743, upload-time = "2024-02-19T13:28:30.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/73/681d45ffb1c637f295f35ae4ded4fec7ba84d22738283a679a4c8ffbcb30/sqlalchemy_searchable-2.1.0-py3-none-any.whl", hash = "sha256:a4ef31d6ba60face514563beed6c4a72b5639add67503689e83d5f7d9a6c76ec", size = 8316, upload-time = "2024-02-19T13:28:28.129Z" }, +] + +[[package]] +name = "sqlalchemy-utils" +version = "0.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bf/abfd5474cdd89ddd36dbbde9c6efba16bfa7f5448913eba946fed14729da/SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990", size = 138017, upload-time = "2024-03-24T15:17:28.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f0/dc4757b83ac1ab853cf222df8535ed73973e0c203d983982ba7b8bc60508/SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e", size = 93083, upload-time = "2024-03-24T15:17:24.533Z" }, +] + [[package]] name = "starlette" version = "0.46.2" @@ -580,15 +1294,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" -version = "0.34.3" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ]