diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c031d3804..55e045e1f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,7 +67,7 @@ jobs: - name: Run BDD tests run: | - uv run behave tests/features --tags="@backend and @production" --no-capture + uv run behave tests/features --tags="@backend and @production and not @skip" --no-capture - name: Upload results to Codecov uses: codecov/codecov-action@v4 diff --git a/README.md b/README.md index ca79d6363..aaa47bad2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# NMSampleLocations +# NMSampleLocations aka OcotilloAPI [![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) @@ -9,7 +9,8 @@ **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. +OcotilloAPI 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. --- @@ -197,4 +198,39 @@ Notes: - All `Update` schema fields are optional and default to `None` - All `Response` schema fields are defined as `` if non-nullable and ` | None` if nullable - All raised exceptions should use the `PydanticStyleException` as defined in `services/exceptions_helper.py` -- Errors handled by the database should be enumerated and handled in a database_error_handler in each router's file \ No newline at end of file +- Errors handled by the database should be enumerated and handled in a database_error_handler in each router's file--- + +## ๐Ÿ“ฆ Ocotillo CLI + +The `oco` command exposes project automation and bulk data utilities. + +```bash +# Display available commands +oco --help + +# Bulk import water level data from a CSV +oco water-levels bulk-upload --file water_levels.csv --output json +``` + +The bulk upload command parses and validates each row, creates the corresponding field events/samples/observations, and prints a JSON summary (matching the API response shape) so the workflow can be automated or scripted. +## ๐Ÿงช Testing + +```bash +# Run unit tests +pytest + +# Run Behave BDD specs +behave tests/features +``` + +> Tests require a local Postgres/PostGIS instance. Set `POSTGRES_*` values in `.env`, run migrations, and ensure the database is reachable before running the suites. + +## ๐Ÿ”„ Data Transfers + +Legacy or staging datasets can be imported using the transfer utilities: + +```bash +python -m transfers.transfer +``` + +Configure the `.env` file with the appropriate credentials before running transfers. diff --git a/api/observation.py b/api/observation.py index 90970e5a9..3b446bd71 100644 --- a/api/observation.py +++ b/api/observation.py @@ -15,8 +15,13 @@ # =============================================================================== from datetime import datetime -from fastapi import APIRouter, Query, Request -from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT +from fastapi import APIRouter, Query, Request, UploadFile, File, HTTPException +from starlette.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, +) from api.pagination import CustomPage from core.dependencies import ( @@ -36,6 +41,7 @@ UpdateWaterChemistryObservation, ) from schemas.transducer import TransducerObservationWithBlockResponse +from schemas.water_level_csv import WaterLevelBulkUploadResponse from services.crud_helper import model_deleter, model_adder from services.observation_helper import ( get_observations, @@ -44,6 +50,7 @@ get_transducer_observations, ) from services.query_helper import simple_get_by_id +from services.water_level_csv import bulk_upload_water_levels router = APIRouter(prefix="/observation", tags=["observation"]) @@ -81,6 +88,30 @@ async def add_water_chemistry_observation( return model_adder(session, Observation, obs_data, user=user) +@router.post( + "/groundwater-level/bulk-upload", + response_model=WaterLevelBulkUploadResponse, + status_code=HTTP_200_OK, +) +async def bulk_upload_groundwater_levels( + user: amp_admin_dependency, + file: UploadFile = File(...), +): + contents = await file.read() + if not contents: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail="Uploaded file is empty", + ) + + result = bulk_upload_water_levels(contents) + + if result.exit_code != 0: + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=result.payload) + + return result.payload + + # PATCH ======================================================================== diff --git a/manage.py b/cli/__init__.py similarity index 58% rename from manage.py rename to cli/__init__.py index 7b9f24a1c..8e546ddc2 100644 --- a/manage.py +++ b/cli/__init__.py @@ -13,37 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from dotenv import load_dotenv - -load_dotenv() - -import click -from core.initializers import init_lexicon - - -# from migration.migration2 import migrate_wells, migrate_water_levels -# -# -# def wells(): -# with session_ctx() as sess: -# migrate_wells(sess, 1000) -# -# -# def waterlevels(): -# with session_ctx() as sess: -# migrate_water_levels(sess, 800) -@click.group() -def cli(): - """Command line interface for managing the application.""" - pass - - -@cli.command() -def initialize_lexicon(): - init_lexicon() - - -if __name__ == "__main__": - cli() # ============= EOF ============================================= diff --git a/cli/cli.py b/cli/cli.py new file mode 100644 index 000000000..6f7278392 --- /dev/null +++ b/cli/cli.py @@ -0,0 +1,88 @@ +# =============================================================================== +# 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 dotenv import load_dotenv + +load_dotenv() + +import click + + +@click.group() +def cli(): + """Command line interface for managing the application.""" + pass + + +@cli.command() +def initialize_lexicon(): + from core.initializers import init_lexicon + + init_lexicon() + + +@cli.command() +@click.argument( + "root_directory", + type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True), +) +def associate_assets_command(root_directory: str): + from cli.service_adapter import associate_assets + + associate_assets(root_directory) + + +@cli.command() +@click.argument( + "file_path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), +) +def well_inventory_csv(file_path: str): + """ + parse and upload a csv to database + """ + # TODO: use the same helper function used by api to parse and upload a WI csv + from cli.service_adapter import well_inventory_csv + + well_inventory_csv(file_path) + + +@cli.command() +@click.argument( + "file_path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), +) +@click.option( + "--output", + "output_format", + type=click.Choice(["json"], case_sensitive=False), + default=None, + help="Optional output format", +) +def water_levels_csv(file_path: str, output_format: str | None): + """ + parse and upload a csv + """ + # TODO: use the same helper function used by api to parse and upload a WL csv + from cli.service_adapter import water_levels_csv + + pretty_json = (output_format or "").lower() == "json" + water_levels_csv(file_path, pretty_json=pretty_json) + + +if __name__ == "__main__": + cli() + +# ============= EOF ============================================= diff --git a/cli/service_adapter.py b/cli/service_adapter.py new file mode 100644 index 000000000..a177a95ff --- /dev/null +++ b/cli/service_adapter.py @@ -0,0 +1,100 @@ +# =============================================================================== +# 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 csv +import io +import mimetypes +import sys +from pathlib import Path + +from fastapi import UploadFile +from sqlalchemy import select + +from db import Thing, Asset +from db.engine import session_ctx +from services.asset_helper import upload_and_associate +from services.gcs_helper import get_storage_bucket, make_blob_name_and_uri +from services.water_level_csv import bulk_upload_water_levels + + +def well_inventory_csv(source_file: Path | str): + if isinstance(source_file, str): + source_file = Path(source_file) + + +def water_levels_csv(source_file: Path | str, *, pretty_json: bool = False): + if isinstance(source_file, str): + source_file = Path(source_file) + + result = bulk_upload_water_levels(source_file, pretty_json=pretty_json) + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.exit_code + + +def associate_assets(source_directory: Path | str) -> list[str]: + """ + given a directory + and the directory contains a manifest file + and the manifest file is a 3-column csv (asset_file_name, thing_name aka pointid, asset_type) + and the directory contains a set of photos + + then when i run the associate photos command + the app should save the photos to gcs + and associate each uploaded photo with the corresponding thing + + """ + if isinstance(source_directory, str): + source_directory = Path(source_directory) + m = source_directory / "manifest.txt" + + bucket = get_storage_bucket() + uris = [] + with session_ctx() as sess: + with open(m, "r") as rf: + reader = csv.DictReader(rf) + for row in reader: + # save file to gcs + path = row["asset_file_name"].strip() + + with open(source_directory / path, "rb") as fp: + file = UploadFile( + io.BytesIO(fp.read()), filename=path, size=len(fp.read()) + ) + + sql = select(Thing).where(Thing.name == row["thing_name"].strip()) + thing = sess.scalars(sql).one_or_none() + if thing: + # get mime_type from file + mime_type, encoding = mimetypes.guess_type(path) + blob_name, uri = make_blob_name_and_uri(file) + sql = select(Asset).where(Asset.uri == uri) + existing_asset = sess.scalars(sql).one_or_none() + if existing_asset: + continue + uri, blob_name = upload_and_associate( + sess, file, bucket, thing, path, **{"mime_type": mime_type} + ) + uris.append(uri) + + else: + print(f"no thing with name={row['thing_name']} found in db") + sess.commit() + + return uris + + +# ============= EOF ============================================= diff --git a/schemas/water_level_csv.py b/schemas/water_level_csv.py new file mode 100644 index 000000000..00d71eaf4 --- /dev/null +++ b/schemas/water_level_csv.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 pydantic import BaseModel + + +class WaterLevelBulkUploadSummary(BaseModel): + total_rows_processed: int + total_rows_imported: int + validation_errors_or_warnings: int + + +class WaterLevelBulkUploadRow(BaseModel): + well_name_point_id: str + field_event_id: int + field_activity_id: int + sample_id: int + observation_id: int + measurement_date_time: str + level_status: str + data_quality: str + + +class WaterLevelBulkUploadResponse(BaseModel): + summary: WaterLevelBulkUploadSummary + water_levels: list[WaterLevelBulkUploadRow] + validation_errors: list[str] + + +# ============= EOF ============================================= diff --git a/services/asset_helper.py b/services/asset_helper.py new file mode 100644 index 000000000..83c48509d --- /dev/null +++ b/services/asset_helper.py @@ -0,0 +1,44 @@ +# =============================================================================== +# Author: Jake Ross +# Copyright 2025 New Mexico Bureau of Geology & Mineral Resources +# 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 +# =============================================================================== +from typing import BinaryIO + +from google.cloud.storage import Bucket +from sqlalchemy.orm import Session + +from db import AssetThingAssociation, Thing, Asset +from services.gcs_helper import gcs_upload + + +def upload_and_associate( + session: Session, + ff: BinaryIO, + bucket: Bucket, + thing: Thing, + name: str, + **asset_args, +) -> tuple[str, str]: + uri, blob_name = gcs_upload(ff, bucket) + asset = Asset( + name=name, + storage_path=blob_name, + storage_service="gcs", + size=ff.size, + uri=uri, + **asset_args, + # label=filename, + # mime_type="image/png", + ) + assoc = AssetThingAssociation() + assoc.thing = thing + assoc.asset = asset + session.add(assoc) + session.add(asset) + return uri, blob_name + + +# ============= EOF ============================================= diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 41ada4481..804d4cdfd 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -14,13 +14,15 @@ # limitations under the License. # =============================================================================== import base64 +import datetime import json import os -import datetime from hashlib import md5 + from fastapi import UploadFile from google.oauth2 import service_account from sqlalchemy import select + from core.settings import settings from db import Asset, AssetThingAssociation @@ -51,12 +53,23 @@ def get_storage_client() -> storage.Client: return client -def get_storage_bucket(client=None) -> storage.Bucket: +def get_storage_bucket(client=None, bucket: str = None) -> storage.Bucket: if client is None: client = get_storage_client() - bucket = client.bucket(GCS_BUCKET_NAME) - return bucket + if bucket is None: + bucket = GCS_BUCKET_NAME + + return client.bucket(bucket) + + +def make_blob_name_and_uri(file): + head, extension = os.path.splitext(file.filename) + file_id = md5(file.file.read()).hexdigest() + + blob_name = f"{head}_{file_id}{extension}" + uri = f"{GCS_BUCKET_BASE_URL}/{blob_name}" + return blob_name, uri def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): @@ -65,19 +78,15 @@ def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): # make file id from hash of file contents file.file.seek(0) - file_id = md5(file.file.read()).hexdigest() - - head, extension = os.path.splitext(file.filename) - blob_name = f"{head}_{file_id}{extension}" + blob_name, uri = make_blob_name_and_uri(file) eblob = bucket.get_blob(blob_name) - url = f"{GCS_BUCKET_BASE_URL}/{blob_name}" if not eblob: blob = bucket.blob(blob_name) file.file.seek(0) blob.upload_from_file(file.file, content_type=file.content_type) - return url, blob_name + return uri, blob_name def gcs_remove(uri: str, bucket: storage.Bucket): diff --git a/services/water_level_csv.py b/services/water_level_csv.py new file mode 100644 index 000000000..ff49fe12e --- /dev/null +++ b/services/water_level_csv.py @@ -0,0 +1,417 @@ +# ============================================================================== +# 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 __future__ import annotations + +import csv +import io +import json +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, BinaryIO, Iterable, List + +from pydantic import BaseModel, ConfigDict, ValidationError, field_validator +from sqlalchemy import select +from sqlalchemy.orm import Session + +from db import Thing, FieldEvent, FieldActivity, Sample, Observation, Parameter +from db.engine import session_ctx + +# Required CSV columns for the bulk upload +REQUIRED_FIELDS: List[str] = [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", +] + +# Allow-list values for validation. These represent early MVP lexicon values. +VALID_LEVEL_STATUSES = {"stable", "rising", "falling"} +VALID_DATA_QUALITIES = {"approved", "provisional"} +VALID_SAMPLERS = {"groundwater team", "consultant"} + +# Mapping between human-friendly sample methods provided in CSV uploads and +# their canonical lexicon terms stored in the database. +SAMPLE_METHOD_ALIASES = { + "electric tape": "Electric tape measurement (E-probe)", + "steel tape": "Steel-tape measurement", +} +SAMPLE_METHOD_CANONICAL = { + value.lower(): value for value in SAMPLE_METHOD_ALIASES.values() +} + + +@dataclass +class BulkUploadResult: + exit_code: int + stdout: str + stderr: str + payload: dict[str, Any] + + +@dataclass +class _ValidatedRow: + row_index: int + raw: dict[str, str] + well: Thing + field_staff: str + sampler: str + sample_method_term: str + field_event_dt: datetime + measurement_dt: datetime + mp_height: float + depth_to_water_ft: float + level_status: str + data_quality: str + water_level_notes: str | None + + +class WaterLevelCsvRow(BaseModel): + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + field_staff: str + well_name_point_id: str + field_event_date_time: datetime + measurement_date_time: datetime + sampler: str + sample_method: str + mp_height: float + level_status: str + depth_to_water_ft: float + data_quality: str + water_level_notes: str | None = None + + @field_validator( + "field_staff", + "well_name_point_id", + "sampler", + "sample_method", + "level_status", + "data_quality", + ) + @classmethod + def _require_value(cls, value: str) -> str: + if value is None or value == "": + raise ValueError("value is required") + return value + + @field_validator("sampler") + @classmethod + def _validate_sampler(cls, value: str) -> str: + if value.lower() not in VALID_SAMPLERS: + raise ValueError( + f"Invalid sampler '{value}'. Expected one of: {sorted(VALID_SAMPLERS)}" + ) + return value + + @field_validator("level_status") + @classmethod + def _validate_level_status(cls, value: str) -> str: + if value.lower() not in VALID_LEVEL_STATUSES: + raise ValueError( + f"Invalid level_status '{value}'. Expected one of: {sorted(VALID_LEVEL_STATUSES)}" + ) + return value + + @field_validator("data_quality") + @classmethod + def _validate_data_quality(cls, value: str) -> str: + if value.lower() not in VALID_DATA_QUALITIES: + raise ValueError( + f"Invalid data_quality '{value}'. Expected one of: {sorted(VALID_DATA_QUALITIES)}" + ) + return value + + @field_validator("sample_method") + @classmethod + def _normalize_sample_method(cls, value: str) -> str: + normalized = value.lower() + if normalized in SAMPLE_METHOD_ALIASES: + return SAMPLE_METHOD_ALIASES[normalized] + if normalized in SAMPLE_METHOD_CANONICAL: + return SAMPLE_METHOD_CANONICAL[normalized] + raise ValueError( + f"Invalid sample_method '{value}'. Expected one of: {sorted(SAMPLE_METHOD_ALIASES.keys())}" + ) + + @field_validator("water_level_notes", mode="before") + @classmethod + def _empty_to_none(cls, value: str | None) -> str | None: + if value is None: + return None + if isinstance(value, str) and value.strip() == "": + return None + return value + + +def bulk_upload_water_levels( + source_file: str | Path | bytes | BinaryIO, *, pretty_json: bool = False +) -> BulkUploadResult: + """Parse a CSV of water-level measurements and write database rows.""" + + try: + headers, csv_rows = _read_csv(source_file) + except FileNotFoundError: + msg = f"File not found: {source_file}" + payload = _build_payload([], [], 0, 0, [msg]) + stdout = _serialize_payload(payload, pretty_json) + return BulkUploadResult(exit_code=1, stdout=stdout, stderr=msg, payload=payload) + + validation_errors: list[str] = [] + created_rows: list[dict[str, Any]] = [] + + with session_ctx() as session: + parameter_id = _get_groundwater_level_parameter_id(session) + + # Validate headers early so we can short-circuit without touching the DB. + header_errors = _validate_headers(headers) + if header_errors: + validation_errors.extend(header_errors) + else: + valid_rows, row_errors = _validate_rows(session, csv_rows) + validation_errors.extend(row_errors) + + if not validation_errors: + try: + created_rows = _create_records(session, parameter_id, valid_rows) + session.commit() + except Exception as exc: # pragma: no cover - safety fallback + session.rollback() + validation_errors.append(str(exc)) + + if validation_errors: + session.rollback() + + summary = { + "total_rows_processed": len(csv_rows), + "total_rows_imported": len(created_rows) if not validation_errors else 0, + "validation_errors_or_warnings": len(validation_errors), + } + payload = _build_payload( + csv_rows, created_rows, **summary, errors=validation_errors + ) + stdout = _serialize_payload(payload, pretty_json) + stderr = "\n".join(validation_errors) + exit_code = 0 if not validation_errors else 1 + return BulkUploadResult( + exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload + ) + + +def _serialize_payload(payload: dict[str, Any], pretty: bool) -> str: + return json.dumps(payload, indent=2 if pretty else None) + + +def _build_payload( + csv_rows: Iterable[dict[str, Any]], + created_rows: list[dict[str, Any]], + total_rows_processed: int, + total_rows_imported: int, + validation_errors_or_warnings: int, + *, + errors: list[str], +) -> dict[str, Any]: + return { + "summary": { + "total_rows_processed": total_rows_processed, + "total_rows_imported": total_rows_imported, + "validation_errors_or_warnings": validation_errors_or_warnings, + }, + "water_levels": created_rows, + "validation_errors": errors, + } + + +def _read_csv( + source: str | Path | bytes | BinaryIO, +) -> tuple[list[str], list[dict[str, str]]]: + if isinstance(source, (str, Path)): + path = Path(source) + text = path.read_text(encoding="utf-8") + elif isinstance(source, bytes): + text = source.decode("utf-8") + elif hasattr(source, "read"): + data = source.read() + if isinstance(data, bytes): + text = data.decode("utf-8") + else: + text = str(data) + else: + raise TypeError("Unsupported CSV source type") + + stream = io.StringIO(text) + reader = csv.DictReader(stream) + rows = [ + { + k.strip(): (v.strip() if isinstance(v, str) else v or "") + for k, v in row.items() + } + for row in reader + ] + headers = [h.strip() for h in reader.fieldnames or []] + return headers, rows + + +def _validate_headers(headers: list[str]) -> list[str]: + missing = [field for field in REQUIRED_FIELDS if field not in headers] + return [f"CSV missing required column '{field}'" for field in missing] + + +def _validate_rows( + session: Session, rows: list[dict[str, str]] +) -> tuple[list[_ValidatedRow], list[str]]: + valid_rows: list[_ValidatedRow] = [] + errors: list[str] = [] + + wells_by_name: dict[str, Thing] = {} + + for idx, raw_row in enumerate(rows, start=1): + normalized = {k: (v or "").strip() for k, v in raw_row.items() if k is not None} + + missing = [field for field in REQUIRED_FIELDS if not normalized.get(field)] + if missing: + errors.extend( + [f"Row {idx}: Missing required field '{field}'" for field in missing] + ) + continue + + try: + model = WaterLevelCsvRow(**normalized) + except ValidationError as exc: + for err in exc.errors(): + location = ".".join(str(part) for part in err["loc"]) + message = err["msg"] + errors.append(f"Row {idx}: {location} - {message}") + continue + + well_name = model.well_name_point_id + well = wells_by_name.get(well_name) + if well is None: + sql = select(Thing).where(Thing.name == well_name) + well = session.scalars(sql).one_or_none() + if well is None: + errors.append(f"Row {idx}: Unknown well_name_point_id '{well_name}'") + continue + wells_by_name[well_name] = well + + valid_rows.append( + _ValidatedRow( + row_index=idx, + raw={**normalized}, + well=well, + field_staff=model.field_staff, + sampler=model.sampler, + sample_method_term=model.sample_method, + field_event_dt=model.field_event_date_time, + measurement_dt=model.measurement_date_time, + mp_height=model.mp_height, + depth_to_water_ft=model.depth_to_water_ft, + level_status=model.level_status, + data_quality=model.data_quality, + water_level_notes=model.water_level_notes, + ) + ) + + return valid_rows, errors + + +def _create_records( + session: Session, parameter_id: int, rows: list[_ValidatedRow] +) -> list[dict[str, Any]]: + created: list[dict[str, Any]] = [] + + for row in rows: + field_event = FieldEvent( + thing=row.well, + event_date=row.field_event_dt, + notes=_build_field_event_notes(row), + ) + field_activity = FieldActivity( + field_event=field_event, + activity_type="groundwater level", + notes=f"Sampler: {row.sampler}", + ) + sample = Sample( + field_activity=field_activity, + sample_date=row.measurement_dt, + sample_name=f"wl-{uuid.uuid4()}", + sample_matrix="water", + sample_method=row.sample_method_term, + qc_type="Normal", + notes=row.water_level_notes, + ) + observation = Observation( + sample=sample, + observation_datetime=row.measurement_dt, + parameter_id=parameter_id, + value=row.depth_to_water_ft, + unit="ft", + measuring_point_height=row.mp_height, + groundwater_level_reason=None, + notes=_build_observation_notes(row), + ) + session.add(field_event) + session.add(field_activity) + session.add(sample) + session.add(observation) + session.flush() + + created.append( + { + "well_name_point_id": row.raw["well_name_point_id"], + "field_event_id": field_event.id, + "field_activity_id": field_activity.id, + "sample_id": sample.id, + "observation_id": observation.id, + "measurement_date_time": row.raw["measurement_date_time"], + "level_status": row.level_status, + "data_quality": row.data_quality, + } + ) + + return created + + +def _build_field_event_notes(row: _ValidatedRow) -> str | None: + parts = [f"Field staff: {row.field_staff}"] + if row.water_level_notes: + parts.append(row.water_level_notes) + notes = " | ".join(part for part in parts if part) + return notes or None + + +def _build_observation_notes(row: _ValidatedRow) -> str | None: + parts = [f"Level status: {row.level_status}", f"Data quality: {row.data_quality}"] + notes = " | ".join(parts) + return notes or None + + +def _get_groundwater_level_parameter_id(session: Session) -> int: + sql = select(Parameter.id).where(Parameter.parameter_name == "groundwater level") + parameter_id = session.scalars(sql).one_or_none() + if parameter_id is None: + raise RuntimeError("Groundwater level parameter is not initialized") + return parameter_id + + +# ============= EOF ============================================= diff --git a/tests/features/cli-associate-assets.feature b/tests/features/cli-associate-assets.feature new file mode 100644 index 000000000..533d205ef --- /dev/null +++ b/tests/features/cli-associate-assets.feature @@ -0,0 +1,120 @@ +# Created by jakeross at 12/9/25 +@backend @cli @gcs +Feature: Associate assets with things based on a manifest file + In order to keep assets organized and discoverable + As a manager of the system + I want assets in a directory to be uploaded and associated to things using a CSV manifest + + Background: + Given a local directory named "asset_import_batch" + And the directory contains a manifest file named "manifest.txt" + And the manifest file is a 2-column CSV with headers asset_file_name and thing_name + And the directory contains a set of asset files referenced in the manifest + + @happy_path + Scenario Outline: Successfully upload and associate assets from a valid manifest + Given the manifest contains a row for "" with thing "" + And the directory contains a asset file named "" + When I run the "associate_assets" command on the directory + Then the app should upload "" to Google Cloud Storage + And the app should create an association between the uploaded asset and thing "" +# And the association should record: +# | field | value | +# | thing_name | | +# | asset_type | | +# | file_name | | +# | storage_type | gcs | +# And the command should exit with a success status + + Examples: + | asset_file_name | thing_name | + | AR0001_1.JPG | AR0001 | + | AR0001_2.JPG | AR0001 | + + @idempotent @multiple_runs + Scenario: Idempotent behavior when running associate photos multiple times with the same manifest + When I run the "associate_assets" command on the directory + Then each photo listed in the manifest should be uploaded exactly once to GCS + And each uploaded photo should be associated exactly once to its corresponding thing + When I run the "associate photos" command on the same directory again with the same manifest + Then each uploaded photo should be associated exactly once to its corresponding thing +# +# @multiple_rows @idempotent +# Scenario: Upload and associate multiple assets in a single run +# Given the manifest contains rows for multiple asset_file_name values for the same thing_name +# And the directory contains asset files matching all listed asset_file_name values +# When I run the "associate assets" command on the directory +# Then all assets listed in the manifest should be uploaded to GCS +# And all uploaded assets should be associated with their corresponding things +# And no duplicate associations should be created if the command is re-run with the same manifest and files +# +# @negative @missing-file +# Scenario: Manifest references a asset that does not exist in the directory +# Given the manifest contains a row for "missing-asset.jpg" with a valid thing_name and asset_type +# And the directory does not contain a file named "missing-asset.jpg" +# When I run the "associate_assets" command on the directory +# Then the app should not attempt to upload "missing-asset.jpg" +# And the app should log an error indicating the missing file +# And the app should report at least one failure in the run summary +# And other valid assets present in the directory and manifest should still be uploaded and associated +# +# @negative @extra-file +# Scenario: Directory contains extra assets that are not listed in the manifest +# Given the directory contains a asset file named "orphan-asset.jpg" +# And the manifest does not contain any row with asset_file_name "orphan-asset.jpg" +# When I run the "associate assets" command on the directory +# Then the app should not upload "orphan-asset.jpg" +# And the app should log a warning indicating assets in the directory without manifest entries +# And the command should still exit with a success status if all manifest-referenced assets are processed successfully +# +# @negative @invalid-csv +# Scenario: Manifest file has invalid CSV format +# Given the manifest file is not a valid 3-column CSV with the expected headers +# When I run the "associate assets" command on the directory +# Then the app should not upload any assets +# And the app should report that the manifest is invalid +# And the command should exit with a failure status +# +# @negative @missing-manifest +# Scenario: Manifest file is missing from the directory +# Given the directory does not contain "manifest.csv" +# When I run the "associate assets" command on the directory +# Then the app should not upload any assets +# And the app should report that the manifest file is required +# And the command should exit with a failure status +# +# @negative @upload-failure @gcs +# Scenario: GCS upload fails for a specific asset +# Given the manifest contains a valid row for "unstable-asset.jpg" +# And the directory contains "unstable-asset.jpg" +# And an error occurs while uploading "unstable-asset.jpg" to GCS +# When I run the "associate assets" command on the directory +# Then the app should report the upload failure for "unstable-asset.jpg" +# And the app should not create an association for "unstable-asset.jpg" +# And the app should continue processing other assets where possible +# And the command should exit with a failure status +# +# @negative @association-failure +# Scenario: Association cannot be created after successful upload +# Given the manifest contains a valid row for "orphan-association.jpg" +# And the directory contains "orphan-association.jpg" +# And the asset is successfully uploaded to GCS +# And an error occurs while creating the association to the corresponding thing +# When I run the "associate assets" command on the directory +# Then the app should not repeat the upload of "orphan-association.jpg" +# And the app should record that the association step failed +# And the command should exit with a failure status +# And the error details should identify the affected thing_name and asset_file_name +# +## @validation @asset-type +## Scenario Outline: Validate allowed asset types in the manifest +## Given the manifest contains a row for "" with thing "" and asset type "" +## And the directory contains "" +## When I run the "associate assets" command on the directory +## Then the app should "" for asset type "" +## And the app should in the run summary +## +## Examples: +## | asset_file_name | thing_name | asset_type | result | summary_status | +## | pump-002-front.jpg| PUMP-002 | asset_front | successfully upload and associate | report the row assuccessful | +## | pump-002-xray.jpg | PUMP-002 | unknown_type | reject processing of | report the row as invalid asset type | diff --git a/tests/features/data/asset_import_batch/AR0001_1.JPG b/tests/features/data/asset_import_batch/AR0001_1.JPG new file mode 100644 index 000000000..fdb6141b4 Binary files /dev/null and b/tests/features/data/asset_import_batch/AR0001_1.JPG differ diff --git a/tests/features/data/asset_import_batch/AR0001_2.JPG b/tests/features/data/asset_import_batch/AR0001_2.JPG new file mode 100644 index 000000000..82cb127fc Binary files /dev/null and b/tests/features/data/asset_import_batch/AR0001_2.JPG differ diff --git a/tests/features/data/asset_import_batch/manifest.txt b/tests/features/data/asset_import_batch/manifest.txt new file mode 100644 index 000000000..9081c4c97 --- /dev/null +++ b/tests/features/data/asset_import_batch/manifest.txt @@ -0,0 +1,3 @@ +asset_file_name,thing_name +AR0001_1.JPG, AR0001 +AR0001_2.JPG, AR0001 \ No newline at end of file diff --git a/tests/features/environment.py b/tests/features/environment.py index aa31f3c4d..123bc588f 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -16,6 +16,8 @@ import random from datetime import datetime, timedelta +from sqlalchemy import select + from core.initializers import erase_and_rebuild_db from db import ( Location, @@ -42,6 +44,8 @@ ThingAquiferAssociation, GeologicFormation, ThingGeologicFormationAssociation, + Base, + Asset, ) from db.engine import session_ctx @@ -86,7 +90,7 @@ def add_location(context, session): @add_context_object_container("wells") def add_well(context, session, location, name_num): well = Thing( - name=f"WL-{name_num:04d}", + name=f"AR{name_num:04d}", first_visit_date="2023-03-03", thing_type="water well", release_status="draft", @@ -496,10 +500,22 @@ def add_geologic_formation(context, session, formation_code, well): def before_all(context): context.objects = {} + rebuild = False - rebuild = True + # rebuild = True + erase_data = True if rebuild: erase_and_rebuild_db() + elif erase_data: + with session_ctx() as session: + for table in reversed(Base.metadata.sorted_tables): + if table.name in ("alembic_version", "parameter"): + continue + elif table.name.startswith("lexicon"): + continue + + session.execute(table.delete()) + session.commit() with session_ctx() as session: @@ -679,4 +695,25 @@ def after_all(context): session.commit() +def before_scenario(context, scenario): + # runs before EVERY scenario + # e.g. reset test data, open browser, etc. + pass + + +def after_scenario(context, scenario): + # runs after EVERY scenario + # e.g. clean up temp files, close db sessions + if scenario.name.startswith( + "Successfully upload and associate assets from a valid manifest" + ): + # delete all the assets uploaded for this scenario + with session_ctx() as session: + for uri in context.uris: + sql = select(Asset).where(Asset.uri == uri) + asset = session.scalars(sql).one() + session.delete(asset) + session.commit() + + # ============= EOF ============================================= diff --git a/tests/features/steps/cli-associate-assets.py b/tests/features/steps/cli-associate-assets.py new file mode 100644 index 000000000..e7b8ecef8 --- /dev/null +++ b/tests/features/steps/cli-associate-assets.py @@ -0,0 +1,167 @@ +# =============================================================================== +# Author: Jake Ross +# Copyright 2025 New Mexico Bureau of Geology & Mineral Resources +# 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 +# =============================================================================== +import csv +import mimetypes +from pathlib import Path + +from behave import given, when, then +from behave.runner import Context +from sqlalchemy import select + +from cli.service_adapter import associate_assets +from db import Thing, Asset +from db.engine import session_ctx +from services.gcs_helper import get_storage_bucket + + +@given('a local directory named "asset_import_batch"') +def step_impl(context: Context): + context.source_directory = ( + Path("tests") / "features" / "data" / "asset_import_batch" + ) + assert context.source_directory.exists() + assert context.source_directory.is_dir() + + +@given('the directory contains a manifest file named "manifest.txt"') +def step_impl(context: Context): + context.manifest_file = context.source_directory / "manifest.txt" + assert context.manifest_file.exists() + + +@given( + "the manifest file is a 2-column CSV with headers asset_file_name and thing_name" +) +def step_impl(context: Context): + header = ["asset_file_name", "thing_name"] + with open(context.manifest_file) as f: + reader = csv.DictReader(f) + inheader = reader.fieldnames + context.asset_file_names = [r["asset_file_name"] for r in reader] + + assert sorted(inheader) == sorted(header) + + +@given("the directory contains a set of asset files referenced in the manifest") +def step_impl(context: Context): + for a in context.asset_file_names: + p = context.source_directory / a + assert p.exists() + assert mimetypes.guess_type(str(p))[0] in ( + "image/png", + "image/jpeg", + "application/pdf", + ) + + +@given('the manifest contains a row for "{asset_file_name}" with thing "{thing_name}"') +def step_impl(context: Context, asset_file_name, thing_name): + with open(context.manifest_file) as f: + reader = csv.DictReader(f) + for r in reader: + if r["asset_file_name"].strip() == asset_file_name.strip(): + assert r["thing_name"].strip() == thing_name.strip() + break + else: + raise Exception(f"{asset_file_name} not found in manifest") + + +@given('the directory contains a asset file named "{asset_file_name}"') +def step_impl(context: Context, asset_file_name): + for path in context.source_directory.iterdir(): + if path.name == asset_file_name: + break + else: + raise Exception(f"{asset_file_name} not found in directory") + + +@when('I run the "associate_assets" command on the directory') +def step_impl(context: Context): + uris = associate_assets(context.source_directory) + context.uris = uris + + +@then('the app should upload "{asset_file_name}" to Google Cloud Storage') +def step_impl(context: Context, asset_file_name): + bucket = get_storage_bucket() + head, ext = asset_file_name.split(".") + for uri in context.uris: + blob = uri.split("/")[-1] + if blob.startswith(head): + if bucket.get_blob(blob): + break + else: + raise Exception(f"{asset_file_name} not found in gcs") + else: + raise Exception(f"{asset_file_name} not uploaded") + + +@then( + 'the app should create an association between the uploaded asset and thing "{thing_name}"' +) +def step_impl(context: Context, thing_name): + with session_ctx() as session: + sql = select(Thing).where(Thing.name == thing_name) + thing = session.scalars(sql).one_or_none() + if not thing: + raise Exception(f"Thing {thing_name} not found") + + assets = thing.assets + for uri in context.uris: + a = next((a for a in assets if a.uri == uri), None) + if a: + break + else: + raise Exception(f"No asset associated with uri {uri}") + else: + raise Exception(f"No asset associated with thing {thing_name}") + + +@given( + 'the manifest contains a row for "missing-asset.jpg" with a valid thing_name and asset_type' +) +def step_impl(context: Context): + context.manifest_file = context.source_directory / "manifest-missing-asset.txt" + assert context.manifest_file.exists() + + +@given('the directory does not contain a file named "missing-asset.jpg"') +def step_impl(context: Context): + assert not (context.source_directory / "missing-asset.jpg").exists() + + +@then("each photo listed in the manifest should be uploaded exactly once to GCS") +def step_impl(context: Context): + bucket = get_storage_bucket() + for uri in context.uris: + blob = uri.split("/")[-1] + assert bucket.get_blob(blob) is not None, f"{uri} not uploaded exactly once" + + +@then( + "each uploaded photo should be associated exactly once to its corresponding thing" +) +def step_impl(context: Context): + with session_ctx() as session: + for uri in context.uris: + sql = select(Asset).where(Asset.uri == uri) + a = session.scalars(sql).one_or_none() + assert ( + len(a.things) == 1 + ), f"{uri} associated with multiple things {[t.name for t in a.things]}" + + +@when( + 'I run the "associate photos" command on the same directory again with the same manifest' +) +def step_impl(context: Context): + uris = associate_assets(context.source_directory) + context.uris = uris + + +# ============= EOF ============================================= diff --git a/tests/features/steps/water-levels-csv.py b/tests/features/steps/water-levels-csv.py new file mode 100644 index 000000000..06901f74d --- /dev/null +++ b/tests/features/steps/water-levels-csv.py @@ -0,0 +1,362 @@ +# ============================================================================== +# 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 json +import tempfile +from pathlib import Path +from typing import Any, Dict, Iterable, List + +from behave import given, when, then +from behave.runner import Context + +from db import Observation +from db.engine import session_ctx +from services.water_level_csv import bulk_upload_water_levels + +REQUIRED_FIELDS: List[str] = [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", +] +OPTIONAL_FIELDS = ["water_level_notes"] +VALID_SAMPLERS = ["Groundwater Team", "Consultant"] +VALID_SAMPLE_METHODS = ["electric tape", "steel tape"] +VALID_LEVEL_STATUSES = ["stable", "rising", "falling"] +VALID_DATA_QUALITIES = ["approved", "provisional"] + + +def _available_well_names(context: Context) -> list[str]: + if not hasattr(context, "well_names"): + context.well_names = [well.name for well in context.objects["wells"]] + return context.well_names + + +def _base_row(context: Context, index: int) -> Dict[str, str]: + well_names = _available_well_names(context) + well_name = well_names[(index - 1) % len(well_names)] + measurement_day = 14 + index + return { + "field_staff": "A Lopez" if index == 1 else "B Chen", + "well_name_point_id": well_name, + "field_event_date_time": f"2025-02-{measurement_day:02d}T08:00:00-07:00", + "measurement_date_time": f"2025-02-{measurement_day:02d}T10:30:00-07:00", + "sampler": VALID_SAMPLERS[(index - 1) % len(VALID_SAMPLERS)], + "sample_method": VALID_SAMPLE_METHODS[(index - 1) % len(VALID_SAMPLE_METHODS)], + "mp_height": "1.5" if index == 1 else "1.8", + "level_status": VALID_LEVEL_STATUSES[(index - 1) % len(VALID_LEVEL_STATUSES)], + "depth_to_water_ft": "45.2" if index == 1 else "47.0", + "data_quality": VALID_DATA_QUALITIES[(index - 1) % len(VALID_DATA_QUALITIES)], + "water_level_notes": "Initial measurement" if index == 1 else "Follow-up", + } + + +def _build_valid_rows(context: Context, count: int = 2) -> List[Dict[str, str]]: + return [_base_row(context, i + 1) for i in range(count)] + + +def _serialize_csv(rows: List[Dict[str, Any]], headers: Iterable[str]) -> str: + header_line = ",".join(headers) + data_lines = [] + for row in rows: + values = [str(row.get(h, "")) for h in headers] + data_lines.append(",".join(values)) + return "\n".join([header_line, *data_lines]) + + +def _write_csv_to_context(context: Context) -> None: + csv_text = _serialize_csv(context.csv_rows, context.csv_headers) + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") + temp_file.write(csv_text.encode("utf-8")) + temp_file.flush() + temp_file.close() + context.csv_file = str(Path(temp_file.name)) + context.csv_raw_text = csv_text + + +def _set_rows( + context: Context, rows: List[Dict[str, str]], headers: List[str] | None = None +) -> None: + context.csv_rows = rows + if headers is not None: + context.csv_headers = headers + elif rows: + context.csv_headers = list(rows[0].keys()) + else: + context.csv_headers = list(REQUIRED_FIELDS) + _write_csv_to_context(context) + context.stdout_json = None + + +def _ensure_stdout_json(context: Context) -> Dict[str, Any]: + if not hasattr(context, "stdout_json") or context.stdout_json is None: + context.stdout_json = json.loads(context.cli_result.stdout) + return context.stdout_json + + +# ============================================================================ +# Scenario: Uploading a valid water level entry CSV containing required fields +# ============================================================================ +@given("a valid CSV file for bulk water level entry upload") +def step_impl(context: Context): + rows = _build_valid_rows(context) + _set_rows(context, rows) + + +@given("my CSV file is encoded in UTF-8 and uses commas as separators") +def step_impl(context: Context): + assert context.csv_raw_text.encode("utf-8").decode("utf-8") == context.csv_raw_text + assert "," in context.csv_raw_text.splitlines()[0] + + +@given("my CSV file contains multiple rows of water level entry data") +def step_impl(context: Context): + assert len(context.csv_rows) >= 2 + + +@given("the CSV includes required fields:") +def step_impl(context: Context): + field_name = context.table.headings[0] + expected_fields = [row[field_name].strip() for row in context.table] + headers = set(context.csv_headers) + missing = [field for field in expected_fields if field not in headers] + assert not missing, f"Missing required headers: {missing}" + + +@given('each "well_name_point_id" value matches an existing well') +def step_impl(context: Context): + available = set(_available_well_names(context)) + for row in context.csv_rows: + assert ( + row["well_name_point_id"] in available + ), f"Unknown well identifier {row['well_name_point_id']}" + + +@given( + '"measurement_date_time" values are valid ISO 8601 timestamps with timezone offsets (e.g. "2025-02-15T10:30:00-08:00")' +) +def step_impl(context: Context): + for row in context.csv_rows: + assert row["measurement_date_time"].startswith("2025-02") + assert "T" in row["measurement_date_time"] + + +@given("the CSV includes optional fields when available:") +def step_impl(context: Context): + field_name = context.table.headings[0] + optional_fields = [row[field_name].strip() for row in context.table] + headers = set(context.csv_headers) + missing = [field for field in optional_fields if field not in headers] + assert not missing, f"Missing optional headers: {missing}" + + +@when("I run the CLI command:") +def step_impl(context: Context): + command_text = (context.text or "").strip() + context.command_text = command_text + output_json = "--output json" in command_text.lower() + _write_csv_to_context(context) + context.cli_result = bulk_upload_water_levels( + context.csv_file, pretty_json=output_json + ) + context.stdout_json = None + + +@then("the command exits with code 0") +def step_impl(context: Context): + assert context.cli_result.exit_code == 0, context.cli_result.stderr + + +@then("stdout should be valid JSON") +def step_impl(context: Context): + _ensure_stdout_json(context) + + +@then("stdout includes a summary containing:") +def step_impl(context: Context): + payload = _ensure_stdout_json(context) + summary = payload.get("summary", {}) + for row in context.table: + field = row[context.table.headings[0]].strip() + expected_value = row[context.table.headings[1]].strip() + actual = summary.get(field) + expected = int(expected_value) if expected_value.isdigit() else expected_value + assert ( + actual == expected + ), f"Summary field {field} expected {expected} but got {actual}" + + +@then("stdout includes an array of created water level entry objects") +def step_impl(context: Context): + payload = _ensure_stdout_json(context) + rows = payload.get("water_levels", []) + assert rows, "Expected created water level records" + with session_ctx() as session: + for row in rows: + assert "well_name_point_id" in row + assert "measurement_date_time" in row + obs = session.get(Observation, row["observation_id"]) + assert obs is not None, "Observation missing from database" + + +@then("stderr should be empty") +def step_impl(context: Context): + assert context.cli_result.stderr == "" + + +# ============================================================================ +# Scenario: Upload succeeds when required columns are present but reordered +# ============================================================================ +@given("my CSV file contains all required headers but in a different column order") +def step_impl(context: Context): + rows = _build_valid_rows(context) + headers = list(reversed(list(rows[0].keys()))) + _set_rows(context, rows, headers=headers) + assert headers != list(rows[0].keys()) + + +@then("all water level entries are imported") +def step_impl(context: Context): + payload = _ensure_stdout_json(context) + summary = payload["summary"] + assert summary["total_rows_processed"] == summary["total_rows_imported"] + assert summary["total_rows_imported"] > 0 + + +# ============================================================================ +# Scenario: Upload succeeds when CSV contains extra columns +# ============================================================================ +@given("my CSV file contains extra columns but is otherwise valid") +def step_impl(context: Context): + rows = _build_valid_rows(context) + for idx, row in enumerate(rows): + row["custom_note"] = f"extra-{idx}" + headers = list(rows[0].keys()) + _set_rows(context, rows, headers=headers) + assert "custom_note" in context.csv_headers + + +# ============================================================================ +# Scenario: No entries imported when any row fails validation +# ============================================================================ +@given( + 'my CSV file contains 3 rows of data with 2 valid rows and 1 row missing the required "well_name_point_id"' +) +def step_impl(context: Context): + rows = _build_valid_rows(context, count=3) + rows[2]["well_name_point_id"] = "" + _set_rows(context, rows) + context.missing_field = "well_name_point_id" + + +@then("the command exits with a non-zero exit code") +def step_impl(context: Context): + assert context.cli_result.exit_code != 0 + + +@then( + 'stderr should contain a validation error for the row missing "well_name_point_id"' +) +def step_impl(context: Context): + assert "well_name_point_id" in context.cli_result.stderr + + +@then("no water level entries are imported") +def step_impl(context: Context): + payload = _ensure_stdout_json(context) + summary = payload["summary"] + assert summary["total_rows_imported"] == 0 + + +# ============================================================================ +# Scenario Outline: Upload fails when a required field is missing +# ============================================================================ +@given('my CSV file contains a row missing the required "{required_field}" field') +def step_impl(context: Context, required_field: str): + rows = _build_valid_rows(context, count=1) + rows[0][required_field] = "" + _set_rows(context, rows) + context.missing_field = required_field + + +@then('stderr should contain a validation error for the "{required_field}" field') +def step_impl(context: Context, required_field: str): + assert required_field in context.cli_result.stderr + + +# ============================================================================ +# Scenario: Upload fails due to invalid date formats +# ============================================================================ +@given( + 'my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field' +) +def step_impl(context: Context): + rows = _build_valid_rows(context, count=1) + rows[0]["measurement_date_time"] = "02/15/2025 10:30" + _set_rows(context, rows) + context.invalid_fields = ["measurement_date_time"] + + +@then("stderr should contain validation errors identifying the invalid field and row") +def step_impl(context: Context): + stderr = context.cli_result.stderr + assert stderr, "Expected stderr output" + for field in getattr(context, "invalid_fields", []): + assert field in stderr + assert "Row" in stderr + + +# ============================================================================ +# Scenario: Upload fails due to invalid numeric fields +# ============================================================================ +@given( + 'my CSV file contains values that cannot be parsed as numeric in numeric-required fields such as "mp_height" or "depth_to_water_ft"' +) +def step_impl(context: Context): + rows = _build_valid_rows(context, count=1) + rows[0]["mp_height"] = "one point five" + rows[0]["depth_to_water_ft"] = "forty" + _set_rows(context, rows) + context.invalid_fields = ["mp_height", "depth_to_water_ft"] + + +# ============================================================================ +# Scenario: Upload fails due to invalid lexicon values +# ============================================================================ +@given( + 'my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality"' +) +def step_impl(context: Context): + rows = _build_valid_rows(context, count=1) + rows[0]["sampler"] = "Unknown Team" + rows[0]["sample_method"] = "mystery" + rows[0]["level_status"] = "supercharged" + rows[0]["data_quality"] = "bad" + _set_rows(context, rows) + context.invalid_fields = [ + "sampler", + "sample_method", + "level_status", + "data_quality", + ] + + +# ============= EOF ============================================= diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature new file mode 100644 index 000000000..4bdbe9c0d --- /dev/null +++ b/tests/features/water-level-csv.feature @@ -0,0 +1,159 @@ +# features/cli/bulk_upload_water_levels.feature + +@skip +@cli +@backend +@BDMS-TBD +@production +Feature: Bulk upload water level entries from CSV via CLI + As a hydrogeologist or data specialist + I want to upload a CSV file containing water level entry data for multiple wells using a CLI command + So that water level records can be created efficiently and accurately in the system + +# Background: +# Given the CLI binary "bdms" is installed and available on the PATH +# And I have a valid CLI configuration for the target environment +# And valid lexicon values exist for: +# | lexicon category | +# | sampler | +# | sample_method | +# | level_status | +# | data_quality | + + @positive @happy_path @BDMS-TBD + Scenario: Uploading a valid water level entry CSV containing required and optional fields + Given a valid CSV file for bulk water level entry upload + And my CSV file is encoded in UTF-8 and uses commas as separators + And my CSV file contains multiple rows of water level entry data + And the CSV includes required fields: + | required field name | + | field_staff | + | well_name_point_id | + | field_event_date_time | + | measurement_date_time | + | sampler | + | sample_method | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | + And each "well_name_point_id" value matches an existing well + And "measurement_date_time" values are valid ISO 8601 timestamps with timezone offsets (e.g. "2025-02-15T10:30:00-08:00") + And the CSV includes optional fields when available: + | optional field name | + | water_level_notes | + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv --output json + """ + Then the command exits with code 0 + And stdout should be valid JSON + And stdout includes a summary containing: + | summary_field | value | + | total_rows_processed | 2 | + | total_rows_imported | 2 | + | validation_errors_or_warnings | 0 | + And stdout includes an array of created water level entry objects + And stderr should be empty + + @positive @validation @column_order @BDMS-TBD + Scenario: Upload succeeds when required columns are present but in a different order + Given my CSV file contains all required headers but in a different column order + And the CSV includes required fields: + | required field name | + | well_name_point_id | + | measurement_date_time | + | sampler | + | sample_method | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with code 0 + And all water level entries are imported + And stderr should be empty + + @positive @validation @extra_columns @BDMS-TBD + Scenario: Upload succeeds when CSV contains extra, unknown columns + Given my CSV file contains extra columns but is otherwise valid + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with code 0 + And all water level entries are imported + And stderr should be empty + + ########################################################################### + # NEGATIVE VALIDATION SCENARIOS + ########################################################################### + + @negative @validation @BDMS-TBD + Scenario: No water level entries are imported when any row fails validation + Given my CSV file contains 3 rows of data with 2 valid rows and 1 row missing the required "well_name_point_id" + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with a non-zero exit code + And stderr should contain a validation error for the row missing "well_name_point_id" + And no water level entries are imported + + @negative @validation @required_fields @BDMS-TBD + Scenario Outline: Upload fails when a required field is missing + Given my CSV file contains a row missing the required "" field + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with a non-zero exit code + And stderr should contain a validation error for the "" field + And no water level entries are imported + + Examples: + | required_field | + | well_name_point_id | + | measurement_date_time | + | sampler | + | sample_method | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | + + @negative @validation @date_formats @BDMS-TBD + Scenario: Upload fails due to invalid date formats + Given my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with a non-zero exit code + And stderr should contain validation errors identifying the invalid field and row + And no water level entries are imported + + @negative @validation @numeric_fields @BDMS-TBD + Scenario: Upload fails due to invalid numeric fields + Given my CSV file contains values that cannot be parsed as numeric in numeric-required fields such as "mp_height" or "depth_to_water_ft" + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with a non-zero exit code + And stderr should contain validation errors identifying the invalid field and row + And no water level entries are imported + + @negative @validation @lexicon_values @BDMS-TBD + Scenario: Upload fails due to invalid lexicon values + Given my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality" + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv + """ + Then the command exits with a non-zero exit code + And stderr should contain validation errors identifying the invalid field and row + And no water level entries are imported diff --git a/tests/test_observation.py b/tests/test_observation.py index 3a9c7cf10..643684111 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -25,7 +25,8 @@ amp_editor_function, viewer_function, ) -from db import Observation +from db import Observation, FieldEvent, FieldActivity, Sample +from db.engine import session_ctx from main import app from schemas import DT_FMT from tests import ( @@ -118,6 +119,71 @@ def test_add_groundwater_level_observation(groundwater_level_sample, sensor): cleanup_post_test(Observation, data["id"]) +def test_bulk_upload_groundwater_levels_api(water_well_thing): + csv_content = ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ) + csv_content += "\n" + csv_content += ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "Groundwater Team", + "electric tape", + "1.5", + "stable", + "45.2", + "approved", + "Initial measurement", + ] + ) + + files = { + "file": ("water_levels.csv", csv_content, "text/csv"), + } + + response = client.post("/observation/groundwater-level/bulk-upload", files=files) + data = response.json() + assert response.status_code == 200 + assert data["summary"]["total_rows_imported"] == 1 + assert data["summary"]["total_rows_processed"] == 1 + assert data["summary"]["validation_errors_or_warnings"] == 0 + assert data["validation_errors"] == [] + row = data["water_levels"][0] + assert row["well_name_point_id"] == water_well_thing.name + + with session_ctx() as session: + observation = session.get(Observation, row["observation_id"]) + assert observation is not None + # cleanup in reverse dependency order + if observation: + session.delete(observation) + sample = session.get(Sample, row["sample_id"]) + if sample: + session.delete(sample) + field_activity = session.get(FieldActivity, row["field_activity_id"]) + if field_activity: + session.delete(field_activity) + field_event = session.get(FieldEvent, row["field_event_id"]) + if field_event: + session.delete(field_event) + session.commit() + + # PATCH tests ================================================================== diff --git a/transfers/asset_transfer.py b/transfers/asset_transfer.py index 5783fe00b..d8ec6525b 100644 --- a/transfers/asset_transfer.py +++ b/transfers/asset_transfer.py @@ -18,9 +18,9 @@ from sqlalchemy.orm import Session from starlette.datastructures import UploadFile -from db import Asset, AssetThingAssociation, Thing +from db import Thing +from services.asset_helper import upload_and_associate from services.gcs_helper import ( - gcs_upload, get_storage_bucket, get_storage_client, ) @@ -81,22 +81,15 @@ def _asset_step(self, session, i, db_item): f = srcblob.download_as_bytes() ff = UploadFile(file=io.BytesIO(f), filename=filename, size=len(f)) - uri, blob_name = gcs_upload(ff, self._bucket) - asset = Asset( - name=filename, - label=filename, - storage_path=blob_name, - storage_service="gcs", - mime_type="image/png", - size=ff.size, - uri=uri, + uri = upload_and_associate( + session, + ff, + self._bucket, + db_item, + filename, + **{"label": filename, "mime_type": "image/png"}, ) - assoc = AssetThingAssociation() - assoc.thing = db_item - assoc.asset = asset - session.add(assoc) - session.add(asset) - # session.commit() + logger.info( f"Added asset {i}-{j}/{n} thing.id={db_item.id} thing={db_item.name} uri: {uri}" ) diff --git a/uv.lock b/uv.lock index 61ebbba0d..083edcd3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -92,6 +92,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -528,16 +537,17 @@ wheels = [ [[package]] name = "fastapi" -version = "0.116.1" +version = "0.124.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/b7/4dbca3f9d847ba9876dcb7098c13a4c6c86ee8db148c923fab78e27748d3/fastapi-0.124.2.tar.gz", hash = "sha256:72e188f01f360e2f59da51c8822cbe4bca210c35daaae6321b1b724109101c00", size = 361867, upload-time = "2025-12-10T12:10:10.676Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, + { url = "https://files.pythonhosted.org/packages/25/c5/8a5231197b81943b2df126cc8ea2083262e004bee3a39cf85a471392d145/fastapi-0.124.2-py3-none-any.whl", hash = "sha256:6314385777a507bb19b34bd064829fddaea0eea54436deb632b5de587554055c", size = 112711, upload-time = "2025-12-10T12:10:08.855Z" }, ] [[package]] @@ -929,7 +939,68 @@ wheels = [ ] [[package]] -name = "nmsamplelocations" +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, + { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, + { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, + { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, + { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, + { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, + { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, +] + +[[package]] +name = "ocotilloapi" version = "0.1.0" source = { virtual = "." } dependencies = [ @@ -1064,7 +1135,7 @@ requires-dist = [ { name = "dnspython", specifier = "==2.7.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "email-validator", specifier = "==2.2.0" }, - { name = "fastapi", specifier = "==0.116.1" }, + { name = "fastapi", specifier = "==0.124.2" }, { name = "fastapi-pagination", specifier = "==0.14.3" }, { name = "frozenlist", specifier = "==1.7.0" }, { name = "geoalchemy2", specifier = "==0.18.0" }, @@ -1091,7 +1162,7 @@ requires-dist = [ { name = "packaging", specifier = "==25.0" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "==2.3.0.250703" }, - { name = "pg8000", specifier = "==1.31.4" }, + { name = "pg8000", specifier = "==1.31.5" }, { name = "phonenumbers", specifier = "==9.0.13" }, { name = "pillow", specifier = "==11.3.0" }, { name = "pluggy", specifier = "==1.6.0" }, @@ -1126,11 +1197,11 @@ requires-dist = [ { name = "sqlalchemy-continuum", specifier = "==1.4.2" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.0" }, - { name = "starlette", specifier = "==0.47.3" }, + { name = "starlette", specifier = "==0.49.1" }, { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.1" }, { name = "tzdata", specifier = "==2025.2" }, - { name = "urllib3", specifier = "==2.5.0" }, + { name = "urllib3", specifier = "==2.6.0" }, { name = "uvicorn", specifier = "==0.38.0" }, { name = "yarl", specifier = "==1.20.1" }, ] @@ -1145,67 +1216,6 @@ dev = [ { name = "requests", specifier = ">=2.32.5" }, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - -[[package]] -name = "numpy" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, - { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, - { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, - { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, - { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, - { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, - { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, - { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, - { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, - { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, - { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, - { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1279,15 +1289,15 @@ wheels = [ [[package]] name = "pg8000" -version = "1.31.4" +version = "1.31.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, { name = "scramp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/dd/bcf762fca8768973ffe3e9f0ef9809c11ff95e6f80115bc8c7b2492a969e/pg8000-1.31.4.tar.gz", hash = "sha256:e7ecce4339891f27b0b22e2f79eb9efe44118bd384207359fc18350f788ace00", size = 115515, upload-time = "2025-07-20T17:18:37.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/9a/077ab21e700051e03d8c5232b6bcb9a1a4d4b6242c9a0226df2cfa306414/pg8000-1.31.5.tar.gz", hash = "sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78", size = 118933, upload-time = "2025-09-14T09:16:49.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/da/0fc4596b75f07a93dacb322063a5d139aefbe1b5b68997bf1e9c4aa86667/pg8000-1.31.4-py3-none-any.whl", hash = "sha256:d14fb2054642ee80f9a216721892e99e19db60a005358460ffa48872351423d4", size = 55407, upload-time = "2025-07-20T17:18:36.151Z" }, + { url = "https://files.pythonhosted.org/packages/45/07/5fd183858dff4d24840f07fc845f213cd371a19958558607ba22035dadd7/pg8000-1.31.5-py3-none-any.whl", hash = "sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201", size = 57816, upload-time = "2025-09-14T09:16:47.798Z" }, ] [[package]] @@ -1893,14 +1903,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.47.3" +version = "0.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, ] [[package]] @@ -1944,11 +1954,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.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" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585, upload-time = "2025-12-05T15:08:47.885Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083, upload-time = "2025-12-05T15:08:45.983Z" }, ] [[package]]