diff --git a/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py b/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py new file mode 100644 index 00000000..1f223eab --- /dev/null +++ b/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py @@ -0,0 +1,27 @@ +"""enable pg_trgm extension + +Revision ID: 0493ebb8237d +Revises: t6u7v8w9x0y1 +Create Date: 2026-05-01 11:17:44.571959 + +""" + +from typing import Sequence, Union + +from sqlalchemy import text + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0493ebb8237d" +down_revision: Union[str, Sequence[str], None] = "t6u7v8w9x0y1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + + +def downgrade() -> None: + op.execute(text("DROP EXTENSION IF EXISTS pg_trgm")) diff --git a/api/asset.py b/api/asset.py index 456b5d3a..4574747c 100644 --- a/api/asset.py +++ b/api/asset.py @@ -17,7 +17,7 @@ import logging import time -from fastapi import APIRouter, Depends, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError @@ -173,6 +173,20 @@ async def add_asset( assoc = AssetThingAssociation() audit_add(user, assoc) thing = session.get(Thing, thing_id) + + if not thing: + raise HTTPException( + status_code=409, + detail=[ + { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {thing_id} not found.", + "type": "value_error", + "input": {"thing_id": thing_id}, + } + ], + ) + assoc.thing = thing assoc.asset = asset session.add(assoc) diff --git a/api/thing.py b/api/thing.py index baeed59e..f40b768c 100644 --- a/api/thing.py +++ b/api/thing.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== from typing import Annotated, Optional + from fastapi import APIRouter, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select @@ -29,47 +30,47 @@ from api.pagination import CustomPage from core.app import public_route from core.dependencies import ( - session_dependency, admin_dependency, editor_dependency, + session_dependency, viewer_dependency, ) from db.deployment import Deployment from db.thing import Thing, ThingIdLink, WellScreen from schemas.deployment import DeploymentResponse from schemas.thing import ( + CreateSpring, CreateThingIdLink, CreateWell, CreateWellScreen, - ThingResponse, - WellResponse, - WellScreenResponse, - UpdateSpring, - UpdateWell, SpringResponse, - CreateSpring, ThingIdLinkResponse, + ThingResponse, + UpdateSpring, UpdateThingIdLink, + UpdateWell, UpdateWellScreen, + WellResponse, + WellScreenResponse, ) from schemas.well_details import WellDetailsResponse from schemas.well_export import WellExportResponse -from services.crud_helper import model_patcher, model_adder, model_deleter +from services.crud_helper import model_adder, model_deleter, model_patcher from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category from services.query_helper import ( - simple_get_by_id, - paginated_all_getter, order_sort_filter, + paginated_all_getter, + simple_get_by_id, ) from services.thing_helper import ( + WELL_DESCRIPTOR_MODEL_MAP, add_thing, - patch_thing, add_well_screen, get_db_things, get_thing_of_a_thing_type_by_id, modify_well_descriptor_tables, - WELL_DESCRIPTOR_MODEL_MAP, + patch_thing, ) from services.well_details_helper import ( get_well_details_payload, @@ -154,7 +155,6 @@ def get_water_wells( filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, query: Optional[str] = None, name: Optional[str] = None, - name_contains: Optional[str] = None, include_contacts: bool = False, ) -> CustomPage[WellResponse]: """ @@ -171,7 +171,6 @@ def get_water_wells( thing_type=thing_type, include_contacts=include_contacts, filters=filter_params, - name_contains=name_contains, ) @@ -298,7 +297,6 @@ def get_springs( order: str = None, filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, query: str = None, - name_contains: Optional[str] = None, ) -> CustomPage[SpringResponse]: """ Retrieve all springs from the database. @@ -312,7 +310,6 @@ def get_springs( sort, thing_type=thing_type, filters=filter_params, - name_contains=name_contains, ) @@ -373,7 +370,6 @@ def get_things( order: Optional[str] = None, include_contacts: bool = False, filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, - name_contains: Optional[str] = None, ) -> CustomPage[ThingResponse]: """ Retrieve all things or filter by type. @@ -388,7 +384,6 @@ def get_things( within=within, include_contacts=include_contacts, filters=filter_params, - name_contains=name_contains, ) diff --git a/db/engine.py b/db/engine.py index 2d2f0d9f..4983be93 100644 --- a/db/engine.py +++ b/db/engine.py @@ -179,7 +179,7 @@ def getconn(): conn = connector.connect( instance_name, # The Cloud SQL instance name - "pg8000", + "psycopg", **connect_kwargs, ) return conn @@ -190,7 +190,7 @@ def getconn(): pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( - "postgresql+pg8000://", + "postgresql+psycopg://", creator=getconn, echo=False, pool_size=pool_size, @@ -220,7 +220,7 @@ def getconn(): 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}" + url = f"postgresql+psycopg://{auth}{host}{port_part}/{name}" # else: # url = "sqlite:///./development.db" @@ -243,7 +243,7 @@ def getconn(): _install_pool_logging(engine) async_engine = create_async_engine( - url.replace("postgresql+pg8000", "postgresql+asyncpg"), + url.replace("postgresql+psycopg", "postgresql+asyncpg"), plugins=["geoalchemy2"], ) # if "postgresql" not in url: diff --git a/pyproject.toml b/pyproject.toml index e757a412..ff464cb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ dependencies = [ "utm==0.8.1", "uvicorn==0.42.0", "yarl==1.23.0", + "psycopg[binary]>=3.3.3", ] [tool.uv] diff --git a/services/thing_helper.py b/services/thing_helper.py index 16fdd9a6..79e5f698 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -16,41 +16,44 @@ import logging import time from datetime import datetime -from typing import Sequence, Optional +from typing import Any, Optional, Sequence from zoneinfo import ZoneInfo -from fastapi import Request, HTTPException +from fastapi import HTTPException, Request from fastapi_pagination.ext.sqlalchemy import paginate from pydantic import BaseModel from shapely import wkb from shapely.geometry import mapping -from sqlalchemy import select, func +from sqlalchemy import Text, case, cast, desc, func, literal, or_, select +from sqlalchemy.dialects.postgresql import REGCONFIG from sqlalchemy.orm import Session, aliased, selectinload from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT +from api.pagination import CustomPage from db import ( - LocationThingAssociation, - Thing, - ThingContactAssociation, - Location, - WellScreen, - WellPurpose, - WellCasingMaterial, - ThingAquiferAssociation, + Contact, + DataProvenance, GroupThingAssociation, + Location, + LocationThingAssociation, MeasuringPointHistory, - DataProvenance, - ThingIdLink, MonitoringFrequencyHistory, StatusHistory, - search, + Thing, + ThingAquiferAssociation, + ThingContactAssociation, + ThingIdLink, + WellCasingMaterial, + WellPurpose, + WellScreen, ) +from schemas.thing import WellResponse from services.audit_helper import audit_add from services.crud_helper import model_patcher -from services.exceptions_helper import PydanticStyleException from services.env import get_bool_env +from services.exceptions_helper import PydanticStyleException from services.geospatial_helper import make_within_wkt -from services.query_helper import make_query, order_sort_filter, simple_get_by_id +from services.query_helper import order_sort_filter, simple_get_by_id logger = logging.getLogger(__name__) @@ -122,17 +125,126 @@ def get_db_things( name: Optional[str] = None, include_contacts: bool = False, filters: Optional[list[str]] = None, - name_contains: Optional[str] = None, -) -> list: - - if query: - sql = search( - select(Thing), - query, - vector=Thing.search_vector, +) -> CustomPage[Any]: + sql = select(Thing) + + # Querying logic + # + # Combines multiple search strategies so the global search supports: + # + # 1. Full-text search using Thing.search_vector + # - Best for normal word-based and multi-word searches. + # + # 2. Trigram fuzzy search using pg_trgm operators + # - "%" compares similarity across the full string. + # - "<%" compares similarity against a word/substring extent, + # which helps with names like "cary" matching "john carry". + # + # 3. Partial substring search using ILIKE + # - Handles very short partial inputs like "ty" matching "tyler". + # - This is useful because trigram matching is less effective for + # short queries under 3 characters. + # + # OR is used so any matching strategy can return a result. + if query and query.strip(): + clean_query = query.strip() + partial_query = f"%{clean_query}%" + + # Ranking scores + # + # These are used only for ordering results, not for filtering. + # similarity() ranks whole-string similarity. + # word_similarity() ranks best word/span similarity. + name_sim = func.coalesce(func.similarity(Thing.name, clean_query), 0) + type_sim = func.coalesce(func.similarity(Thing.thing_type, clean_query), 0) + + name_word_sim = func.coalesce(func.word_similarity(Thing.name, clean_query), 0) + type_word_sim = func.coalesce( + func.word_similarity(Thing.thing_type, clean_query), 0 ) - else: - sql = select(Thing) + + exact_or_partial_rank = case( + # Exact matches rank highest + (func.lower(Thing.name) == clean_query.lower(), 2.0), + (func.lower(Thing.thing_type) == clean_query.lower(), 2.0), + # Prefix matches rank next + (Thing.name.ilike(f"{clean_query}%"), 1.75), + (Thing.thing_type.ilike(f"{clean_query}%"), 1.75), + # Partial substring matches rank after prefix matches + (Thing.name.ilike(partial_query), 1.5), + (Thing.thing_type.ilike(partial_query), 1.5), + else_=0.0, + ) + + search_conditions = [ + Thing.search_vector.op("@@")( + func.parse_websearch( + cast("english", REGCONFIG), + cast(clean_query, Text), + ) + ), + Thing.name.op("%")(clean_query), + Thing.thing_type.op("%")(clean_query), + literal(clean_query).op("<%")(Thing.name), + literal(clean_query).op("<<%")(Thing.name), + literal(clean_query).op("<%")(Thing.thing_type), + literal(clean_query).op("<<%")(Thing.thing_type), + Thing.name.ilike(partial_query), + Thing.thing_type.ilike(partial_query), + ] + + rank_expressions = [ + exact_or_partial_rank, + name_sim, + type_sim, + name_word_sim, + type_word_sim, + ] + + if include_contacts: + contact_sim = func.coalesce(func.similarity(Contact.name, clean_query), 0) + contact_word_sim = func.coalesce( + func.word_similarity(Contact.name, clean_query), 0 + ) + + sql = sql.outerjoin(Thing.contact_associations).outerjoin( + ThingContactAssociation.contact + ) + + contact_exact_or_partial_rank = case( + (func.lower(Contact.name) == clean_query.lower(), 2.0), + (Contact.name.ilike(f"{clean_query}%"), 1.75), + (Contact.name.ilike(partial_query), 1.5), + else_=0.0, + ) + + search_conditions.extend( + [ + Contact.name.op("%")(clean_query), + literal(clean_query).op("<%")(Contact.name), + literal(clean_query).op("<<%")(Contact.name), + Contact.name.ilike(partial_query), + ] + ) + + rank_expressions.extend( + [ + contact_exact_or_partial_rank, + contact_sim, + contact_word_sim, + ] + ) + + sql = sql.where(or_(*search_conditions)).order_by( + desc(func.greatest(*rank_expressions)) + ) + + if include_contacts: + sql = sql.options( + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ) + ) if thing_type: sql = sql.where(Thing.thing_type == thing_type) @@ -153,9 +265,6 @@ def get_db_things( if name: sql = sql.where(Thing.name == name) - if name_contains and name_contains.strip(): - sql = sql.where(Thing.name.ilike(f"%{name_contains.strip()}%")) - if within: latest_assoc = ( select( @@ -186,7 +295,7 @@ def get_db_things( sql = order_sort_filter(sql, Thing, sort, order, filters=merged_filters) - return paginate(query=sql, conn=session) + return paginate(query=sql, conn=session, unique=True) def get_thing_type_from_request(request: Request) -> str: @@ -339,7 +448,6 @@ def add_thing( # ---------- if thing_type == WATER_WELL_THING_TYPE: - # Create MeasuringPointHistory record if measuring_point_height provided if measuring_point_height is not None: measuring_point_history = MeasuringPointHistory( diff --git a/uv.lock b/uv.lock index c0e13457..6b09914e 100644 --- a/uv.lock +++ b/uv.lock @@ -1503,6 +1503,7 @@ dependencies = [ { name = "propcache" }, { name = "proto-plus" }, { name = "protobuf" }, + { name = "psycopg", extra = ["binary"] }, { name = "psycopg2-binary" }, { name = "pyasn1" }, { name = "pyasn1-modules" }, @@ -1617,6 +1618,7 @@ requires-dist = [ { name = "propcache", specifier = "==0.4.1" }, { name = "proto-plus", specifier = "==1.27.2" }, { name = "protobuf", specifier = "==6.33.5" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, @@ -2027,6 +2029,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11"