From 94180f4f19f8356c3e065fdf1dea9a4e7c8c740d Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 9 Dec 2025 16:51:24 -0700 Subject: [PATCH 01/18] feat: implement asset upload and association functionality with manifest support --- manage.py | 76 ++++++++++-- services/gcs_helper.py | 8 +- services/validation/asset_helper.py | 44 +++++++ tests/features/cli-associate-assets.feature | 114 ++++++++++++++++++ tests/features/environment.py | 2 + .../steps/asset_import_batch/manifest.txt | 4 + tests/features/steps/cli-associate-assets.py | 75 ++++++++++++ transfers/asset_transfer.py | 27 ++--- 8 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 services/validation/asset_helper.py create mode 100644 tests/features/cli-associate-assets.feature create mode 100644 tests/features/steps/asset_import_batch/manifest.txt create mode 100644 tests/features/steps/cli-associate-assets.py diff --git a/manage.py b/manage.py index 7b9f24a1c..fd977473b 100644 --- a/manage.py +++ b/manage.py @@ -13,7 +13,17 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import csv +import mimetypes + from dotenv import load_dotenv +from fastapi import UploadFile +from sqlalchemy import select + +from db import Thing +from db.engine import session_ctx +from services.gcs_helper import get_storage_bucket +from services.validation.asset_helper import upload_and_associate load_dotenv() @@ -21,17 +31,6 @@ 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.""" @@ -43,6 +42,61 @@ def initialize_lexicon(): init_lexicon() +@cli.command() +def associate_assets(): + """ + 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 + + """ + + bucket = get_storage_bucket() + m = "manifest.txt" + with open(m, "r") as rf: + reader = csv.DictReader(rf) + + with session_ctx() as sess: + for row in reader: + # save file to gcs + path = row["asset_file_name"] + + with open(path, "rb") as fp: + file = UploadFile(fp) + + sql = select(Thing).where(Thing.name == row["thing_name"]) + thing = sess.scalars(sql).one_or_none() + if thing: + # get mime_type from file + mime_type, encoding = mimetypes.guess_type(path) + upload_and_associate( + sess, file, bucket, thing, path, **{"mime_type": mime_type} + ) + else: + pass + + +@cli.command() +def well_inventory_csv(): + """ + parse and upload a csv to database + """ + # TODO: use the same helper function used by api to parse and upload a WI csv + + +@cli.command() +def waterlevels_csv(): + """ + parse and upload a csv + """ + # TODO: use the same helper function used by api to parse and upload a WL csv + + if __name__ == "__main__": cli() diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 41ada4481..f870b92a9 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -51,12 +51,14 @@ 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 gcs_upload(file: UploadFile, bucket: storage.Bucket = None): diff --git a/services/validation/asset_helper.py b/services/validation/asset_helper.py new file mode 100644 index 000000000..6781715cb --- /dev/null +++ b/services/validation/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, +) -> 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 + + +# ============= EOF ============================================= diff --git a/tests/features/cli-associate-assets.feature b/tests/features/cli-associate-assets.feature new file mode 100644 index 000000000..c0fd5a7f1 --- /dev/null +++ b/tests/features/cli-associate-assets.feature @@ -0,0 +1,114 @@ +# 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 3-column CSV with headers asset_file_name, thing_name and asset_type + 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 asset type "" + 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 uploaded asset should be stored under the expected GCS bucket and path for "" +# 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 | asset_type | + | pump-001-front.jpg | PUMP-001-POINTID | asset_front | + | pump-001-side.jpg | PUMP-001-POINTID | asset_side | + | panel-123-name.png | PANEL-123 | nameplate | + +# @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/environment.py b/tests/features/environment.py index 1d655a4da..5e08ac683 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -496,6 +496,8 @@ def add_geologic_formation(context, session, formation_code, well): def before_all(context): context.objects = {} + return + rebuild = False rebuild = True if rebuild: diff --git a/tests/features/steps/asset_import_batch/manifest.txt b/tests/features/steps/asset_import_batch/manifest.txt new file mode 100644 index 000000000..a5dafe59e --- /dev/null +++ b/tests/features/steps/asset_import_batch/manifest.txt @@ -0,0 +1,4 @@ +asset_file_name,thing_name,asset_type +pump-001-front.jpg,PUMP-001-POINTID,asset_front +pump-001-side.jpg,PUMP-001-POINTID,asset_side +panel-123-name.png,PANEL-123,nameplate \ No newline at end of file diff --git a/tests/features/steps/cli-associate-assets.py b/tests/features/steps/cli-associate-assets.py new file mode 100644 index 000000000..6d0476d06 --- /dev/null +++ b/tests/features/steps/cli-associate-assets.py @@ -0,0 +1,75 @@ +# =============================================================================== +# 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 +from behave.runner import Context + + +@given('a local directory named "asset_import_batch"') +def step_impl(context: Context): + context.source_directory = ( + Path("tests") / "features" / "steps" / "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 3-column CSV with headers asset_file_name, thing_name and asset_type" +) +def step_impl(context: Context): + header = ["asset_file_name", "thing_name", "asset_type"] + with open(context.manifest_file) as f: + inheader = csv.DictReader(f).fieldnames + + assert sorted(inheader) == sorted(header) + + +@given("the directory contains a set of asset files referenced in the manifest") +def step_impl(context: Context): + for path in context.source_directory.iterdir(): + if path.name == "manifest.txt": + continue + + assert mimetypes.guess_type(str(path)) in ("image/png", "application/pdf") + + +@given( + 'the manifest contains a row for "{asset_file_name}" with thing "{thing_name}" and asset type "{asset_type}"' +) +def step_impl(context: Context, asset_file_name, thing_name, asset_type): + with open(context.manifest_file) as f: + reader = csv.DictReader(f) + for r in reader: + if r["asset_file_name"] == asset_file_name: + assert r["thing_name"] == thing_name + assert r["asset_type"] == asset_type + 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") + + +# ============= EOF ============================================= diff --git a/transfers/asset_transfer.py b/transfers/asset_transfer.py index 5783fe00b..f031e96cc 100644 --- a/transfers/asset_transfer.py +++ b/transfers/asset_transfer.py @@ -18,12 +18,12 @@ from sqlalchemy.orm import Session from starlette.datastructures import UploadFile -from db import Asset, AssetThingAssociation, Thing +from db import Thing from services.gcs_helper import ( - gcs_upload, get_storage_bucket, get_storage_client, ) +from services.validation.asset_helper import upload_and_associate from transfers.logger import logger from transfers.transferer import Transferer from transfers.util import read_csv, filter_to_valid_point_ids @@ -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}" ) From ca4750924192e8a749979ab6e0ef5cc0d6000537 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 9 Dec 2025 17:29:34 -0700 Subject: [PATCH 02/18] feat: update asset association command to return blob names and enhance feature tests --- manage.py | 49 ++++++++++++-------- services/validation/asset_helper.py | 4 +- tests/features/cli-associate-assets.feature | 5 +- tests/features/steps/cli-associate-assets.py | 25 +++++++++- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/manage.py b/manage.py index fd977473b..e523050c1 100644 --- a/manage.py +++ b/manage.py @@ -15,6 +15,7 @@ # =============================================================================== import csv import mimetypes +from pathlib import Path from dotenv import load_dotenv from fastapi import UploadFile @@ -43,7 +44,27 @@ def initialize_lexicon(): @cli.command() -def associate_assets(): +def associate_assets_command(): + associate_assets() + + +@cli.command() +def well_inventory_csv(): + """ + parse and upload a csv to database + """ + # TODO: use the same helper function used by api to parse and upload a WI csv + + +@cli.command() +def waterlevels_csv(): + """ + parse and upload a csv + """ + # TODO: use the same helper function used by api to parse and upload a WL csv + + +def associate_assets(source_directory: Path) -> list[str]: """ given a directory and the directory contains a manifest file @@ -57,16 +78,17 @@ def associate_assets(): """ bucket = get_storage_bucket() - m = "manifest.txt" + m = source_directory / "manifest.txt" with open(m, "r") as rf: reader = csv.DictReader(rf) + blobs = [] with session_ctx() as sess: for row in reader: # save file to gcs path = row["asset_file_name"] - with open(path, "rb") as fp: + with open(source_directory / path, "rb") as fp: file = UploadFile(fp) sql = select(Thing).where(Thing.name == row["thing_name"]) @@ -74,27 +96,14 @@ def associate_assets(): if thing: # get mime_type from file mime_type, encoding = mimetypes.guess_type(path) - upload_and_associate( + uri, blob_name = upload_and_associate( sess, file, bucket, thing, path, **{"mime_type": mime_type} ) + blobs.append(blob_name) + else: pass - - -@cli.command() -def well_inventory_csv(): - """ - parse and upload a csv to database - """ - # TODO: use the same helper function used by api to parse and upload a WI csv - - -@cli.command() -def waterlevels_csv(): - """ - parse and upload a csv - """ - # TODO: use the same helper function used by api to parse and upload a WL csv + return blobs if __name__ == "__main__": diff --git a/services/validation/asset_helper.py b/services/validation/asset_helper.py index 6781715cb..83c48509d 100644 --- a/services/validation/asset_helper.py +++ b/services/validation/asset_helper.py @@ -21,7 +21,7 @@ def upload_and_associate( thing: Thing, name: str, **asset_args, -) -> str: +) -> tuple[str, str]: uri, blob_name = gcs_upload(ff, bucket) asset = Asset( name=name, @@ -38,7 +38,7 @@ def upload_and_associate( assoc.asset = asset session.add(assoc) session.add(asset) - return uri + return uri, blob_name # ============= EOF ============================================= diff --git a/tests/features/cli-associate-assets.feature b/tests/features/cli-associate-assets.feature index c0fd5a7f1..bf24a3caf 100644 --- a/tests/features/cli-associate-assets.feature +++ b/tests/features/cli-associate-assets.feature @@ -15,9 +15,8 @@ Feature: Associate assets with things based on a manifest file Scenario Outline: Successfully upload and associate assets from a valid manifest Given the manifest contains a row for "" with thing "" and asset type "" 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 uploaded asset should be stored under the expected GCS bucket and path for "" + 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 | diff --git a/tests/features/steps/cli-associate-assets.py b/tests/features/steps/cli-associate-assets.py index 6d0476d06..1bd2226dc 100644 --- a/tests/features/steps/cli-associate-assets.py +++ b/tests/features/steps/cli-associate-assets.py @@ -9,9 +9,12 @@ import mimetypes from pathlib import Path -from behave import given +from behave import given, when, then from behave.runner import Context +from manage import associate_assets +from services.gcs_helper import get_storage_bucket + @given('a local directory named "asset_import_batch"') def step_impl(context: Context): @@ -72,4 +75,24 @@ def step_impl(context: Context, asset_file_name): 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): + uploaded_blobs = associate_assets(context.source_directory) + context.upload_blobs = uploaded_blobs + + +@then('the app should upload "" to Google Cloud Storage') +def step_impl(context: Context, asset_file_name): + bucket = get_storage_bucket() + head, ext = asset_file_name.split(".") + for blob in context.uploaded_blobs: + if blob.startswith(head): + if bucket.get_blob(blob): + break + else: + raise Exception(f"{blob} not found in gcs") + else: + raise Exception(f"{blob} not uploaded") + + # ============= EOF ============================================= From e17ac7b7f5aa418bb12cc73c11e85cb33e61aff6 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 9 Dec 2025 20:54:03 -0700 Subject: [PATCH 03/18] feat: update asset association process to support 2-column manifest and improve error handling --- manage.py | 51 ++++++------ tests/features/cli-associate-assets.feature | 14 ++-- tests/features/environment.py | 9 ++- .../steps/asset_import_batch/AR0001_1.JPG | Bin 0 -> 95809 bytes .../steps/asset_import_batch/AR0001_2.JPG | Bin 0 -> 91756 bytes .../steps/asset_import_batch/manifest.txt | 7 +- tests/features/steps/cli-associate-assets.py | 73 ++++++++++++------ 7 files changed, 94 insertions(+), 60 deletions(-) create mode 100644 tests/features/steps/asset_import_batch/AR0001_1.JPG create mode 100644 tests/features/steps/asset_import_batch/AR0001_2.JPG diff --git a/manage.py b/manage.py index e523050c1..8a2d64c74 100644 --- a/manage.py +++ b/manage.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== import csv +import io import mimetypes from pathlib import Path @@ -79,31 +80,35 @@ def associate_assets(source_directory: Path) -> list[str]: bucket = get_storage_bucket() m = source_directory / "manifest.txt" - with open(m, "r") as rf: - reader = csv.DictReader(rf) - blobs = [] + uris = [] with session_ctx() as sess: - for row in reader: - # save file to gcs - path = row["asset_file_name"] - - with open(source_directory / path, "rb") as fp: - file = UploadFile(fp) - - sql = select(Thing).where(Thing.name == row["thing_name"]) - thing = sess.scalars(sql).one_or_none() - if thing: - # get mime_type from file - mime_type, encoding = mimetypes.guess_type(path) - uri, blob_name = upload_and_associate( - sess, file, bucket, thing, path, **{"mime_type": mime_type} - ) - blobs.append(blob_name) - - else: - pass - return blobs + 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) + uri, blob_name = upload_and_associate( + sess, file, bucket, thing, path, **{"mime_type": mime_type} + ) + uris.append(uri) + + else: + pass + sess.commit() + + return uris if __name__ == "__main__": diff --git a/tests/features/cli-associate-assets.feature b/tests/features/cli-associate-assets.feature index bf24a3caf..81a9654ff 100644 --- a/tests/features/cli-associate-assets.feature +++ b/tests/features/cli-associate-assets.feature @@ -8,16 +8,16 @@ Feature: Associate assets with things based on a manifest file 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 3-column CSV with headers asset_file_name, thing_name and asset_type + 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 asset type "" + 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 app should create an association between the uploaded asset and thing "" # And the association should record: # | field | value | # | thing_name | | @@ -27,10 +27,10 @@ Feature: Associate assets with things based on a manifest file # And the command should exit with a success status Examples: - | asset_file_name | thing_name | asset_type | - | pump-001-front.jpg | PUMP-001-POINTID | asset_front | - | pump-001-side.jpg | PUMP-001-POINTID | asset_side | - | panel-123-name.png | PANEL-123 | nameplate | + | asset_file_name | thing_name | + | AR0001_1.JPG | AR0001 | + | AR0001_2.JPG | AR0001 | + # @multiple_rows @idempotent # Scenario: Upload and associate multiple assets in a single run diff --git a/tests/features/environment.py b/tests/features/environment.py index 5e08ac683..c70f7fb8f 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -86,7 +86,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 +496,9 @@ def add_geologic_formation(context, session, formation_code, well): def before_all(context): context.objects = {} - return rebuild = False - rebuild = True + # rebuild = True if rebuild: erase_and_rebuild_db() @@ -513,6 +512,10 @@ def before_all(context): well_1 = add_well(context, session, loc_1, name_num=1) well_2 = add_well(context, session, loc_2, name_num=2) well_3 = add_well(context, session, loc_3, name_num=3) + session.commit() + print("asdf", well_1, well_1.name) + return + spring_4 = add_spring(context, session, loc_4, name_num=4) sensor_1 = add_sensor(context, session) deployment = add_deployment(context, session, well_1.id, sensor_1.id) diff --git a/tests/features/steps/asset_import_batch/AR0001_1.JPG b/tests/features/steps/asset_import_batch/AR0001_1.JPG new file mode 100644 index 0000000000000000000000000000000000000000..fdb6141b417c4f95806a02c40e5e123b2cc7581d GIT binary patch literal 95809 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 literal 0 HcmV?d00001 diff --git a/tests/features/steps/asset_import_batch/AR0001_2.JPG b/tests/features/steps/asset_import_batch/AR0001_2.JPG new file mode 100644 index 0000000000000000000000000000000000000000..82cb127fc2ab74b9f3cfa96041ffd29d1e0ca8fd GIT binary patch literal 91756 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༿pLKhrf=q?%CHOa{p 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" to Google Cloud Storage') +@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 blob in context.uploaded_blobs: + for uri in context.uris: + blob = uri.split("/")[-1] if blob.startswith(head): if bucket.get_blob(blob): break else: - raise Exception(f"{blob} not found in gcs") + raise Exception(f"{asset_file_name} not found in gcs") else: - raise Exception(f"{blob} not uploaded") + 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}") # ============= EOF ============================================= From d91f4e583491a47af2974a55d91743b50985a91b Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 9 Dec 2025 22:51:51 -0700 Subject: [PATCH 04/18] feat: enhance asset association process with idempotent behavior and improved error handling --- manage.py | 9 +++- services/gcs_helper.py | 21 +++++--- tests/features/cli-associate-assets.feature | 11 ++++- .../asset_import_batch/AR0001_1.JPG | Bin .../asset_import_batch/AR0001_2.JPG | Bin .../asset_import_batch/manifest.txt | 0 tests/features/environment.py | 40 +++++++++++++-- tests/features/steps/cli-associate-assets.py | 46 +++++++++++++++++- 8 files changed, 110 insertions(+), 17 deletions(-) rename tests/features/{steps => data}/asset_import_batch/AR0001_1.JPG (100%) rename tests/features/{steps => data}/asset_import_batch/AR0001_2.JPG (100%) rename tests/features/{steps => data}/asset_import_batch/manifest.txt (100%) diff --git a/manage.py b/manage.py index 8a2d64c74..c9c7ef817 100644 --- a/manage.py +++ b/manage.py @@ -22,9 +22,9 @@ from fastapi import UploadFile from sqlalchemy import select -from db import Thing +from db import Thing, Asset from db.engine import session_ctx -from services.gcs_helper import get_storage_bucket +from services.gcs_helper import get_storage_bucket, make_blob_name_and_uri from services.validation.asset_helper import upload_and_associate load_dotenv() @@ -99,6 +99,11 @@ def associate_assets(source_directory: Path) -> list[str]: 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} ) diff --git a/services/gcs_helper.py b/services/gcs_helper.py index f870b92a9..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 @@ -61,25 +63,30 @@ def get_storage_bucket(client=None, bucket: str = None) -> storage.Bucket: 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): if bucket is None: bucket = get_storage_bucket() # 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/tests/features/cli-associate-assets.feature b/tests/features/cli-associate-assets.feature index 81a9654ff..533d205ef 100644 --- a/tests/features/cli-associate-assets.feature +++ b/tests/features/cli-associate-assets.feature @@ -31,7 +31,14 @@ Feature: Associate assets with things based on a manifest file | 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 @@ -45,7 +52,7 @@ Feature: Associate assets with things based on a manifest 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 +# 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 diff --git a/tests/features/steps/asset_import_batch/AR0001_1.JPG b/tests/features/data/asset_import_batch/AR0001_1.JPG similarity index 100% rename from tests/features/steps/asset_import_batch/AR0001_1.JPG rename to tests/features/data/asset_import_batch/AR0001_1.JPG diff --git a/tests/features/steps/asset_import_batch/AR0001_2.JPG b/tests/features/data/asset_import_batch/AR0001_2.JPG similarity index 100% rename from tests/features/steps/asset_import_batch/AR0001_2.JPG rename to tests/features/data/asset_import_batch/AR0001_2.JPG diff --git a/tests/features/steps/asset_import_batch/manifest.txt b/tests/features/data/asset_import_batch/manifest.txt similarity index 100% rename from tests/features/steps/asset_import_batch/manifest.txt rename to tests/features/data/asset_import_batch/manifest.txt diff --git a/tests/features/environment.py b/tests/features/environment.py index c70f7fb8f..ab2c11302 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 @@ -499,8 +503,19 @@ def before_all(context): rebuild = False # 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: @@ -512,10 +527,6 @@ def before_all(context): well_1 = add_well(context, session, loc_1, name_num=1) well_2 = add_well(context, session, loc_2, name_num=2) well_3 = add_well(context, session, loc_3, name_num=3) - session.commit() - print("asdf", well_1, well_1.name) - return - spring_4 = add_spring(context, session, loc_4, name_num=4) sensor_1 = add_sensor(context, session) deployment = add_deployment(context, session, well_1.id, sensor_1.id) @@ -684,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 index 209b4591e..5b0815f7e 100644 --- a/tests/features/steps/cli-associate-assets.py +++ b/tests/features/steps/cli-associate-assets.py @@ -13,7 +13,7 @@ from behave.runner import Context from sqlalchemy import select -from db import Thing +from db import Thing, Asset from db.engine import session_ctx from manage import associate_assets from services.gcs_helper import get_storage_bucket @@ -22,7 +22,7 @@ @given('a local directory named "asset_import_batch"') def step_impl(context: Context): context.source_directory = ( - Path("tests") / "features" / "steps" / "asset_import_batch" + Path("tests") / "features" / "data" / "asset_import_batch" ) assert context.source_directory.exists() assert context.source_directory.is_dir() @@ -122,4 +122,46 @@ def step_impl(context: Context, thing_name): 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 ============================================= From debb511b5a1d4dd41989b1ab4066c759cdc02d29 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 9 Dec 2025 23:17:33 -0700 Subject: [PATCH 05/18] feat: refactor CLI structure and implement asset association functionality --- cli/__init__.py | 17 +++++++++++++++++ tests/features/steps/cli-associate-assets.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 cli/__init__.py diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 000000000..8e546ddc2 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,17 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== + +# ============= EOF ============================================= diff --git a/tests/features/steps/cli-associate-assets.py b/tests/features/steps/cli-associate-assets.py index 5b0815f7e..e7b8ecef8 100644 --- a/tests/features/steps/cli-associate-assets.py +++ b/tests/features/steps/cli-associate-assets.py @@ -13,9 +13,9 @@ 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 manage import associate_assets from services.gcs_helper import get_storage_bucket From c768afc992130ba3a5bcc6929f8863ca1eea3391 Mon Sep 17 00:00:00 2001 From: jakeross Date: Wed, 10 Dec 2025 07:16:04 -0700 Subject: [PATCH 06/18] feat: implement CLI for asset management and CSV uploads --- cli/cli.py | 80 +++++++++++++++++++++++++++++ manage.py => cli/service_adapter.py | 50 ++++-------------- 2 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 cli/cli.py rename manage.py => cli/service_adapter.py (78%) diff --git a/cli/cli.py b/cli/cli.py new file mode 100644 index 000000000..d383c93c9 --- /dev/null +++ b/cli/cli.py @@ -0,0 +1,80 @@ +# =============================================================================== +# 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), +) +def waterlevels_csv(file_path: str): + """ + 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_level_csv + + water_level_csv(file_path) + + +if __name__ == "__main__": + cli() + +# ============= EOF ============================================= diff --git a/manage.py b/cli/service_adapter.py similarity index 78% rename from manage.py rename to cli/service_adapter.py index c9c7ef817..8c4c59549 100644 --- a/manage.py +++ b/cli/service_adapter.py @@ -18,7 +18,6 @@ import mimetypes from pathlib import Path -from dotenv import load_dotenv from fastapi import UploadFile from sqlalchemy import select @@ -27,45 +26,18 @@ from services.gcs_helper import get_storage_bucket, make_blob_name_and_uri from services.validation.asset_helper import upload_and_associate -load_dotenv() -import click -from core.initializers import init_lexicon +def well_inventory_csv(source_file: Path | str): + if isinstance(source_file, str): + source_file = Path(source_file) -@click.group() -def cli(): - """Command line interface for managing the application.""" - pass +def water_level_csv(source_file: Path | str): + if isinstance(source_file, str): + source_file = Path(source_file) -@cli.command() -def initialize_lexicon(): - init_lexicon() - - -@cli.command() -def associate_assets_command(): - associate_assets() - - -@cli.command() -def well_inventory_csv(): - """ - parse and upload a csv to database - """ - # TODO: use the same helper function used by api to parse and upload a WI csv - - -@cli.command() -def waterlevels_csv(): - """ - parse and upload a csv - """ - # TODO: use the same helper function used by api to parse and upload a WL csv - - -def associate_assets(source_directory: Path) -> list[str]: +def associate_assets(source_directory: Path | str) -> list[str]: """ given a directory and the directory contains a manifest file @@ -77,10 +49,11 @@ def associate_assets(source_directory: Path) -> list[str]: and associate each uploaded photo with the corresponding thing """ - - bucket = get_storage_bucket() + 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: @@ -116,7 +89,4 @@ def associate_assets(source_directory: Path) -> list[str]: return uris -if __name__ == "__main__": - cli() - # ============= EOF ============================================= From 7dd1aac4e0b049300a45faec11a866fc5fa302a7 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Wed, 10 Dec 2025 16:50:19 -0800 Subject: [PATCH 07/18] feat: add water level csv feature --- tests/features/water-level-csv.feature | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/features/water-level-csv.feature diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature new file mode 100644 index 000000000..699a01167 --- /dev/null +++ b/tests/features/water-level-csv.feature @@ -0,0 +1,131 @@ +@backend +@BDMS-TBD +@production +Feature: Bulk upload water level entries from CSV + As a hydrogeologist or data specialist + I want to upload a CSV file containing water level entry data for multiple wells + So that water level records can be created efficiently and accurately in the system + + Background: + Given a functioning api + 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 upload the file to the bulk upload endpoint + Then the system returns a 201 Created status code + And the system should return a response in JSON format + And the response includes a summary containing: + | summary_field | value | + | total_rows_processed | 2 | + | total_rows_imported | 2 | + | validation_errors_or_warnings | 0 | + And the response includes an array of created water level entry objects + + @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 upload the file to the bulk upload endpoint + Then the system returns a 201 Created status code + And the system should return a response in JSON format + And all water level entries are imported + + @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 upload the file to the bulk upload endpoint + Then the system returns a 201 Created status code + And the system should return a response in JSON format + And all water level entries are imported + + ########################################################################### + # 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 upload the file to the bulk upload endpoint + Then the system returns a 422 Unprocessable Entity status code + And the system should return a response in JSON format + And the response includes 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 upload the file to the bulk upload endpoint + Then the system returns a 422 Unprocessable Entity status code + And the system should return a response in JSON format + And the response includes 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 upload the file to the bulk upload endpoint + Then the system returns a 422 Unprocessable Entity status code + And the response includes 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 upload the file to the bulk upload endpoint + Then the system returns a 422 Unprocessable Entity status code + And the response includes 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 upload the file to the bulk upload endpoint + Then the system returns a 422 Unprocessable Entity status code + And the response includes validation errors identifying the invalid field and row + And no water level entries are imported \ No newline at end of file From 795fcfdf7a38caa21e75dcb1ab99ed9efe3a5dda Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Wed, 10 Dec 2025 16:58:57 -0800 Subject: [PATCH 08/18] fix: skip water level feature in test suite --- tests/features/water-level-csv.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index 699a01167..afa96ec42 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -1,3 +1,4 @@ +@skip @backend @BDMS-TBD @production From db11629d7c16025d52b349aca7deecf527959cc5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 11:22:03 -0700 Subject: [PATCH 09/18] fix: exclude skipped tests from BDD run in CI workflow --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 9b2471581ae68daddfe40a6b31e3a213ec5000c1 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 11:31:02 -0700 Subject: [PATCH 10/18] refactor: update import paths for asset_helper module --- cli/service_adapter.py | 4 ++-- services/{validation => }/asset_helper.py | 0 transfers/asset_transfer.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename services/{validation => }/asset_helper.py (100%) diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 8c4c59549..0b20b30ac 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -23,8 +23,8 @@ 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.validation.asset_helper import upload_and_associate def well_inventory_csv(source_file: Path | str): @@ -83,7 +83,7 @@ def associate_assets(source_directory: Path | str) -> list[str]: uris.append(uri) else: - pass + print(f"no thing with name={row['thing_name']} found in db") sess.commit() return uris diff --git a/services/validation/asset_helper.py b/services/asset_helper.py similarity index 100% rename from services/validation/asset_helper.py rename to services/asset_helper.py diff --git a/transfers/asset_transfer.py b/transfers/asset_transfer.py index f031e96cc..d8ec6525b 100644 --- a/transfers/asset_transfer.py +++ b/transfers/asset_transfer.py @@ -19,11 +19,11 @@ from starlette.datastructures import UploadFile from db import Thing +from services.asset_helper import upload_and_associate from services.gcs_helper import ( get_storage_bucket, get_storage_client, ) -from services.validation.asset_helper import upload_and_associate from transfers.logger import logger from transfers.transferer import Transferer from transfers.util import read_csv, filter_to_valid_point_ids From a02736927c747a25555534f66e0e26ae0d14a4fe Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 21:22:36 -0700 Subject: [PATCH 11/18] refactor: update import paths for asset_helper module --- cli/cli.py | 6 +- cli/service_adapter.py | 2 +- tests/features/steps/water-levels-csv.py | 212 +++++++++++++++++++++++ tests/features/water-level-csv.feature | 175 +++++++++++-------- 4 files changed, 317 insertions(+), 78 deletions(-) create mode 100644 tests/features/steps/water-levels-csv.py diff --git a/cli/cli.py b/cli/cli.py index d383c93c9..1c34c08a0 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -64,14 +64,14 @@ def well_inventory_csv(file_path: str): "file_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), ) -def waterlevels_csv(file_path: str): +def water_levels_csv(file_path: str): """ 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_level_csv + from cli.service_adapter import water_levels_csv - water_level_csv(file_path) + water_levels_csv(file_path) if __name__ == "__main__": diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 0b20b30ac..3df8fdeed 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -32,7 +32,7 @@ def well_inventory_csv(source_file: Path | str): source_file = Path(source_file) -def water_level_csv(source_file: Path | str): +def water_levels_csv(source_file: Path | str): if isinstance(source_file, str): source_file = Path(source_file) diff --git a/tests/features/steps/water-levels-csv.py b/tests/features/steps/water-levels-csv.py new file mode 100644 index 000000000..a12820e67 --- /dev/null +++ b/tests/features/steps/water-levels-csv.py @@ -0,0 +1,212 @@ +# =============================================================================== +# 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 behave import given, when, then +from behave.runner import Context + + +# ================================================================================ +# Uploading a valid water level entry CSV containing required and optional fields +# ================================================================================ +@given("a valid CSV file for bulk water level entry upload") +def step_impl(context: Context): + context.csv_file = "tests/features/data/water-levels-valid.csv" + + +@given("my CSV file is encoded in UTF-8 and uses commas as separators") +def step_impl(context: Context): + pass + + +@given("my CSV file contains multiple rows of water level entry data") +def step_impl(context: Context): + pass + + +@given("the CSV includes required fields:") +def step_impl(context: Context): + pass + + +@given('each "well_name_point_id" value matches an existing well') +def step_impl(context: Context): + pass + + +@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): + pass + + +@given("the CSV includes optional fields when available:") +def step_impl(context: Context): + pass + + +@when("I run the CLI command:") +def step_impl(context: Context): + pass + + +@then("the command exits with code 0") +def step_impl(context: Context): + """ """ + raise NotImplementedError("STEP: Then the command exits with code 0") + + +@then("stdout should be valid JSON") +def step_impl(context: Context): + raise NotImplementedError("STEP: And stdout should be valid JSON") + + +@then("stdout includes a summary containing:") +def step_impl(context: Context): + pass + + +@then("stdout includes an array of created water level entry objects") +def step_impl(context: Context): + raise NotImplementedError( + "STEP: And stdout includes an array of created water level entry objects" + ) + + +@then("stderr should be empty") +def step_impl(context: Context): + raise NotImplementedError("STEP: And stderr should be empty") + + +# ================================================================================ +# 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") +def step_impl(context: Context): + + raise NotImplementedError( + "STEP: Given my CSV file contains all required headers but in a different column order" + ) + + +@then("all water level entries are imported") +def step_impl(context: Context): + raise NotImplementedError("STEP: And all water level entries are imported") + + +# ================================================================================ +# Upload succeeds when CSV contains extra, unknown columns +# ================================================================================ +@given("my CSV file contains extra columns but is otherwise valid") +def step_impl(context: Context): + raise NotImplementedError( + "STEP: Given my CSV file contains extra columns but is otherwise valid" + ) + + +# ================================================================================ +# 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"' +) +def step_impl(context: Context): + raise NotImplementedError( + 'STEP: Given my CSV file contains 3 rows of data with 2 valid rows and 1 row missing the required "well_name_point_id"' + ) + + +@then("the command exits with a non-zero exit code") +def step_impl(context: Context): + raise NotImplementedError("STEP: Then the command exits with a non-zero exit code") + + +@then( + 'stderr should contain a validation error for the row missing "well_name_point_id"' +) +def step_impl(context: Context): + raise NotImplementedError( + 'STEP: And stderr should contain a validation error for the row missing "well_name_point_id"' + ) + + +@then("no water level entries are imported") +def step_impl(context: Context): + raise NotImplementedError("STEP: And no water level entries are imported") + + +# ================================================================================ +# 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): + raise NotImplementedError( + 'STEP: Given my CSV file contains a row missing the required "" field' + ) + + +@then('stderr should contain a validation error for the "{required_field}" field') +def step_impl(context: Context, required_field: str): + raise NotImplementedError( + 'STEP: And stderr should contain a validation error for the "" field' + ) + + +# ================================================================================ +# 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): + raise NotImplementedError( + 'STEP: Given my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field' + ) + + +@then("stderr should contain validation errors identifying the invalid field and row") +def step_impl(context: Context): + raise NotImplementedError( + "STEP: And stderr should contain validation errors identifying the invalid field and row" + ) + + +# ================================================================================ +# 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): + raise NotImplementedError( + 'STEP: 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"' + ) + + +# ================================================================================ +# 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): + raise NotImplementedError( + 'STEP: Given my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality"' + ) + + +# ============= EOF ============================================= diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index afa96ec42..c2ba538b6 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -1,20 +1,24 @@ +# features/cli/bulk_upload_water_levels.feature + @skip +@cli @backend @BDMS-TBD @production -Feature: Bulk upload water level entries from CSV +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 + 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 a functioning api - And valid lexicon values exist for: - | lexicon category | - | sampler | - | sample_method | - | level_status | - | data_quality | +# 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 @@ -22,57 +26,67 @@ Feature: Bulk upload water level entries from CSV 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 | + | 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 upload the file to the bulk upload endpoint - Then the system returns a 201 Created status code - And the system should return a response in JSON format - And the response includes a summary containing: - | summary_field | value | - | total_rows_processed | 2 | - | total_rows_imported | 2 | - | validation_errors_or_warnings | 0 | - And the response includes an array of created water level entry objects + | optional field name | + | water_level_notes | + When I run the CLI command: + """ + bdms 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 upload the file to the bulk upload endpoint - Then the system returns a 201 Created status code - And the system should return a response in JSON format + | 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: + """ + bdms 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 upload the file to the bulk upload endpoint - Then the system returns a 201 Created status code - And the system should return a response in JSON format + When I run the CLI command: + """ + bdms 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 @@ -81,52 +95,65 @@ Feature: Bulk upload water level entries from CSV @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 upload the file to the bulk upload endpoint - Then the system returns a 422 Unprocessable Entity status code - And the system should return a response in JSON format - And the response includes a validation error for the row missing "well_name_point_id" + When I run the CLI command: + """ + bdms 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 upload the file to the bulk upload endpoint - Then the system returns a 422 Unprocessable Entity status code - And the system should return a response in JSON format - And the response includes a validation error for the "" field + When I run the CLI command: + """ + bdms 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 | + | 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 upload the file to the bulk upload endpoint - Then the system returns a 422 Unprocessable Entity status code - And the response includes validation errors identifying the invalid field and row + When I run the CLI command: + """ + bdms 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 upload the file to the bulk upload endpoint - Then the system returns a 422 Unprocessable Entity status code - And the response includes validation errors identifying the invalid field and row + When I run the CLI command: + """ + bdms 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 upload the file to the bulk upload endpoint - Then the system returns a 422 Unprocessable Entity status code - And the response includes validation errors identifying the invalid field and row - And no water level entries are imported \ No newline at end of file + When I run the CLI command: + """ + bdms 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 From 0192765dd5a42c0c45ca49632b72ff1d1f437c66 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 21:58:49 -0700 Subject: [PATCH 12/18] feat: enhance water levels CSV upload with output format option --- cli/cli.py | 12 +- cli/service_adapter.py | 10 +- services/water_level_csv.py | 398 +++++++++++++++++++++++ tests/features/steps/water-levels-csv.py | 308 +++++++++++++----- 4 files changed, 646 insertions(+), 82 deletions(-) create mode 100644 services/water_level_csv.py diff --git a/cli/cli.py b/cli/cli.py index 1c34c08a0..6f7278392 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -64,14 +64,22 @@ def well_inventory_csv(file_path: str): "file_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), ) -def water_levels_csv(file_path: str): +@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 - water_levels_csv(file_path) + pretty_json = (output_format or "").lower() == "json" + water_levels_csv(file_path, pretty_json=pretty_json) if __name__ == "__main__": diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 3df8fdeed..a177a95ff 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -16,6 +16,7 @@ import csv import io import mimetypes +import sys from pathlib import Path from fastapi import UploadFile @@ -25,6 +26,7 @@ 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): @@ -32,10 +34,16 @@ def well_inventory_csv(source_file: Path | str): source_file = Path(source_file) -def water_levels_csv(source_file: Path | str): +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]: """ diff --git a/services/water_level_csv.py b/services/water_level_csv.py new file mode 100644 index 000000000..8f05048f6 --- /dev/null +++ b/services/water_level_csv.py @@ -0,0 +1,398 @@ +# ============================================================================== +# 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 json +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, 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 + + +@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, *, pretty_json: bool = False +) -> BulkUploadResult: + """Parse a CSV of water-level measurements and write database rows.""" + + try: + path = Path(source_file) + headers, csv_rows = _read_csv(path) + 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) + + 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) + + +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(path: Path) -> tuple[list[str], list[dict[str, str]]]: + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + 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/steps/water-levels-csv.py b/tests/features/steps/water-levels-csv.py index a12820e67..06901f74d 100644 --- a/tests/features/steps/water-levels-csv.py +++ b/tests/features/steps/water-levels-csv.py @@ -1,4 +1,4 @@ -# =============================================================================== +# ============================================================================== # Copyright 2025 ross # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,201 +12,351 @@ # 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 - -# ================================================================================ -# Uploading a valid water level entry CSV containing required and optional fields -# ================================================================================ +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): - context.csv_file = "tests/features/data/water-levels-valid.csv" + 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): - pass + 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): - pass + assert len(context.csv_rows) >= 2 @given("the CSV includes required fields:") def step_impl(context: Context): - pass + 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): - pass + 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): - pass + 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): - pass + 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): - pass + 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): - """ """ - raise NotImplementedError("STEP: Then the command exits with code 0") + assert context.cli_result.exit_code == 0, context.cli_result.stderr @then("stdout should be valid JSON") def step_impl(context: Context): - raise NotImplementedError("STEP: And stdout should be valid JSON") + _ensure_stdout_json(context) @then("stdout includes a summary containing:") def step_impl(context: Context): - pass + 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): - raise NotImplementedError( - "STEP: And stdout includes an array of created water level entry objects" - ) + 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): - raise NotImplementedError("STEP: And stderr should be empty") + assert context.cli_result.stderr == "" -# ================================================================================ -# Upload succeeds when required columns are present but in a different order -# ================================================================================ +# ============================================================================ +# 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): - - raise NotImplementedError( - "STEP: Given my CSV file contains all required headers but in a different column order" - ) + 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): - raise NotImplementedError("STEP: And all water level entries are imported") + payload = _ensure_stdout_json(context) + summary = payload["summary"] + assert summary["total_rows_processed"] == summary["total_rows_imported"] + assert summary["total_rows_imported"] > 0 -# ================================================================================ -# Upload succeeds when CSV contains extra, unknown columns -# ================================================================================ +# ============================================================================ +# Scenario: Upload succeeds when CSV contains extra columns +# ============================================================================ @given("my CSV file contains extra columns but is otherwise valid") def step_impl(context: Context): - raise NotImplementedError( - "STEP: Given my CSV file contains extra columns but is otherwise valid" - ) - - -# ================================================================================ -# No water level entries are imported when any row fails validation -# ================================================================================ + 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): - raise NotImplementedError( - 'STEP: Given my CSV file contains 3 rows of data with 2 valid rows and 1 row missing the required "well_name_point_id"' - ) + 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): - raise NotImplementedError("STEP: Then the command exits with a non-zero exit code") + 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): - raise NotImplementedError( - 'STEP: And stderr should contain a validation error for the row missing "well_name_point_id"' - ) + assert "well_name_point_id" in context.cli_result.stderr @then("no water level entries are imported") def step_impl(context: Context): - raise NotImplementedError("STEP: And no water level entries are imported") + payload = _ensure_stdout_json(context) + summary = payload["summary"] + assert summary["total_rows_imported"] == 0 -# ================================================================================ -# Upload fails when a required field is missing -# ================================================================================ +# ============================================================================ +# 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): - raise NotImplementedError( - 'STEP: Given my CSV file contains a row missing the required "" field' - ) + 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): - raise NotImplementedError( - 'STEP: And stderr should contain a validation error for the "" field' - ) + assert required_field in context.cli_result.stderr -# ================================================================================ -# Upload fails due to invalid date formats -# ================================================================================ +# ============================================================================ +# 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): - raise NotImplementedError( - 'STEP: Given my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field' - ) + 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): - raise NotImplementedError( - "STEP: And stderr should contain validation errors identifying the invalid field and row" - ) + 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 -# ================================================================================ -# Upload fails due to invalid numeric fields -# ================================================================================ +# ============================================================================ +# 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): - raise NotImplementedError( - 'STEP: 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"' - ) + 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"] -# ================================================================================ -# Upload fails due to invalid lexicon values -# ================================================================================ +# ============================================================================ +# 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): - raise NotImplementedError( - 'STEP: Given my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality"' - ) + 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 ============================================= From 2a5e76ad5657e672ab429c53a200c491fa3a34b1 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 22:09:35 -0700 Subject: [PATCH 13/18] feat: enhance water levels CSV upload with output format option --- api/observation.py | 35 +++++++++++++++++-- services/water_level_csv.py | 53 +++++++++++++++++++---------- tests/test_observation.py | 68 ++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 20 deletions(-) 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/services/water_level_csv.py b/services/water_level_csv.py index 8f05048f6..ff49fe12e 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -16,12 +16,13 @@ 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, Iterable, List +from typing import Any, BinaryIO, Iterable, List from pydantic import BaseModel, ConfigDict, ValidationError, field_validator from sqlalchemy import select @@ -65,6 +66,7 @@ class BulkUploadResult: exit_code: int stdout: str stderr: str + payload: dict[str, Any] @dataclass @@ -163,18 +165,17 @@ def _empty_to_none(cls, value: str | None) -> str | None: def bulk_upload_water_levels( - source_file: str | Path, *, pretty_json: bool = False + source_file: str | Path | bytes | BinaryIO, *, pretty_json: bool = False ) -> BulkUploadResult: """Parse a CSV of water-level measurements and write database rows.""" try: - path = Path(source_file) - headers, csv_rows = _read_csv(path) + 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) + return BulkUploadResult(exit_code=1, stdout=stdout, stderr=msg, payload=payload) validation_errors: list[str] = [] created_rows: list[dict[str, Any]] = [] @@ -212,7 +213,9 @@ def bulk_upload_water_levels( 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) + return BulkUploadResult( + exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload + ) def _serialize_payload(payload: dict[str, Any], pretty: bool) -> str: @@ -239,17 +242,33 @@ def _build_payload( } -def _read_csv(path: Path) -> tuple[list[str], list[dict[str, str]]]: - with path.open("r", encoding="utf-8", newline="") as handle: - reader = csv.DictReader(handle) - 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 []] +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 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 ================================================================== From 48494b69a7d7d069fe01cfd12c6f75427c326350 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 22:13:17 -0700 Subject: [PATCH 14/18] feat: add water level bulk upload models using Pydantic --- schemas/water_level_csv.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 schemas/water_level_csv.py 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 ============================================= From e50643ae70464e2795ccdfd293203d0d7d02bf65 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 22:24:27 -0700 Subject: [PATCH 15/18] feat: rename project to OcotilloAPI and update CLI command references --- README.md | 42 ++++++++++++++++++++++++-- pyproject.toml | 8 +++-- tests/features/water-level-csv.feature | 16 +++++----- 3 files changed, 53 insertions(+), 13 deletions(-) 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/pyproject.toml b/pyproject.toml index b2f625e59..e58737841 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "nmsamplelocations" +name = "OcotilloAPI" version = "0.1.0" -description = "Add your description here" +description = "FastAPI backend and CLI for managing Ocotillo groundwater locations, wells, assets, and bulk water-level data transfers." readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -99,6 +99,10 @@ dependencies = [ "uvicorn==0.38.0", "yarl==1.20.1", ] + +[project.scripts] +oco = "cli.cli:cli" + [tool.alembic] # path to migration scripts. diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index c2ba538b6..4bdbe9c0d 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -44,7 +44,7 @@ Feature: Bulk upload water level entries from CSV via CLI | water_level_notes | When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv --output json + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with code 0 And stdout should be valid JSON @@ -71,7 +71,7 @@ Feature: Bulk upload water level entries from CSV via CLI | data_quality | When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv """ Then the command exits with code 0 And all water level entries are imported @@ -82,7 +82,7 @@ Feature: Bulk upload water level entries from CSV via CLI Given my CSV file contains extra columns but is otherwise valid When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv """ Then the command exits with code 0 And all water level entries are imported @@ -97,7 +97,7 @@ Feature: Bulk upload water level entries from CSV via CLI 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: """ - bdms water-levels bulk-upload --file ./water_levels.csv + 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" @@ -108,7 +108,7 @@ Feature: Bulk upload water level entries from CSV via CLI Given my CSV file contains a row missing the required "" field When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv + 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 @@ -130,7 +130,7 @@ Feature: Bulk upload water level entries from CSV via CLI Given my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv + 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 @@ -141,7 +141,7 @@ Feature: Bulk upload water level entries from CSV via CLI 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: """ - bdms water-levels bulk-upload --file ./water_levels.csv + 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 @@ -152,7 +152,7 @@ Feature: Bulk upload water level entries from CSV via CLI Given my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality" When I run the CLI command: """ - bdms water-levels bulk-upload --file ./water_levels.csv + 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 From 4bf1d905ec5e4e9d8e6fe47b5b31cb600d807182 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 22:31:27 -0700 Subject: [PATCH 16/18] feat: update package dependencies in uv.lock file --- uv.lock | 126 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/uv.lock b/uv.lock index 61ebbba0d..5b0d47c45 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -929,7 +929,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 = [ @@ -1145,67 +1206,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" From 17d136be6addca17256e99ae823d3f66128a6904 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 22:50:44 -0700 Subject: [PATCH 17/18] feat: update FastAPI to version 0.124.2 and add annotated-doc package --- pyproject.toml | 2 +- uv.lock | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e58737841..c7e80981c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "dnspython==2.7.0", "dotenv>=0.9.9", "email-validator==2.2.0", - "fastapi==0.116.1", + "fastapi==0.124.2", "fastapi-pagination==0.14.3", "frozenlist==1.7.0", "geoalchemy2==0.18.0", diff --git a/uv.lock b/uv.lock index 5b0d47c45..743273696 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] @@ -1125,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" }, From 0c09f4677d3bae7499e11b0e706975f1cad3954b Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 11 Dec 2025 23:25:48 -0700 Subject: [PATCH 18/18] feat: update package versions for pg8000, starlette, and urllib3 in uv.lock --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 743273696..083edcd3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1162,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" }, @@ -1197,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" }, ] @@ -1289,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]] @@ -1903,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]] @@ -1954,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]]