From 39922b555a26d436dd19eaf1200fbd5451bdac3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:52:11 +0000 Subject: [PATCH 001/163] build(deps): bump packaging from 25.0 to 26.0 Bumps [packaging](https://github.com/pypa/packaging) from 25.0 to 26.0. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/25.0...26.0) --- updated-dependencies: - dependency-name: packaging dependency-version: '26.0' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eacaf9a3..f067e1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "markupsafe==3.0.3", "multidict==6.7.1", "numpy==2.4.2", - "packaging==25.0", + "packaging==26.0", "pandas==2.3.2", "pandas-stubs~=2.3.2", "pg8000==1.31.5", diff --git a/requirements.txt b/requirements.txt index b65c337b..3ca21828 100644 --- a/requirements.txt +++ b/requirements.txt @@ -836,9 +836,9 @@ opentelemetry-semantic-conventions==0.60b1 \ --hash=sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953 \ --hash=sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb # via opentelemetry-sdk -packaging==25.0 \ - --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ - --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f +packaging==26.0 \ + --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 # via # geoalchemy2 # gunicorn diff --git a/uv.lock b/uv.lock index 51911c0b..eed809da 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -1450,7 +1450,7 @@ requires-dist = [ { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, { name = "numpy", specifier = "==2.4.2" }, - { name = "packaging", specifier = "==25.0" }, + { name = "packaging", specifier = "==26.0" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, @@ -1554,11 +1554,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] From 9dc1f714bc68475ff8e2fd3fec7d56b8b544d220 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:30:47 +0000 Subject: [PATCH 002/163] build(deps): bump pycparser from 2.23 to 3.0 Bumps [pycparser](https://github.com/eliben/pycparser) from 2.23 to 3.0. - [Release notes](https://github.com/eliben/pycparser/releases) - [Commits](https://github.com/eliben/pycparser/compare/release_v2.23...release_v3.00) --- updated-dependencies: - dependency-name: pycparser dependency-version: '3.0' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0cbf8cc1..c2475c2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "psycopg2-binary>=2.9.10", "pyasn1==0.6.2", "pyasn1-modules==0.4.2", - "pycparser==2.23", + "pycparser==3.0", "pydantic==2.12.5", "pydantic-core==2.41.5", "pygments==2.19.2", diff --git a/requirements.txt b/requirements.txt index 24cd75ff..8217fb22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1140,9 +1140,9 @@ pyasn1-modules==0.4.2 \ # via # google-auth # ocotilloapi -pycparser==2.23 \ - --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ - --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via # cffi # ocotilloapi diff --git a/uv.lock b/uv.lock index eb03c232..e1e6928d 100644 --- a/uv.lock +++ b/uv.lock @@ -1564,7 +1564,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyasn1", specifier = "==0.6.2" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, - { name = "pycparser", specifier = "==2.23" }, + { name = "pycparser", specifier = "==3.0" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygeoapi", specifier = "==0.22.0" }, @@ -2030,11 +2030,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] From 66a637289dfad1cec21134f240d5f838599dead3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:30:59 +0000 Subject: [PATCH 003/163] build(deps): bump cffi from 1.17.1 to 2.0.0 Bumps [cffi](https://github.com/python-cffi/cffi) from 1.17.1 to 2.0.0. - [Release notes](https://github.com/python-cffi/cffi/releases) - [Commits](https://github.com/python-cffi/cffi/compare/v1.17.1...v2.0.0) --- updated-dependencies: - dependency-name: cffi dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 98 +++++++++++++++++++++++++++++++++++++++++------- uv.lock | 61 ++++++++++++++++++++---------- 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0cbf8cc1..c6e20f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "bcrypt==4.3.0", "cachetools==5.5.2", "certifi==2025.8.3", - "cffi==1.17.1", + "cffi==2.0.0", "charset-normalizer==3.4.4", "click==8.3.1", "cloud-sql-python-connector==1.20.0", diff --git a/requirements.txt b/requirements.txt index 24cd75ff..322a3855 100644 --- a/requirements.txt +++ b/requirements.txt @@ -233,19 +233,91 @@ certifi==2025.8.3 \ # rasterio # requests # sentry-sdk -cffi==1.17.1 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf # via # cryptography # ocotilloapi diff --git a/uv.lock b/uv.lock index eb03c232..b824549b 100644 --- a/uv.lock +++ b/uv.lock @@ -395,24 +395,47 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -1518,7 +1541,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.3.0" }, { name = "cachetools", specifier = "==5.5.2" }, { name = "certifi", specifier = "==2025.8.3" }, - { name = "cffi", specifier = "==1.17.1" }, + { name = "cffi", specifier = "==2.0.0" }, { name = "charset-normalizer", specifier = "==3.4.4" }, { name = "click", specifier = "==8.3.1" }, { name = "cloud-sql-python-connector", specifier = "==1.20.0" }, From 56f6cbf6782f9dda8e62e79d73ec4a3f3f238462 Mon Sep 17 00:00:00 2001 From: mar <78176736+marissafichera@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:18:06 -0700 Subject: [PATCH 004/163] feat(tests): relax validation rules and expand enum coverage in well-inventory-csv feature - Move site_name, elevation_ft, elevation_method, and measuring_point_height_ft from required to optional - Replace "both contact_name and contact_organization required" rule with "at least one required" rule - Remove all-or-nothing water level rule; water_level_date_time now required only when depth_to_water_ft is provided, all other water level fields are independent and optional - Add negative scenarios for invalid address_type, state abbreviation, well_hole_status, monitoring_status, and well_pump_type with allowed values specified - Add well_notes, well_measuring_notes, water_notes, historical_notes, well_hole_status, and monitoring_status to optional fields --- tests/features/well-inventory-csv.feature | 146 +++++++++++++++------- 1 file changed, 98 insertions(+), 48 deletions(-) diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index e2d4e80e..ed36fc7a 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -35,18 +35,15 @@ Feature: Bulk upload well inventory from CSV via CLI | required field name | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | And each "well_name_point_id" value is unique per row And the CSV includes optional fields when available: | optional field name | + | site_name | | field_staff_2 | | field_staff_3 | | contact_1_name | @@ -110,21 +107,30 @@ Feature: Bulk upload well inventory from CSV via CLI | completion_source | | total_well_depth_ft | | historic_depth_to_water_ft | + | historical_notes | | depth_source | | well_pump_type | | well_pump_depth_ft | | is_open | | datalogger_possible | | casing_diameter_ft | + | elevation_ft | + | elevation_method | + | measuring_point_height_ft | | measuring_point_description | | well_purpose | | well_purpose_2 | - | well_status | + | well_hole_status | + | well_status | | monitoring_frequency | + | monitoring_status | | sampling_scenario_notes | + | well_notes | + | well_measuring_notes | + | water_notes | | well_measuring_notes | | sample_possible | - And the csv includes optional water level entry fields when available: + And the csv includes optional water level entry fields when available: | water_level_entry fields | | measuring_person | | sample_method | @@ -132,10 +138,10 @@ Feature: Bulk upload well inventory from CSV via CLI | mp_height | | level_status | | depth_to_water_ft | - | data_quality | + | data_quality | | water_level_notes | - And the required "date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") - And the optional "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") when provided + And the required "date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") + And the optional "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") when provided # And all optional lexicon fields contain valid lexicon values when provided # And all optional numeric fields contain valid numeric values when provided @@ -161,15 +167,11 @@ Feature: Bulk upload well inventory from CSV via CLI | required field name | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | When I run the well inventory bulk upload command Then the command exits with code 0 And the system should return a response in JSON format @@ -204,7 +206,7 @@ Feature: Bulk upload well inventory from CSV via CLI @negative @validation @BDMS-TBD Scenario: Upload fails when a row has an invalid postal code format - Given my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code + Given my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the system should return a response in JSON format @@ -212,7 +214,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact with a invalid phone number format + Scenario: Upload fails when a row has a contact with an invalid phone number format Given my CSV file contains a row with a contact with a phone number that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -221,7 +223,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact with a invalid email format + Scenario: Upload fails when a row has a contact with an invalid email format Given my CSV file contains a row with a contact with an email that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -230,7 +232,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact without a contact_role + Scenario: Upload fails when a row has a contact without a contact_role Given my CSV file contains a row with a contact but is missing the required "contact_role" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -239,7 +241,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact without a "contact_type" + Scenario: Upload fails when a row has a contact without a "contact_type" Given my CSV file contains a row with a contact but is missing the required "contact_type" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -248,7 +250,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an invalid "contact_type" + Scenario: Upload fails when a row has a contact with an invalid "contact_type" Given my CSV file contains a row with a contact_type value that is not in the valid lexicon for "contact_type" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -257,7 +259,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an email without an email_type + Scenario: Upload fails when a row has a contact with an email without an email_type Given my CSV file contains a row with a contact with an email but is missing the required "email_type" field for that email When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -266,7 +268,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with a phone without a phone_type + Scenario: Upload fails when a row has a contact with a phone without a phone_type Given my CSV file contains a row with a contact with a phone but is missing the required "phone_type" field for that phone When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -275,7 +277,7 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an address without an address_type + Scenario: Upload fails when a row has a contact with an address without an address_type Given my CSV file contains a row with a contact with an address but is missing the required "address_type" field for that address When I run the well inventory bulk upload command Then the command exits with a non-zero exit code @@ -283,6 +285,51 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating the missing "address_type" value And no wells are imported + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with an invalid "address_type" + Given my CSV file contains a row with an address_type value that is not one of: Work, Personal, Mailing, Physical + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating an invalid "address_type" value + And no wells are imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with an invalid state abbreviation + Given my CSV file contains a row with a state value that is not a valid 2-letter US state abbreviation + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating an invalid state value + And no wells are imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid well_hole_status value + Given my CSV file contains a row with a well_hole_status value that is not one of: "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating an invalid "well_hole_status" value + And no wells are imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid monitoring_status value + Given my CSV file contains a row with a monitoring_status value that is not one of: "Open", "Open (unequipped)", "Closed", "Datalogger can be installed", "Datalogger cannot be installed", "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used", "Currently monitored", "Not currently monitored" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating an invalid "monitoring_status" value + And no wells are imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid well_pump_type value + Given my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating an invalid "well_pump_type" value + And no wells are imported + @negative @validation @BDMS-TBD Scenario: Upload fails when a row has utm_easting utm_northing and utm_zone values that are not within New Mexico Given my CSV file contains a row with utm_easting utm_northing and utm_zone values that are not within New Mexico @@ -292,6 +339,24 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating the invalid UTM coordinates And no wells are imported + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with neither contact_name nor contact_organization + Given my CSV file contains a row with contact fields filled but both "contact_1_name" and "contact_1_organization" are blank + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided + And no wells are imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when water_level_date_time is missing but depth_to_water_ft is provided + Given my CSV file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the system should return a response in JSON format + And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided + And no wells 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 @@ -305,15 +370,11 @@ Feature: Bulk upload well inventory from CSV via CLI | required_field | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | @negative @validation @boolean_fields @BDMS-TBD Scenario: Upload fails due to invalid boolean field values @@ -359,9 +420,9 @@ Feature: Bulk upload well inventory from CSV via CLI And no wells are imported -# ########################################################################### -# # FILE FORMAT SCENARIOS -# ########################################################################### + ########################################################################### + # FILE FORMAT SCENARIOS + ########################################################################### @negative @file_format @limits @BDMS-TBD Scenario: Upload fails when the CSV exceeds the maximum allowed number of rows @@ -409,7 +470,6 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating a repeated header row And no wells are imported - @negative @validation @header_row @BDMS-TBD Scenario: Upload fails when the header row contains duplicate column names Given my CSV file header row contains the "contact_1_email_1" column name more than once @@ -419,7 +479,6 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating duplicate header names And no wells are imported - ########################################################################### # DELIMITER & QUOTING / EXCEL-RELATED SCENARIOS ########################################################################### @@ -448,27 +507,18 @@ Feature: Bulk upload well inventory from CSV via CLI Then the command exits with code 0 And the system should return a response in JSON format And all wells are imported -# -# @negative @validation @numeric @excel @BDMS-TBD -# Scenario: Upload fails when numeric fields are provided in Excel scientific notation format -# Given my CSV file contains a numeric-required field such as "utm_easting" -# And Excel has exported the "utm_easting" value in scientific notation (for example "1.2345E+06") -# When I run the well inventory bulk upload command -# Then the command exits with a non-zero exit code -# And the system should return a response in JSON format -# And the response includes a validation error indicating an invalid numeric format for "utm_easting" -# And no wells are imported - -########################################################################### + + ########################################################################### # WATER LEVEL ENTRY VALIDATION -########################################################################### + ########################################################################### - # if one water level entry field is filled, then all are required + # water_level_date_time is required only when depth_to_water_ft is provided + # all other water level fields are optional and independent @negative @validation @BDMS-TBD - Scenario: Water level entry fields are all required if any are filled - Given my csv file contains a row where some but not all water level entry fields are filled + Scenario: Upload fails when depth_to_water_ft is provided but water_level_date_time is missing + Given my csv file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the system should return a response in JSON format - And the response includes validation errors for each missing water level entry field + And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided And no wells are imported From 9cbaaa26fc05fb2e909abe4a405c5c1ff8ef3b5b Mon Sep 17 00:00:00 2001 From: mar <78176736+marissafichera@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:10:07 -0700 Subject: [PATCH 005/163] feat(tests): relax validation rules and expand enum coverage in well-inventory-csv feature - removed production tag - removed instances of returning in JSON format - Move site_name, elevation_ft, elevation_method, and measuring_point_height_ft from required to optional - Replace "both contact_name and contact_organization required" rule with "at least one required" rule - Remove all-or-nothing water level rule; water_level_date_time now required only when depth_to_water_ft is provided, all other water level fields are independent and optional - Add negative scenarios for invalid address_type, state abbreviation, well_hole_status, monitoring_status, and well_pump_type with allowed values specified - Add well_notes, well_measuring_notes, water_notes, historical_notes, well_hole_status, and monitoring_status to optional fields --- tests/features/well-inventory-csv.feature | 31 ----------------------- 1 file changed, 31 deletions(-) diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index ed36fc7a..c8c7f2e5 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -1,7 +1,6 @@ @backend @cli @BDMS-TBD -@production Feature: Bulk upload well inventory from CSV via CLI As a hydrogeologist or data specialist I want to upload a CSV file containing well inventory data for multiple wells @@ -151,7 +150,6 @@ Feature: Bulk upload well inventory from CSV via CLI # assumes users are entering datetimes as Mountain Time because location is restricted to New Mexico Then all datetime objects are assigned the correct Mountain Time timezone offset based on the date value. And the command exits with code 0 - And the system should return a response in JSON format # And null values in the response are represented as JSON null And the response includes a summary containing: | summary_field | value | @@ -174,7 +172,6 @@ Feature: Bulk upload well inventory from CSV via CLI | utm_zone | When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported @positive @validation @extra_columns @BDMS-TBD @@ -182,7 +179,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains extra columns but is otherwise valid When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported @positive @validation @autogenerate_ids @BDMS-TBD @@ -190,7 +186,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains all valid columns but uses uppercase "-xxxx" placeholders and blank values for well_name_point_id When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported with system-generated unique well_name_point_id values ########################################################################### @@ -201,7 +196,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains 3 rows of data with 2 valid rows and 1 row with a blank "well_name_point_id" When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported with system-generated unique well_name_point_id values @negative @validation @BDMS-TBD @@ -209,7 +203,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid postal code format And no wells are imported @@ -218,7 +211,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact with a phone number that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid phone number format And no wells are imported @@ -227,7 +219,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact with an email that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid email format And no wells are imported @@ -236,7 +227,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact but is missing the required "contact_role" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "contact_role" field And no wells are imported @@ -245,7 +235,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact but is missing the required "contact_type" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "contact_type" value And no wells are imported @@ -254,7 +243,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact_type value that is not in the valid lexicon for "contact_type" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "contact_type" value And no wells are imported @@ -263,7 +251,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact with an email but is missing the required "email_type" field for that email When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "email_type" value And no wells are imported @@ -272,7 +259,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact with a phone but is missing the required "phone_type" field for that phone When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "phone_type" value And no wells are imported @@ -281,7 +267,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a contact with an address but is missing the required "address_type" field for that address When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "address_type" value And no wells are imported @@ -290,7 +275,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with an address_type value that is not one of: Work, Personal, Mailing, Physical When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "address_type" value And no wells are imported @@ -299,7 +283,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a state value that is not a valid 2-letter US state abbreviation When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid state value And no wells are imported @@ -308,7 +291,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a well_hole_status value that is not one of: "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "well_hole_status" value And no wells are imported @@ -317,7 +299,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a monitoring_status value that is not one of: "Open", "Open (unequipped)", "Closed", "Datalogger can be installed", "Datalogger cannot be installed", "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used", "Currently monitored", "Not currently monitored" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "monitoring_status" value And no wells are imported @@ -326,7 +307,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "well_pump_type" value And no wells are imported @@ -335,7 +315,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with utm_easting utm_northing and utm_zone values that are not within New Mexico When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid UTM coordinates And no wells are imported @@ -344,7 +323,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row with contact fields filled but both "contact_1_name" and "contact_1_organization" are blank When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided And no wells are imported @@ -353,7 +331,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided And no wells are imported @@ -362,7 +339,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row missing the required "" field When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error for the "" field And no wells are imported @@ -382,7 +358,6 @@ Feature: Bulk upload well inventory from CSV via CLI # And my CSV file contains other boolean fields such as "sample_possible" with valid boolean values When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid boolean value for the "is_open" field And no wells are imported @@ -429,7 +404,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains more rows than the configured maximum for bulk upload When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes an error message indicating the row limit was exceeded And no wells are imported @@ -466,7 +440,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a valid but duplicate header row When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating a repeated header row And no wells are imported @@ -475,7 +448,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file header row contains the "contact_1_email_1" column name more than once When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating duplicate header names And no wells are imported @@ -489,7 +461,6 @@ Feature: Bulk upload well inventory from CSV via CLI And my file uses "" as the field delimiter instead of commas When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes an error message indicating an unsupported delimiter And no wells are imported @@ -505,7 +476,6 @@ Feature: Bulk upload well inventory from CSV via CLI # And all other required fields are populated with valid values When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported ########################################################################### @@ -519,6 +489,5 @@ Feature: Bulk upload well inventory from CSV via CLI Given my csv file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided And no wells are imported From 2c21ace9e69686874b838c2fdccc820661441800 Mon Sep 17 00:00:00 2001 From: mar <78176736+marissafichera@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:21:18 -0700 Subject: [PATCH 006/163] `test(features): align water-level CSV feature with well-inventory-style field requirements - Update water-level feature scenarios to mirror well-inventory behavior for requested headers - Keep only base required fields (`field_staff`, `well_name_point_id`, `field_event_date_time`) - Move water-level measurement fields to optional set (`water_level_date_time`, `measuring_person`, `sample_method`, `mp_height`, `level_status`, `depth_to_water_ft`, `data_quality`, `water_level_notes`) - Add timezone-naive ISO 8601 expectation for `field_event_date_time` and `water_level_date_time` - Fix required-field examples and column-order scenario to match new required set - Normalize numeric validation wording to `mp_height` (not `mp_height_ft`)` --- tests/features/water-level-csv.feature | 43 +++++++++++--------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index d924da6f..bfdd098c 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -27,19 +27,20 @@ Feature: Bulk upload water level entries from CSV via CLI | field_staff | | well_name_point_id | | field_event_date_time | - | water_level_date_time | | measuring_person | - | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | And each "well_name_point_id" value matches an existing well + And "field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00") And "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") And the CSV includes optional fields when available: | optional field name | | field_staff_2 | | field_staff_3 | + | water_level_date_time | + | sample_method | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | | water_level_notes | When I run the CLI command: """ @@ -60,19 +61,15 @@ Feature: Bulk upload water level entries from CSV via CLI Given my water level CSV file contains all required headers but in a different column order And the CSV includes required fields: | required field name | + | field_staff | | well_name_point_id | - | water_level_date_time | + | field_event_date_time | | measuring_person | - | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | When I run the CLI command: """ oco water-levels bulk-upload --file ./water_levels.csv """ - # assumes users are entering datetimes as Mountain Time becuase well location is restricted to New Mexico + # assumes users are entering datetimes as Mountain Time because well location is restricted to New Mexico Then all datetime objects are assigned the correct Mountain Time timezone offset based on the date value. And the command exits with code 0 And all water level entries are imported @@ -117,14 +114,10 @@ Feature: Bulk upload water level entries from CSV via CLI Examples: | required_field | + | field_staff | | well_name_point_id | - | water_level_date_time | + | field_event_date_time | | measuring_person | - | 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 @@ -148,13 +141,13 @@ Feature: Bulk upload water level entries from CSV via CLI 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 "measuring_person", "sample_method", "level_status", or "data_quality" + @negative @validation @string_fields @BDMS-TBD + Scenario: Upload accepts free-text values for measuring person and water level descriptors + Given my CSV file contains non-lexicon text values for "measuring_person", "sample_method", "level_status", and "data_quality" When I run the CLI command: """ oco water-levels bulk-upload --file ./water_levels.csv """ - Then the command exits with a non-zero exit code - And stderr should contain validation errors identifying the invalid field and row - And no water level entries are imported + Then the command exits with code 0 + And all water level entries are imported + And stderr should be empty From 32b4c54ce537ceb06e3a88bc586a64ed161b2370 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 3 Mar 2026 16:58:56 -0700 Subject: [PATCH 007/163] feat(tests): add validation error handling for various invalid CSV field values --- .../steps/well-inventory-csv-given.py | 74 +++++++++++++++++- .../well-inventory-csv-validation-error.py | 75 +++++++++++++++++++ tests/features/well-inventory-csv.feature | 1 + 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index f02144fc..1d753cb9 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -50,7 +50,7 @@ def step_step_step(context: Context): @given( - "my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code" + "my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code" ) def step_step_step_2(context: Context): _set_file_content(context, "well-inventory-invalid-postal-code.csv") @@ -362,4 +362,76 @@ def step_step_step_21(context): _set_file_content(context, "well-inventory-missing-wl-fields.csv") +@given( + "my CSV file contains a row with an address_type value that is not one of: Work, Personal, Mailing, Physical" +) +def step_given_row_contains_invalid_address_type_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "contact_1_address_1_type"] = "InvalidAddressType" + _set_content_from_df(context, df) + + +@given( + "my CSV file contains a row with a state value that is not a valid 2-letter US state abbreviation" +) +def step_given_row_contains_invalid_state_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "contact_1_address_1_state"] = "New Mexico" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a well_hole_status value that is not one of: "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used"' +) +def step_given_row_contains_invalid_well_hole_status_value(context: Context): + df = _get_valid_df(context) + if "well_status" in df.columns: + df.loc[0, "well_status"] = "NotARealWellHoleStatus" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a monitoring_status value that is not one of: "Open", "Open (unequipped)", "Closed", "Datalogger can be installed", "Datalogger cannot be installed", "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used", "Currently monitored", "Not currently monitored"' +) +def step_given_row_contains_invalid_monitoring_status_value(context: Context): + df = _get_valid_df(context) + if "monitoring_frequency" in df.columns: + df.loc[0, "monitoring_frequency"] = "NotARealMonitoringStatus" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand"' +) +def step_given_row_contains_invalid_well_pump_type_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "well_pump_type"] = "NotARealPumpType" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with contact fields filled but both "contact_1_name" and "contact_1_organization" are blank' +) +def step_given_row_contains_contact_fields_but_name_and_org_are_blank(context: Context): + df = _get_valid_df(context) + df.loc[0, "contact_1_name"] = "" + df.loc[0, "contact_1_organization"] = "" + # Keep other contact data present so composite contact validation is exercised. + df.loc[0, "contact_1_role"] = "Owner" + df.loc[0, "contact_1_type"] = "Primary" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank' +) +@given( + 'my csv file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank' +) +def step_given_depth_to_water_is_filled_but_water_level_date_time_is_blank( + context: Context, +): + _set_file_content(context, "well-inventory-missing-wl-fields.csv") + + # ============= EOF ============================================= diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 8aecbeae..3a9b2475 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -31,6 +31,26 @@ def _handle_validation_error(context, expected_errors): assert v["value"] == e["value"], f"Expected {e['value']} for {v['value']}" +def _assert_any_validation_error_contains( + context: Context, field_fragment: str | None, error_fragment: str +): + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, "Expected at least one validation error" + found = False + for error in validation_errors: + field = str(error.get("field", "")) + message = str(error.get("error", "")) + if field_fragment and field_fragment not in field: + continue + if error_fragment in message: + found = True + break + assert ( + found + ), f"Expected validation error containing field '{field_fragment}' and message '{error_fragment}'" + + @then( 'the response includes a validation error indicating the missing "address_type" value' ) @@ -214,4 +234,59 @@ def step_step_step_10(context): _handle_validation_error(context, expected_errors) +@then( + 'the response includes a validation error indicating an invalid "address_type" value' +) +def step_then_response_includes_invalid_address_type_error(context: Context): + _assert_any_validation_error_contains(context, "address", "Input should be") + + +@then("the response includes a validation error indicating an invalid state value") +def step_then_response_includes_invalid_state_error(context: Context): + _assert_any_validation_error_contains( + context, "state", "Value error, State must be a 2 letter abbreviation" + ) + + +@then( + 'the response includes a validation error indicating an invalid "well_hole_status" value' +) +def step_then_response_includes_invalid_well_hole_status_error(context: Context): + _assert_any_validation_error_contains( + context, "Database error", "database error occurred" + ) + + +@then( + 'the response includes a validation error indicating an invalid "monitoring_status" value' +) +def step_then_response_includes_invalid_monitoring_status_error(context: Context): + _assert_any_validation_error_contains(context, "monitoring", "Input should be") + + +@then( + 'the response includes a validation error indicating an invalid "well_pump_type" value' +) +def step_then_response_includes_invalid_well_pump_type_error(context: Context): + _assert_any_validation_error_contains(context, "Invalid value", "well_pump_type") + + +@then( + 'the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided' +) +def step_then_response_includes_contact_name_or_org_required_error(context: Context): + _assert_any_validation_error_contains( + context, "composite field error", "contact_1_name is required" + ) + + +@then( + 'the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided' +) +def step_then_response_includes_water_level_datetime_required_error(context: Context): + _assert_any_validation_error_contains( + context, "composite field error", "All water level fields must be provided" + ) + + # ============= EOF ============================================= diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index c8c7f2e5..1500a5f9 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -1,3 +1,4 @@ +@production @backend @cli @BDMS-TBD From 4519fc5cd30be63b8985c980312fc063cafa1549 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Tue, 3 Mar 2026 17:08:07 -0700 Subject: [PATCH 008/163] Update tests/features/steps/well-inventory-csv-validation-error.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/features/steps/well-inventory-csv-validation-error.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 3a9b2475..3a00c219 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -272,12 +272,15 @@ def step_then_response_includes_invalid_well_pump_type_error(context: Context): @then( - 'the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided' + 'the response includes validation errors indicating that both "contact_1_name" and "contact_1_organization" must be provided when any contact information is present' ) def step_then_response_includes_contact_name_or_org_required_error(context: Context): _assert_any_validation_error_contains( context, "composite field error", "contact_1_name is required" ) + _assert_any_validation_error_contains( + context, "composite field error", "contact_1_organization is required" + ) @then( From 2c3cde404cd56ce9564cb1ef1af3c4a3723716d9 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 3 Mar 2026 17:08:24 -0700 Subject: [PATCH 009/163] feat(tests): update validation error message for well_pump_type field --- tests/features/steps/well-inventory-csv-validation-error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 3a00c219..2b71d31d 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -268,7 +268,7 @@ def step_then_response_includes_invalid_monitoring_status_error(context: Context 'the response includes a validation error indicating an invalid "well_pump_type" value' ) def step_then_response_includes_invalid_well_pump_type_error(context: Context): - _assert_any_validation_error_contains(context, "Invalid value", "well_pump_type") + _assert_any_validation_error_contains(context, "well_pump_type", "Input should be") @then( From bc8955885098c2411a5cb67517b4c0af458545e5 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 3 Mar 2026 17:15:32 -0700 Subject: [PATCH 010/163] feat: enhance validation error handling for contact fields and improve error extraction --- services/well_inventory_csv.py | 15 +++++++++++-- .../well-inventory-csv-validation-error.py | 21 ++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 247091a2..561210f4 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -195,11 +195,12 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): added = _add_csv_row(session, group, model, user) wells.append(added) except ValueError as e: + error_text = str(e) validation_errors.append( { "row": current_row_id or "unknown", - "field": "Invalid value", - "error": str(e), + "field": _extract_field_from_value_error(error_text), + "error": error_text, } ) session.rollback() @@ -238,6 +239,16 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): } +def _extract_field_from_value_error(error_text: str) -> str: + """Best-effort extraction of field name from wrapped validation errors.""" + lines = [line.strip() for line in error_text.splitlines() if line.strip()] + if len(lines) >= 3 and re.match(r"^\d+ validation error", lines[0]): + field_name = lines[1] + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", field_name): + return field_name + return "Invalid value" + + def _make_location(model) -> Location: point = Point(model.utm_easting, model.utm_northing) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 2b71d31d..928c95e7 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -271,16 +271,27 @@ def step_then_response_includes_invalid_well_pump_type_error(context: Context): _assert_any_validation_error_contains(context, "well_pump_type", "Input should be") +@then( + 'the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided' +) @then( 'the response includes validation errors indicating that both "contact_1_name" and "contact_1_organization" must be provided when any contact information is present' ) def step_then_response_includes_contact_name_or_org_required_error(context: Context): - _assert_any_validation_error_contains( - context, "composite field error", "contact_1_name is required" - ) - _assert_any_validation_error_contains( - context, "composite field error", "contact_1_organization is required" + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, "Expected at least one validation error" + found = any( + "composite field error" in str(err.get("field", "")) + and ( + "contact_1_name is required" in str(err.get("error", "")) + or "contact_1_organization is required" in str(err.get("error", "")) + ) + for err in validation_errors ) + assert ( + found + ), "Expected contact validation error requiring contact_1_name or contact_1_organization" @then( From 5471b0b7cb872697c9cecb20f9e6bf830bbcd742 Mon Sep 17 00:00:00 2001 From: mar <78176736+marissafichera@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:47:43 -0700 Subject: [PATCH 011/163] `test(features): enforce lexicon values for water-level descriptor fields - Update water-level CSV feature to require lexicon-valid values (when provided) for: - sample_method - level_status - data_quality - Replace free-text acceptance scenario with negative validation scenario for invalid lexicon values - Keep failure expectations consistent: non-zero exit, field/row validation errors, and no imports` --- tests/features/water-level-csv.feature | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index bfdd098c..701718c3 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -31,6 +31,7 @@ Feature: Bulk upload water level entries from CSV via CLI And each "well_name_point_id" value matches an existing well And "field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00") And "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") + And when provided, "sample_method", "level_status", and "data_quality" values are valid lexicon values And the CSV includes optional fields when available: | optional field name | | field_staff_2 | @@ -141,13 +142,13 @@ Feature: Bulk upload water level entries from CSV via CLI And stderr should contain validation errors identifying the invalid field and row And no water level entries are imported - @negative @validation @string_fields @BDMS-TBD - Scenario: Upload accepts free-text values for measuring person and water level descriptors - Given my CSV file contains non-lexicon text values for "measuring_person", "sample_method", "level_status", and "data_quality" + @negative @validation @lexicon_values @BDMS-TBD + Scenario: Upload fails due to invalid lexicon values for water level descriptor fields + Given my CSV file contains invalid lexicon values for "sample_method", "level_status", or "data_quality" When I run the CLI command: """ oco water-levels bulk-upload --file ./water_levels.csv """ - Then the command exits with code 0 - And all water level entries are imported - And stderr should be empty + 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 814092b2b916efd453e94598107ff1fb01cb343e Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 10:55:33 -0700 Subject: [PATCH 012/163] feat: add normalized chemistry results materialized view and update related configurations --- ...zed_chemistry_results_materialized_view.py | 293 ++++++++++++++++++ cli/cli.py | 1 + core/pygeoapi-config.yml | 23 ++ docker-compose.yml | 38 ++- entrypoint.sh | 11 +- tests/test_cli_commands.py | 9 +- tests/test_ogc.py | 2 + 7 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py diff --git a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py new file mode 100644 index 00000000..ba10d567 --- /dev/null +++ b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py @@ -0,0 +1,293 @@ +"""add normalized chemistry results materialized view + +Revision ID: b6f7a8b9c0d1 +Revises: l5e6f7a8b9c0 +Create Date: 2026-03-04 14:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "b6f7a8b9c0d1" +down_revision: Union[str, Sequence[str], None] = "l5e6f7a8b9c0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +# Static analyte columns for major chemistry pivots. +# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K). +STATIC_ANALYTE_COLUMNS: list[tuple[str, str]] = [ + ("tds", "tds"), + ("calcium", "calcium"), + ("calcium_total", "calcium_total"), + ("magnesium", "magnesium"), + ("magnesium_total", "magnesium_total"), + ("sodium", "sodium"), + ("sodium_total", "sodium_total"), + ("potassium", "potassium"), + ("potassium_total", "potassium_total"), + ("sodium_plus_potassium", "sodium_plus_potassium"), + ("bicarbonate", "bicarbonate"), + ("carbonate", "carbonate"), + ("sulfate", "sulfate"), + ("chloride", "chloride"), + ("ion_balance", "ion_balance"), + ("total_anions", "total_anions"), + ("total_cations", "total_cations"), + ("alkalinity", "alkalinity"), + ("hardness", "hardness"), + ("specific_conductance", "specific_conductance"), + ("ph", "ph"), + ("nitrate", "nitrate"), + ("fluoride", "fluoride"), + ("silica", "silica"), +] + + +def _static_analyte_select_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _static_analyte_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _create_normalized_chemistry_results_view() -> str: + static_columns = _static_analyte_select_columns() + static_unit_columns = _static_analyte_unit_columns() + return f""" + CREATE MATERIALIZED VIEW ogc_normalized_chemistry_results AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mc.id AS result_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + trim(mc."Analyte") AS analyte_name, + trim(mc."Symbol") AS symbol_name, + mc."SampleValue"::double precision AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + WHERE mc."SampleValue" IS NOT NULL + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.symbol_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS symbol_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.symbol_token, '') = 'tds' + OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids') + THEN 'tds' + + WHEN coalesce(nr.symbol_token, '') = 'ca' + OR coalesce(nr.analyte_token, '') = 'ca' + THEN 'calcium' + WHEN coalesce(nr.analyte_token, '') = 'catotal' + THEN 'calcium_total' + + WHEN coalesce(nr.symbol_token, '') = 'mg' + OR coalesce(nr.analyte_token, '') = 'mg' + THEN 'magnesium' + WHEN coalesce(nr.analyte_token, '') = 'mgtotal' + THEN 'magnesium_total' + + WHEN coalesce(nr.symbol_token, '') = 'na' + OR coalesce(nr.analyte_token, '') = 'na' + THEN 'sodium' + WHEN coalesce(nr.analyte_token, '') = 'natotal' + THEN 'sodium_total' + + WHEN coalesce(nr.symbol_token, '') = 'k' + OR coalesce(nr.analyte_token, '') = 'k' + THEN 'potassium' + WHEN coalesce(nr.analyte_token, '') = 'ktotal' + THEN 'potassium_total' + + WHEN coalesce(nr.analyte_token, '') = 'nak' + THEN 'sodium_plus_potassium' + + WHEN coalesce(nr.symbol_token, '') = 'hco3' + OR coalesce(nr.analyte_token, '') = 'hco3' + THEN 'bicarbonate' + WHEN coalesce(nr.symbol_token, '') = 'co3' + OR coalesce(nr.analyte_token, '') = 'co3' + THEN 'carbonate' + WHEN coalesce(nr.symbol_token, '') = 'so4' + OR coalesce(nr.analyte_token, '') = 'so4' + THEN 'sulfate' + WHEN coalesce(nr.symbol_token, '') = 'cl' + OR coalesce(nr.analyte_token, '') = 'cl' + THEN 'chloride' + + WHEN coalesce(nr.analyte_token, '') = 'ionbal' + THEN 'ion_balance' + WHEN coalesce(nr.analyte_token, '') = 'tan' + THEN 'total_anions' + WHEN coalesce(nr.analyte_token, '') = 'tcat' + THEN 'total_cations' + + WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity') + THEN 'alkalinity' + WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness') + THEN 'hardness' + WHEN coalesce(nr.analyte_token, '') IN ( + 'condlab', + 'specificconductance', + 'specificconductivity', + 'conductivity' + ) + THEN 'specific_conductance' + WHEN coalesce(nr.symbol_token, '') = 'ph' + OR coalesce(nr.analyte_token, '') IN ('ph', 'phl') + THEN 'ph' + + WHEN coalesce(nr.symbol_token, '') = 'no3' + OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate') + THEN 'nitrate' + WHEN coalesce(nr.symbol_token, '') = 'f' + OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride') + THEN 'fluoride' + WHEN coalesce(nr.symbol_token, '') = 'sio2' + OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica') + THEN 'silica' + + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{static_columns}, +{static_unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "NMA_Chemistry_SampleInfo", + "NMA_MajorChemistry", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_normalized_chemistry_results. Missing required tables: " + + ", ".join(missing) + ) + + op.execute( + text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") + ) + op.execute(text(_create_normalized_chemistry_results_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_normalized_chemistry_results IS " + "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_normalized_chemistry_results_id " + "ON ogc_normalized_chemistry_results (id)" + ) + ) + + +def downgrade() -> None: + op.execute( + text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") + ) diff --git a/cli/cli.py b/cli/cli.py index 09c20185..da1972c0 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -55,6 +55,7 @@ class SmokePopulation(str, Enum): "ogc_avg_tds_wells", "ogc_depth_to_water_trend_wells", "ogc_water_well_summary", + "ogc_normalized_chemistry_results", ) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 1a468b13..80227db0 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -172,4 +172,27 @@ resources: table: ogc_water_well_summary geom_field: point + normalized_chemistry_results: + type: collection + title: Normalized Chemistry Results + description: Latest major chemistry analyte values per location, represented as static analyte columns. + keywords: [chemistry, analytes, normalized, major-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_normalized_chemistry_results + geom_field: point + {thing_collections_block} diff --git a/docker-compose.yml b/docker-compose.yml index 9eb88baf..e3a57f96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # keep docker-compose.yml in root directory to configure with root .env services: - db: + db_dev: build: context: . dockerfile: ./docker/db/Dockerfile @@ -9,13 +9,32 @@ services: environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_DB=ocotilloapi_dev ports: - 5432:5432 volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data_dev:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ocotilloapi_dev"] + interval: 2s + timeout: 5s + retries: 20 + + db_test: + build: + context: . + dockerfile: ./docker/db/Dockerfile + platform: linux/amd64 + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=ocotilloapi_test + ports: + - 5433:5432 + volumes: + - postgres_data_test:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ocotilloapi_test"] interval: 2s timeout: 5s retries: 20 @@ -27,20 +46,21 @@ services: environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_HOST=db + - POSTGRES_DB=ocotilloapi_dev + - POSTGRES_HOST=db_dev - POSTGRES_PORT=5432 - MODE=${MODE} - AUTHENTIK_DISABLE_AUTHENTICATION=${AUTHENTIK_DISABLE_AUTHENTICATION} ports: - 8000:8000 depends_on: - db: + db_dev: condition: service_healthy # <-- wait for DB to be ready links: - - db + - db_dev volumes: - .:/app volumes: - postgres_data: + postgres_data_dev: + postgres_data_test: diff --git a/entrypoint.sh b/entrypoint.sh index 66248761..91b46aa6 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,7 +1,12 @@ #!/bin/sh + +DB_HOST="${POSTGRES_HOST:-db}" +DB_PORT="${POSTGRES_PORT:-5432}" +DB_NAME="${POSTGRES_DB:-postgres}" + # Wait for PostgreSQL to be ready -until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h db -p 5432 -U "$POSTGRES_USER"; do - echo "Waiting for postgres..." +until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -d "$DB_NAME"; do + echo "Waiting for postgres at ${DB_HOST}:${DB_PORT}/${DB_NAME}..." sleep 2 done echo "PostgreSQL is ready!" @@ -9,4 +14,4 @@ echo "PostgreSQL is ready!" echo "Applying migrations..." alembic upgrade head echo "Starting the application..." -uvicorn main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file +uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 0673d8ba..738de291 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -58,9 +58,10 @@ def __exit__(self, exc_type, exc, tb): "REFRESH MATERIALIZED VIEW ogc_avg_tds_wells", "REFRESH MATERIALIZED VIEW ogc_depth_to_water_trend_wells", "REFRESH MATERIALIZED VIEW ogc_water_well_summary", + "REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results", ] assert commit_called["value"] is True - assert "Refreshed 4 materialized view(s)." in result.output + assert "Refreshed 5 materialized view(s)." in result.output def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): @@ -335,10 +336,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index e243c90b..52b36118 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -202,6 +202,7 @@ def test_ogc_collections(): "latest_tds_wells", "depth_to_water_trend_wells", "water_well_summary", + "normalized_chemistry_results", }.issubset(ids) @@ -210,6 +211,7 @@ def test_ogc_new_collection_items_endpoints(): "latest_tds_wells", "depth_to_water_trend_wells", "water_well_summary", + "normalized_chemistry_results", ): response = client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") assert response.status_code == 200 From 884ffc66eb7f96aaa846965cc0a3372c5a16f355 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:56:00 +0000 Subject: [PATCH 013/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 738de291..d504fb96 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -336,12 +336,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 8e9f4e61bac39f6f265432f56ebec5da4daebca4 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 13:39:38 -0700 Subject: [PATCH 014/163] feat: update environment configuration for Docker and enhance README with local development setup --- .env.example | 2 +- README.md | 38 ++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 2 ++ entrypoint.sh | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index d8a7547d..08dda83e 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DB_DRIVER=postgres POSTGRES_USER=admin POSTGRES_PASSWORD=password -POSTGRES_DB=ocotillo +POSTGRES_DB=ocotilloapi_dev POSTGRES_HOST=localhost POSTGRES_PORT=5432 diff --git a/README.md b/README.md index 155dc2b9..8e20b678 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,36 @@ Notes: * Create file gcs_credentials.json in the root directory of the project, and obtain its contents from a teammate. * PostgreSQL uses the default port 5432. +Minimum vars to set in `.env` for local development: +* `POSTGRES_USER` +* `POSTGRES_PASSWORD` +* `POSTGRES_DB` (`ocotilloapi_dev` when using Docker Compose dev) +* `POSTGRES_HOST` (`localhost` for local psql/pytest against mapped Docker port) +* `POSTGRES_PORT` (`5432`) +* `MODE` (`development` recommended locally) +* `SESSION_SECRET_KEY` + +Auth-related vars (required when auth is enabled, optional when `AUTHENTIK_DISABLE_AUTHENTICATION=1`): +* `AUTHENTIK_DISABLE_AUTHENTICATION` +* `AUTHENTIK_URL` +* `AUTHENTIK_CLIENT_ID` +* `AUTHENTIK_AUTHORIZE_URL` +* `AUTHENTIK_TOKEN_URL` + +pygeoapi vars: +* `PYGEOAPI_MOUNT_PATH` (default `/ogcapi`) +* `PYGEOAPI_RUNTIME_DIR` (default `/tmp/pygeoapi`) +* `PYGEOAPI_POSTGRES_HOST` +* `PYGEOAPI_POSTGRES_PORT` +* `PYGEOAPI_POSTGRES_DB` +* `PYGEOAPI_POSTGRES_USER` +* `PYGEOAPI_POSTGRES_PASSWORD` + +Optional telemetry vars: +* `SENTRY_DSN` +* `APITALLY_CLIENT_ID` +* `ENVIRONMENT` + In development set `MODE=development` to allow lexicon enums to be populated. When `MODE=development`, the app attempts to seed the database with 10 example records via `transfers/seed.py`; if a `contact` record already exists, the seed step is skipped. #### 5. Database and server @@ -169,9 +199,13 @@ docker compose up --build Notes: * Requires Docker Desktop. -* Spins up two containers: `db` (PostGIS/PostgreSQL) and `app` (FastAPI API service). +* By default, spins up two containers: `db_dev` (PostGIS/PostgreSQL) and `app` (FastAPI API service). +* `db_test` is opt-in via profile: `docker compose --profile test up`. * `alembic upgrade head` runs on app startup after `docker compose up`. -* The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` in your `.env` to run local commands against the Docker DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). +* Compose uses hardcoded DB names: + * dev: `ocotilloapi_dev` + * test: `ocotilloapi_test` +* The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). #### Staging Data diff --git a/docker-compose.yml b/docker-compose.yml index e3a57f96..61ddc3b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: retries: 20 db_test: + profiles: + - test build: context: . dockerfile: ./docker/db/Dockerfile diff --git a/entrypoint.sh b/entrypoint.sh index 91b46aa6..3fd13d48 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,5 @@ #!/bin/sh +set -eu DB_HOST="${POSTGRES_HOST:-db}" DB_PORT="${POSTGRES_PORT:-5432}" From ae1ce5ed280733e87b8d3aa87d2c62a8029565c7 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 13:41:55 -0700 Subject: [PATCH 015/163] feat: update database service configuration in tests to use development setup --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 79bfcd7e..a1ef3010 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,8 +37,8 @@ jobs: - name: Start database (PostGIS) run: | - docker compose build db - docker compose up -d db + docker compose build db_dev + docker compose up -d db_dev - name: Wait for database readiness run: | @@ -122,8 +122,8 @@ jobs: - name: Start database (PostGIS) run: | - docker compose build db - docker compose up -d db + docker compose build db_dev + docker compose up -d db_dev - name: Wait for database readiness run: | From 181218ac2f8f2e08c3b9375565cf0a0c7b947d77 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 14:00:56 -0700 Subject: [PATCH 016/163] feat: add minor chemistry wells materialized view and update related configurations --- ...zed_chemistry_results_materialized_view.py | 1 + ...minor_chemistry_wells_materialized_view.py | 329 ++++++++++++++++++ cli/cli.py | 1 + core/pygeoapi-config.yml | 29 +- tests/test_cli_commands.py | 9 +- tests/test_ogc.py | 2 + 6 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py diff --git a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py index ba10d567..b497740a 100644 --- a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py +++ b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py @@ -246,6 +246,7 @@ def _create_normalized_chemistry_results_view() -> str: JOIN latest_location AS ll ON ll.thing_id = t.id JOIN location AS l ON l.id = ll.location_id WHERE lr.rn = 1 + AND t.thing_type = 'water well' GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point """ diff --git a/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py new file mode 100644 index 00000000..302e88bf --- /dev/null +++ b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py @@ -0,0 +1,329 @@ +"""add minor chemistry wells materialized view + +Revision ID: c7f8a9b0d1e2 +Revises: b6f7a8b9c0d1 +Create Date: 2026-03-04 16:20:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "c7f8a9b0d1e2" +down_revision: Union[str, Sequence[str], None] = "b6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +STATIC_ANALYTE_COLUMNS: list[tuple[str, str]] = [ + ("h2r", "h2r"), + ("o18r", "o18r"), + ("c13r", "c13r"), + ("c14", "c14"), + ("c14_years", "c14_years"), + ("fluoride", "fluoride"), + ("barium", "barium"), + ("barium_total", "barium_total"), + ("copper", "copper"), + ("copper_total", "copper_total"), + ("zinc", "zinc"), + ("zinc_total", "zinc_total"), + ("molybdenum", "molybdenum"), + ("molybdenum_total", "molybdenum_total"), + ("silica", "silica"), + ("silicon", "silicon"), + ("silicon_total", "silicon_total"), + ("manganese", "manganese"), + ("manganese_total", "manganese_total"), + ("iron", "iron"), + ("iron_total", "iron_total"), + ("strontium", "strontium"), + ("strontium_total", "strontium_total"), + ("chromium", "chromium"), + ("chromium_total", "chromium_total"), + ("boron", "boron"), + ("boron_total", "boron_total"), + ("uranium", "uranium"), + ("uranium_total", "uranium_total"), + ("lithium", "lithium"), + ("lithium_total", "lithium_total"), + ("silver", "silver"), + ("silver_total", "silver_total"), + ("antimony", "antimony"), + ("antimony_total", "antimony_total"), + ("beryllium", "beryllium"), + ("beryllium_total", "beryllium_total"), + ("lead", "lead"), + ("lead_total", "lead_total"), + ("thallium", "thallium"), + ("thallium_total", "thallium_total"), + ("bromide", "bromide"), + ("selenium", "selenium"), + ("selenium_total", "selenium_total"), + ("vanadium", "vanadium"), + ("vanadium_total", "vanadium_total"), + ("aluminum", "aluminum"), + ("aluminum_total", "aluminum_total"), + ("arsenic", "arsenic"), + ("arsenic_total", "arsenic_total"), + ("nickel", "nickel"), + ("nickel_total", "nickel_total"), + ("cadmium", "cadmium"), + ("cadmium_total", "cadmium_total"), + ("cobalt", "cobalt"), + ("cobalt_total", "cobalt_total"), + ("phosphate", "phosphate"), + ("nitrite", "nitrite"), + ("nitrate", "nitrate"), + ("nitrate_as_n", "nitrate_as_n"), + ("thorium", "thorium"), + ("thorium_total", "thorium_total"), + ("tin", "tin"), + ("tin_total", "tin_total"), + ("mercury", "mercury"), + ("mercury_total", "mercury_total"), + ("titanium", "titanium"), + ("titanium_total", "titanium_total"), +] + + +def _static_analyte_value_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _static_analyte_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _create_minor_chemistry_wells_view() -> str: + value_columns = _static_analyte_value_columns() + unit_columns = _static_analyte_unit_columns() + + return f""" + CREATE MATERIALIZED VIEW ogc_minor_chemistry_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mtc.id AS result_id, + COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime, + trim(mtc.analyte) AS analyte_name, + trim(mtc.symbol) AS symbol_name, + mtc.sample_value::double precision AS sample_value, + mtc.units AS units + FROM "NMA_MinorTraceChemistry" AS mtc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mtc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + mtc.sample_value IS NOT NULL + AND t.thing_type = 'water well' + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.symbol_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS symbol_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r' + WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r' + WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r' + WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14' + WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years' + + WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride' + WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium' + WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total' + WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper' + WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total' + WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc' + WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total' + WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum' + WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total' + WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica' + WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon' + WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total' + WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese' + WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total' + WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron' + WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total' + WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium' + WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total' + WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium' + WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total' + WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron' + WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total' + WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium' + WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total' + WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium' + WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total' + WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver' + WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total' + WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony' + WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total' + WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium' + WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total' + WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead' + WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total' + WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium' + WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total' + WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide' + WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium' + WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total' + WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium' + WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total' + WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum' + WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total' + WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic' + WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total' + WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel' + WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total' + WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium' + WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total' + WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt' + WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total' + WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate' + WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite' + WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate' + WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n' + WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium' + WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total' + WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin' + WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total' + WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury' + WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total' + WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium' + WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total' + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{value_columns}, +{unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + AND t.thing_type = 'water well' + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "NMA_Chemistry_SampleInfo", + "NMA_MinorTraceChemistry", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_minor_chemistry_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_minor_chemistry_wells")) + op.execute(text(_create_minor_chemistry_wells_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_minor_chemistry_wells IS " + "'Latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_minor_chemistry_wells_id " + "ON ogc_minor_chemistry_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_minor_chemistry_wells")) diff --git a/cli/cli.py b/cli/cli.py index da1972c0..808a7f68 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -56,6 +56,7 @@ class SmokePopulation(str, Enum): "ogc_depth_to_water_trend_wells", "ogc_water_well_summary", "ogc_normalized_chemistry_results", + "ogc_minor_chemistry_wells", ) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 80227db0..15cf2a73 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -174,9 +174,9 @@ resources: normalized_chemistry_results: type: collection - title: Normalized Chemistry Results - description: Latest major chemistry analyte values per location, represented as static analyte columns. - keywords: [chemistry, analytes, normalized, major-chemistry] + title: Major Chemistry (Water Wells) + description: Latest major chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, major-chemistry] extents: spatial: bbox: [-109.05, 31.33, -103.00, 37.00] @@ -195,4 +195,27 @@ resources: table: ogc_normalized_chemistry_results geom_field: point + minor_chemistry_wells: + type: collection + title: Minor Chemistry (Water Wells) + description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_minor_chemistry_wells + geom_field: point + {thing_collections_block} diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index d504fb96..7b218351 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -59,9 +59,10 @@ def __exit__(self, exc_type, exc, tb): "REFRESH MATERIALIZED VIEW ogc_depth_to_water_trend_wells", "REFRESH MATERIALIZED VIEW ogc_water_well_summary", "REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results", + "REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells", ] assert commit_called["value"] is True - assert "Refreshed 5 materialized view(s)." in result.output + assert "Refreshed 6 materialized view(s)." in result.output def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): @@ -336,10 +337,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 52b36118..4793996a 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -203,6 +203,7 @@ def test_ogc_collections(): "depth_to_water_trend_wells", "water_well_summary", "normalized_chemistry_results", + "minor_chemistry_wells", }.issubset(ids) @@ -212,6 +213,7 @@ def test_ogc_new_collection_items_endpoints(): "depth_to_water_trend_wells", "water_well_summary", "normalized_chemistry_results", + "minor_chemistry_wells", ): response = client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") assert response.status_code == 200 From 8513ad01739f44d93c266f2a052c74527848b55d Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:01:18 +0000 Subject: [PATCH 017/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 7b218351..ea68aeec 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -337,12 +337,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From e15d366144f9359b9e450e861edef7420d271337 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 14:04:09 -0700 Subject: [PATCH 018/163] feat: add test for normalized major chemistry to ensure latest results are used --- tests/test_ogc.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 4793996a..57ffe3ef 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -190,6 +190,77 @@ def test_latest_tds_uses_latest_timestamp_within_same_day(water_well_thing): session.commit() +def test_ogc_normalized_major_chemistry_uses_latest_per_analyte(water_well_thing): + with session_ctx() as session: + csi = NMA_Chemistry_SampleInfo( + thing_id=water_well_thing.id, + nma_sample_point_id="MAJOR-NORM-01", + collection_date=datetime(2024, 3, 1, 10, 0, 0), + ) + session.add(csi) + session.flush() + + # Older calcium result + calcium_old = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Ca", + symbol="", + sample_value=80.0, + units="mg/L", + analysis_date=datetime(2024, 3, 1, 9, 0, 0), + ) + # Newer calcium result that should win for calcium + calcium_units + calcium_new = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Ca", + symbol="", + sample_value=95.0, + units="mg/L as CaCO3", + analysis_date=datetime(2024, 3, 2, 9, 0, 0), + ) + # Separate analyte with even later date to drive latest_chemistry_date + chloride = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Cl", + symbol="", + sample_value=40.0, + units="mg/L", + analysis_date=datetime(2024, 3, 3, 8, 0, 0), + ) + + session.add_all([calcium_old, calcium_new, chloride]) + session.commit() + + session.execute( + text("REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results") + ) + session.commit() + + row = session.execute( + text( + "SELECT calcium, calcium_units, chloride, chloride_units, latest_chemistry_date " + "FROM ogc_normalized_chemistry_results WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.calcium) == 95.0 + assert row.calcium_units == "mg/L as CaCO3" + assert float(row.chloride) == 40.0 + assert row.chloride_units == "mg/L" + assert row.latest_chemistry_date.isoformat() == "2024-03-03" + + session.delete(chloride) + session.delete(calcium_new) + session.delete(calcium_old) + session.delete(csi) + session.commit() + session.execute( + text("REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results") + ) + session.commit() + + def test_ogc_collections(): response = client.get("/ogcapi/collections") assert response.status_code == 200 From 7cebfcc9b9d592bf7526e26920b16255adc07dfd Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 4 Mar 2026 14:39:27 -0700 Subject: [PATCH 019/163] feat: rename normalized chemistry results to major chemistry results and update related configurations --- .github/workflows/tests.yml | 8 +- README.md | 8 +- ...zed_chemistry_results_materialized_view.py | 20 +++-- ...minor_chemistry_wells_materialized_view.py | 10 --- cli/cli.py | 2 +- core/pygeoapi-config.yml | 4 +- docker-compose.yml | 31 ++----- docker/db/init/01-create-test-db.sql | 10 +++ tests/__init__.py | 20 ++++- tests/conftest.py | 11 ++- tests/test_cli_commands.py | 8 +- tests/test_ogc.py | 90 ++++++++++++++++--- 12 files changed, 150 insertions(+), 72 deletions(-) create mode 100644 docker/db/init/01-create-test-db.sql diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1ef3010..79bfcd7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,8 +37,8 @@ jobs: - name: Start database (PostGIS) run: | - docker compose build db_dev - docker compose up -d db_dev + docker compose build db + docker compose up -d db - name: Wait for database readiness run: | @@ -122,8 +122,8 @@ jobs: - name: Start database (PostGIS) run: | - docker compose build db_dev - docker compose up -d db_dev + docker compose build db + docker compose up -d db - name: Wait for database readiness run: | diff --git a/README.md b/README.md index 8e20b678..7e35d3ec 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,14 @@ docker compose up --build Notes: * Requires Docker Desktop. -* By default, spins up two containers: `db_dev` (PostGIS/PostgreSQL) and `app` (FastAPI API service). -* `db_test` is opt-in via profile: `docker compose --profile test up`. +* By default, spins up two containers: `db` (PostGIS/PostgreSQL) and `app` (FastAPI API service). +* `db` initializes both application databases in the same Postgres service: + * `ocotilloapi_dev` + * `ocotilloapi_test` * `alembic upgrade head` runs on app startup after `docker compose up`. * Compose uses hardcoded DB names: * dev: `ocotilloapi_dev` - * test: `ocotilloapi_test` + * test: `ocotilloapi_test` (created by init SQL in `docker/db/init/01-create-test-db.sql`) * The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). #### Staging Data diff --git a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py index b497740a..a70edaf0 100644 --- a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py +++ b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py @@ -80,11 +80,11 @@ def _static_analyte_unit_columns() -> str: ) -def _create_normalized_chemistry_results_view() -> str: +def _create_major_chemistry_results_view() -> str: static_columns = _static_analyte_select_columns() static_unit_columns = _static_analyte_unit_columns() return f""" - CREATE MATERIALIZED VIEW ogc_normalized_chemistry_results AS + CREATE MATERIALIZED VIEW ogc_major_chemistry_results AS WITH latest_location AS ( {LATEST_LOCATION_CTE} ), @@ -100,7 +100,10 @@ def _create_normalized_chemistry_results_view() -> str: FROM "NMA_MajorChemistry" AS mc JOIN "NMA_Chemistry_SampleInfo" AS csi ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t + ON t.id = csi.thing_id WHERE mc."SampleValue" IS NOT NULL + AND t.thing_type = 'water well' ), normalized_rows AS ( SELECT @@ -246,7 +249,6 @@ def _create_normalized_chemistry_results_view() -> str: JOIN latest_location AS ll ON ll.thing_id = t.id JOIN location AS l ON l.id = ll.location_id WHERE lr.rn = 1 - AND t.thing_type = 'water well' GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point """ @@ -266,29 +268,31 @@ def upgrade() -> None: if not required_tables.issubset(existing_tables): missing = sorted(t for t in required_tables if t not in existing_tables) raise RuntimeError( - "Cannot create ogc_normalized_chemistry_results. Missing required tables: " + "Cannot create ogc_major_chemistry_results. Missing required tables: " + ", ".join(missing) ) + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_major_chemistry_results")) op.execute( text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") ) - op.execute(text(_create_normalized_chemistry_results_view())) + op.execute(text(_create_major_chemistry_results_view())) op.execute( text( - "COMMENT ON MATERIALIZED VIEW ogc_normalized_chemistry_results IS " + "COMMENT ON MATERIALIZED VIEW ogc_major_chemistry_results IS " "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'" ) ) op.execute( text( - "CREATE UNIQUE INDEX ux_ogc_normalized_chemistry_results_id " - "ON ogc_normalized_chemistry_results (id)" + "CREATE UNIQUE INDEX ux_ogc_major_chemistry_results_id " + "ON ogc_major_chemistry_results (id)" ) ) def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_major_chemistry_results")) op.execute( text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") ) diff --git a/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py index 302e88bf..e2e014ac 100644 --- a/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py +++ b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py @@ -137,7 +137,6 @@ def _create_minor_chemistry_wells_view() -> str: mtc.id AS result_id, COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime, trim(mtc.analyte) AS analyte_name, - trim(mtc.symbol) AS symbol_name, mtc.sample_value::double precision AS sample_value, mtc.units AS units FROM "NMA_MinorTraceChemistry" AS mtc @@ -162,15 +161,6 @@ def _create_minor_chemistry_wells_view() -> str: ), '' ) AS analyte_token, - NULLIF( - regexp_replace( - lower(trim(coalesce(cr.symbol_name, ''))), - '[^a-z0-9]+', - '', - 'g' - ), - '' - ) AS symbol_token, cr.sample_value, cr.units FROM chemistry_rows AS cr diff --git a/cli/cli.py b/cli/cli.py index 808a7f68..e9f8dc36 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -55,7 +55,7 @@ class SmokePopulation(str, Enum): "ogc_avg_tds_wells", "ogc_depth_to_water_trend_wells", "ogc_water_well_summary", - "ogc_normalized_chemistry_results", + "ogc_major_chemistry_results", "ogc_minor_chemistry_wells", ) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 15cf2a73..0a205d29 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -172,7 +172,7 @@ resources: table: ogc_water_well_summary geom_field: point - normalized_chemistry_results: + major_chemistry_results: type: collection title: Major Chemistry (Water Wells) description: Latest major chemistry analyte values for water wells, represented as static analyte columns. @@ -192,7 +192,7 @@ resources: password: {postgres_password_env} search_path: [public] id_field: id - table: ogc_normalized_chemistry_results + table: ogc_major_chemistry_results geom_field: point minor_chemistry_wells: diff --git a/docker-compose.yml b/docker-compose.yml index 61ddc3b7..9a557f82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # keep docker-compose.yml in root directory to configure with root .env services: - db_dev: + db: build: context: . dockerfile: ./docker/db/Dockerfile @@ -14,33 +14,13 @@ services: - 5432:5432 volumes: - postgres_data_dev:/var/lib/postgresql/data + - ./docker/db/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ocotilloapi_dev"] interval: 2s timeout: 5s retries: 20 - db_test: - profiles: - - test - build: - context: . - dockerfile: ./docker/db/Dockerfile - platform: linux/amd64 - environment: - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=ocotilloapi_test - ports: - - 5433:5432 - volumes: - - postgres_data_test:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ocotilloapi_test"] - interval: 2s - timeout: 5s - retries: 20 - app: build: context: . @@ -49,20 +29,19 @@ services: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=ocotilloapi_dev - - POSTGRES_HOST=db_dev + - POSTGRES_HOST=db - POSTGRES_PORT=5432 - MODE=${MODE} - AUTHENTIK_DISABLE_AUTHENTICATION=${AUTHENTIK_DISABLE_AUTHENTICATION} ports: - 8000:8000 depends_on: - db_dev: + db: condition: service_healthy # <-- wait for DB to be ready links: - - db_dev + - db volumes: - .:/app volumes: postgres_data_dev: - postgres_data_test: diff --git a/docker/db/init/01-create-test-db.sql b/docker/db/init/01-create-test-db.sql new file mode 100644 index 00000000..53ab9cb5 --- /dev/null +++ b/docker/db/init/01-create-test-db.sql @@ -0,0 +1,10 @@ +-- Initialize test database inside the same Postgres service used for dev. +-- This script runs only when the data directory is first initialized. + +CREATE DATABASE ocotilloapi_test; + +\connect ocotilloapi_dev +CREATE EXTENSION IF NOT EXISTS postgis; + +\connect ocotilloapi_test +CREATE EXTENSION IF NOT EXISTS postgis; diff --git a/tests/__init__.py b/tests/__init__.py index b5cee011..57fa0c35 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,13 +14,29 @@ # limitations under the License. # =============================================================================== import os +import socket from functools import lru_cache from dotenv import load_dotenv # Load .env file BEFORE importing anything else -# Use override=True to override conflicting shell environment variables -load_dotenv(override=True) +# Use override=False so explicit shell environment variables can override .env +load_dotenv(override=False) + + +def _normalize_test_db_host() -> None: + """Fallback docker-compose hostnames to localhost for host-run tests.""" + for env_name in ("POSTGRES_HOST", "PYGEOAPI_POSTGRES_HOST"): + host = (os.environ.get(env_name) or "").strip() + if host != "db": + continue + try: + socket.gethostbyname(host) + except OSError: + os.environ[env_name] = "localhost" + + +_normalize_test_db_host() # for safety don't test on the production database port os.environ["POSTGRES_PORT"] = "5432" diff --git a/tests/conftest.py b/tests/conftest.py index 3847263b..a5f037b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import os +import socket import pytest from alembic import command @@ -16,7 +17,15 @@ def pytest_configure(): - load_dotenv(override=True) + load_dotenv(override=False) + for env_name in ("POSTGRES_HOST", "PYGEOAPI_POSTGRES_HOST"): + host = (os.environ.get(env_name) or "").strip() + if host != "db": + continue + try: + socket.gethostbyname(host) + except OSError: + os.environ[env_name] = "localhost" os.environ.setdefault("POSTGRES_PORT", "54321") # NOTE: This hardcoded secret key is for tests only and must NEVER be used in production. os.environ.setdefault("SESSION_SECRET_KEY", "test-session-secret-key") diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index ea68aeec..6d70f587 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -58,7 +58,7 @@ def __exit__(self, exc_type, exc, tb): "REFRESH MATERIALIZED VIEW ogc_avg_tds_wells", "REFRESH MATERIALIZED VIEW ogc_depth_to_water_trend_wells", "REFRESH MATERIALIZED VIEW ogc_water_well_summary", - "REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results", + "REFRESH MATERIALIZED VIEW ogc_major_chemistry_results", "REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells", ] assert commit_called["value"] is True @@ -337,10 +337,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 57ffe3ef..7912cab9 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime +from datetime import date, datetime from importlib.util import find_spec import pytest @@ -27,7 +27,7 @@ viewer_function, amp_viewer_function, ) -from db import NMA_Chemistry_SampleInfo, NMA_MajorChemistry +from db import NMA_Chemistry_SampleInfo, NMA_MajorChemistry, NMA_MinorTraceChemistry from db.engine import session_ctx from main import app from tests import client, override_authentication @@ -190,11 +190,11 @@ def test_latest_tds_uses_latest_timestamp_within_same_day(water_well_thing): session.commit() -def test_ogc_normalized_major_chemistry_uses_latest_per_analyte(water_well_thing): +def test_ogc_major_chemistry_results_uses_latest_per_analyte(water_well_thing): with session_ctx() as session: csi = NMA_Chemistry_SampleInfo( thing_id=water_well_thing.id, - nma_sample_point_id="MAJOR-NORM-01", + nma_sample_point_id="MAJNORM01", collection_date=datetime(2024, 3, 1, 10, 0, 0), ) session.add(csi) @@ -231,15 +231,13 @@ def test_ogc_normalized_major_chemistry_uses_latest_per_analyte(water_well_thing session.add_all([calcium_old, calcium_new, chloride]) session.commit() - session.execute( - text("REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results") - ) + session.execute(text("REFRESH MATERIALIZED VIEW ogc_major_chemistry_results")) session.commit() row = session.execute( text( "SELECT calcium, calcium_units, chloride, chloride_units, latest_chemistry_date " - "FROM ogc_normalized_chemistry_results WHERE id = :thing_id" + "FROM ogc_major_chemistry_results WHERE id = :thing_id" ), {"thing_id": water_well_thing.id}, ).one() @@ -255,9 +253,77 @@ def test_ogc_normalized_major_chemistry_uses_latest_per_analyte(water_well_thing session.delete(calcium_old) session.delete(csi) session.commit() - session.execute( - text("REFRESH MATERIALIZED VIEW ogc_normalized_chemistry_results") + session.execute(text("REFRESH MATERIALIZED VIEW ogc_major_chemistry_results")) + session.commit() + + +def test_ogc_minor_chemistry_wells_uses_latest_per_analyte(water_well_thing): + with session_ctx() as session: + csi = NMA_Chemistry_SampleInfo( + thing_id=water_well_thing.id, + nma_sample_point_id="MINRNORM1", + collection_date=datetime(2024, 4, 1, 10, 0, 0), + ) + session.add(csi) + session.flush() + + # Older barium result + barium_old = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="Ba", + symbol="", + sample_value=0.40, + units="mg/L", + analysis_date=date(2024, 4, 1), + ) + # Newer barium result that should win for barium + barium_units + barium_new = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="Ba", + symbol="", + sample_value=0.55, + units="ug/L", + analysis_date=date(2024, 4, 2), + ) + # Separate analyte with even later date to drive latest_chemistry_date + fluoride = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="F", + symbol="", + sample_value=1.2, + units="mg/L", + analysis_date=date(2024, 4, 3), ) + + session.add_all([barium_old, barium_new, fluoride]) + session.commit() + + session.execute(text("REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells")) + session.commit() + + row = session.execute( + text( + "SELECT barium, barium_units, fluoride, fluoride_units, latest_chemistry_date " + "FROM ogc_minor_chemistry_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.barium) == 0.55 + assert row.barium_units == "ug/L" + assert float(row.fluoride) == 1.2 + assert row.fluoride_units == "mg/L" + assert row.latest_chemistry_date.isoformat() == "2024-04-03" + + session.delete(fluoride) + session.delete(barium_new) + session.delete(barium_old) + session.delete(csi) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells")) session.commit() @@ -273,7 +339,7 @@ def test_ogc_collections(): "latest_tds_wells", "depth_to_water_trend_wells", "water_well_summary", - "normalized_chemistry_results", + "major_chemistry_results", "minor_chemistry_wells", }.issubset(ids) @@ -283,7 +349,7 @@ def test_ogc_new_collection_items_endpoints(): "latest_tds_wells", "depth_to_water_trend_wells", "water_well_summary", - "normalized_chemistry_results", + "major_chemistry_results", "minor_chemistry_wells", ): response = client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") From f2198a620445c47d2a82e6b636c7e889d2853eaa Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:39:56 +0000 Subject: [PATCH 020/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6d70f587..6f17f410 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -337,12 +337,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 3b5af5aaaeaa756c86abaae297d8a1c1e3f4042e Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 5 Mar 2026 10:54:44 -0700 Subject: [PATCH 021/163] feat: update test configuration to use specific PostGIS version and improve database readiness checks --- .github/workflows/tests.yml | 50 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 79bfcd7e..0cb02ced 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,15 +31,26 @@ jobs: SESSION_SECRET_KEY: supersecretkeyforunittests AUTHENTIK_DISABLE_AUTHENTICATION: 1 + services: + postgis: + image: postgis/postgis:17-3.5 + # don't test against latest. be explicit in version being tested to avoid breaking changes + # image: postgis/postgis:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_PORT: 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Check out source repository uses: actions/checkout@v6.0.2 - - name: Start database (PostGIS) - run: | - docker compose build db - docker compose up -d db - - name: Wait for database readiness run: | for i in {1..60}; do @@ -91,10 +102,6 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} - - name: Stop database - if: always() - run: docker compose down -v - bdd-tests: runs-on: ubuntu-latest @@ -116,15 +123,26 @@ jobs: AUTHENTIK_DISABLE_AUTHENTICATION: 1 DROP_AND_REBUILD_DB: 1 + services: + postgis: + image: postgis/postgis:17-3.5 + # don't test against latest. be explicit in version being tested to avoid breaking changes + # image: postgis/postgis:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_PORT: 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Check out source repository uses: actions/checkout@v6.0.2 - - name: Start database (PostGIS) - run: | - docker compose build db - docker compose up -d db - - name: Wait for database readiness run: | for i in {1..60}; do @@ -169,7 +187,3 @@ jobs: - name: Run BDD tests run: uv run behave tests/features --tags="@backend and @production and not @skip" --no-capture - - - name: Stop database - if: always() - run: docker compose down -v From 1e0b253d7f72de9d86fd0d76a6731489c1a7e035 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Sat, 7 Mar 2026 15:10:30 -0700 Subject: [PATCH 022/163] feat(schemas): add alias validation for well inventory fields - Introduced `validation_alias` with `AliasChoices` for selected fields (`well_status`, `sampler`, `measurement_date_time`, `mp_height`) to allow alternate field names. - Ensured alignment with schema validation updates. --- schemas/well_inventory.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index dd547725..765005cb 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -38,6 +38,8 @@ validate_email, AfterValidator, field_validator, + Field, + AliasChoices, ) from schemas import past_or_today_validator, PastOrTodayDatetime from services.util import convert_dt_tz_naive_to_tz_aware @@ -256,7 +258,10 @@ class WellInventoryRow(BaseModel): measuring_point_description: Optional[str] = None well_purpose: WellPurposeField = None well_purpose_2: WellPurposeField = None - well_status: Optional[str] = None + well_status: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("well_status", "well_hole_status"), + ) monitoring_frequency: MonitoringFrequencyField = None result_communication_preference: Optional[str] = None @@ -266,10 +271,19 @@ class WellInventoryRow(BaseModel): sample_possible: OptionalBool = None # TODO: needs a home # water levels - sampler: Optional[str] = None + sampler: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("sampler", "measuring_person"), + ) sample_method: Optional[str] = None - measurement_date_time: OptionalPastOrTodayDateTime = None - mp_height: Optional[float] = None + measurement_date_time: OptionalPastOrTodayDateTime = Field( + default=None, + validation_alias=AliasChoices("measurement_date_time", "water_level_date_time"), + ) + mp_height: Optional[float] = Field( + default=None, + validation_alias=AliasChoices("mp_height", "mp_height_ft"), + ) level_status: Optional[str] = None depth_to_water_ft: Optional[float] = None data_quality: Optional[str] = None From 3b2db6bb7501e8d26cf340e934438f2e40b3594e Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Sat, 7 Mar 2026 15:10:44 -0700 Subject: [PATCH 023/163] test(well_inventory): add tests for schema alias handling - Introduced unit tests for `WellInventoryRow` alias mappings. - Verified correct handling of alias fields like `well_hole_status`, `mp_height_ft`, and others. - Ensured canonical fields take precedence when both alias and canonical values are provided. --- tests/test_well_inventory.py | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 010d4d6e..d2e1d06b 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -25,10 +25,27 @@ FieldEventParticipant, ) from db.engine import session_ctx +from schemas.well_inventory import WellInventoryRow from services.util import transform_srid, convert_ft_to_m from shapely import Point +def _minimal_valid_well_inventory_row(): + return { + "project": "Test Project", + "well_name_point_id": "TEST-0001", + "site_name": "Test Site", + "date_time": "2025-02-15T10:30:00", + "field_staff": "Test Staff", + "utm_easting": 357000, + "utm_northing": 3784000, + "utm_zone": "13N", + "elevation_ft": 5000, + "elevation_method": "Global positioning system (GPS)", + "measuring_point_height_ft": 3.5, + } + + def test_well_inventory_db_contents(): """ Test that the well inventory upload creates the correct database contents. @@ -907,6 +924,50 @@ def test_group_query_with_multiple_conditions(self): session.commit() +class TestWellInventoryRowAliases: + """Schema alias handling for well inventory CSV field names.""" + + def test_well_status_accepts_well_hole_status_alias(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "Abandoned" + + model = WellInventoryRow(**row) + + assert model.well_status == "Abandoned" + + def test_water_level_aliases_are_mapped(self): + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_person": "Tech 1", + "sample_method": "Tape", + "water_level_date_time": "2025-02-15T10:30:00", + "mp_height_ft": 2.5, + "level_status": "Static", + "depth_to_water_ft": 11.2, + "data_quality": "Good", + "water_level_notes": "Initial reading", + } + ) + + model = WellInventoryRow(**row) + + assert model.sampler == "Tech 1" + assert model.measurement_date_time == datetime.fromisoformat( + "2025-02-15T10:30:00" + ) + assert model.mp_height == 2.5 + + def test_canonical_name_wins_when_alias_and_canonical_present(self): + row = _minimal_valid_well_inventory_row() + row["well_status"] = "Abandoned" + row["well_hole_status"] = "Inactive, exists but not used" + + model = WellInventoryRow(**row) + + assert model.well_status == "Abandoned" + + class TestWellInventoryAPIEdgeCases: """Additional edge case tests for API endpoints.""" From 6c38157df265d7cfad3a9073d404cba2906170aa Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 11:53:20 -0600 Subject: [PATCH 024/163] feat(schemas): enhance well inventory schema with flexible validation and new fields - Added `flexible_lexicon_validator` to support case-insensitive validation of enum-like fields. - Introduced new fields: `OriginType`, `WellPumpType`, `MonitoringStatus`, among others. - Updated existing fields to use flexible lexicon validation for improved consistency. - Adjusted `WellInventoryRow` optional fields handling and validation rules. - Refined contact field validation logic to require `role` and `type` when other contact details are provided. --- schemas/well_inventory.py | 147 ++++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 60 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 765005cb..49089ce1 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -29,6 +29,9 @@ AddressType, WellPurpose as WellPurposeEnum, MonitoringFrequency, + OriginType, + WellPumpType, + MonitoringStatus, ) from phonenumbers import NumberParseException from pydantic import ( @@ -124,28 +127,64 @@ def email_validator_function(email_str): raise ValueError(f"Invalid email format. {email_str}") from e +def flexible_lexicon_validator(enum_cls): + def validator(v): + if v is None or v == "": + return None + if isinstance(v, enum_cls): + return v + + v_str = str(v).strip().lower() + for item in enum_cls: + if item.value.lower() == v_str: + return item + return v + + return validator + + # Reusable type PhoneTypeField: TypeAlias = Annotated[ - Optional[PhoneType], BeforeValidator(blank_to_none) + Optional[PhoneType], BeforeValidator(flexible_lexicon_validator(PhoneType)) ] ContactTypeField: TypeAlias = Annotated[ - Optional[ContactType], BeforeValidator(blank_to_none) + Optional[ContactType], BeforeValidator(flexible_lexicon_validator(ContactType)) ] EmailTypeField: TypeAlias = Annotated[ - Optional[EmailType], BeforeValidator(blank_to_none) + Optional[EmailType], BeforeValidator(flexible_lexicon_validator(EmailType)) ] AddressTypeField: TypeAlias = Annotated[ - Optional[AddressType], BeforeValidator(blank_to_none) + Optional[AddressType], BeforeValidator(flexible_lexicon_validator(AddressType)) +] +ContactRoleField: TypeAlias = Annotated[ + Optional[Role], BeforeValidator(flexible_lexicon_validator(Role)) ] -ContactRoleField: TypeAlias = Annotated[Optional[Role], BeforeValidator(blank_to_none)] OptionalFloat: TypeAlias = Annotated[ Optional[float], BeforeValidator(empty_str_to_none) ] MonitoringFrequencyField: TypeAlias = Annotated[ - Optional[MonitoringFrequency], BeforeValidator(blank_to_none) + Optional[MonitoringFrequency], + BeforeValidator(flexible_lexicon_validator(MonitoringFrequency)), ] WellPurposeField: TypeAlias = Annotated[ - Optional[WellPurposeEnum], BeforeValidator(blank_to_none) + Optional[WellPurposeEnum], + BeforeValidator(flexible_lexicon_validator(WellPurposeEnum)), +] +OriginTypeField: TypeAlias = Annotated[ + Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) +] +WellPumpTypeField: TypeAlias = Annotated[ + Optional[WellPumpType], BeforeValidator(flexible_lexicon_validator(WellPumpType)) +] +MonitoringStatusField: TypeAlias = Annotated[ + Optional[MonitoringStatus], + BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), +] +SampleMethodField: TypeAlias = Annotated[ + Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) +] +DataQualityField: TypeAlias = Annotated[ + Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) ] PostalCodeField: TypeAlias = Annotated[ Optional[str], BeforeValidator(postal_code_or_none) @@ -172,18 +211,21 @@ def email_validator_function(email_str): class WellInventoryRow(BaseModel): # Required fields project: str - well_name_point_id: str - site_name: str + well_name_point_id: Optional[str] = None date_time: PastOrTodayDatetime field_staff: str utm_easting: float utm_northing: float utm_zone: str - elevation_ft: float - elevation_method: ElevationMethod - measuring_point_height_ft: float # Optional fields + site_name: Optional[str] = None + elevation_ft: OptionalFloat = None + elevation_method: Annotated[ + Optional[ElevationMethod], + BeforeValidator(flexible_lexicon_validator(ElevationMethod)), + ] = None + measuring_point_height_ft: OptionalFloat = None field_staff_2: Optional[str] = None field_staff_3: Optional[str] = None @@ -242,15 +284,15 @@ class WellInventoryRow(BaseModel): repeat_measurement_permission: OptionalBool = None sampling_permission: OptionalBool = None datalogger_installation_permission: OptionalBool = None - public_availability_acknowledgement: OptionalBool = None # TODO: needs a home + public_availability_acknowledgement: OptionalBool = None special_requests: Optional[str] = None ose_well_record_id: Optional[str] = None date_drilled: OptionalPastOrTodayDate = None completion_source: Optional[str] = None total_well_depth_ft: OptionalFloat = None historic_depth_to_water_ft: OptionalFloat = None - depth_source: Optional[str] = None - well_pump_type: Optional[str] = None + depth_source: OriginTypeField = None + well_pump_type: WellPumpTypeField = None well_pump_depth_ft: OptionalFloat = None is_open: OptionalBool = None datalogger_possible: OptionalBool = None @@ -263,31 +305,34 @@ class WellInventoryRow(BaseModel): validation_alias=AliasChoices("well_status", "well_hole_status"), ) monitoring_frequency: MonitoringFrequencyField = None + monitoring_status: MonitoringStatusField = None result_communication_preference: Optional[str] = None contact_special_requests_notes: Optional[str] = None sampling_scenario_notes: Optional[str] = None + well_notes: Optional[str] = None + water_notes: Optional[str] = None well_measuring_notes: Optional[str] = None - sample_possible: OptionalBool = None # TODO: needs a home + sample_possible: OptionalBool = None # water levels sampler: Optional[str] = Field( default=None, validation_alias=AliasChoices("sampler", "measuring_person"), ) - sample_method: Optional[str] = None + sample_method: SampleMethodField = None measurement_date_time: OptionalPastOrTodayDateTime = Field( default=None, validation_alias=AliasChoices("measurement_date_time", "water_level_date_time"), ) - mp_height: Optional[float] = Field( + mp_height: OptionalFloat = Field( default=None, validation_alias=AliasChoices("mp_height", "mp_height_ft"), ) level_status: Optional[str] = None depth_to_water_ft: Optional[float] = None - data_quality: Optional[str] = None - water_level_notes: Optional[str] = None # TODO: needs a home + data_quality: DataQualityField = None + water_level_notes: Optional[str] = None @field_validator("date_time", mode="before") def make_date_time_tz_aware(cls, v): @@ -306,23 +351,6 @@ def make_date_time_tz_aware(cls, v): @model_validator(mode="after") def validate_model(self): - - optional_wl = ( - "sampler", - "sample_method", - "measurement_date_time", - "mp_height", - "level_status", - "depth_to_water_ft", - "data_quality", - "water_level_notes", - ) - - wl_fields = [getattr(self, a) for a in optional_wl] - if any(wl_fields): - if not all(wl_fields): - raise ValueError("All water level fields must be provided") - # verify utm in NM utm_zone_value = (self.utm_zone or "").upper() if utm_zone_value not in ("12N", "13N"): @@ -339,6 +367,12 @@ def validate_model(self): f" Zone={self.utm_zone}" ) + if self.depth_to_water_ft is not None: + if self.measurement_date_time is None: + raise ValueError( + "water_level_date_time is required when depth_to_water_ft is provided" + ) + required_attrs = ("line_1", "type", "state", "city", "postal_code") all_attrs = ("line_1", "line_2", "type", "state", "city", "postal_code") for jdx in (1, 2): @@ -346,31 +380,35 @@ def validate_model(self): # Check if any contact data is provided name = getattr(self, f"{key}_name") organization = getattr(self, f"{key}_organization") - has_contact_data = any( + + # Check for OTHER contact fields (excluding name and organization) + has_other_contact_data = any( [ - name, - organization, getattr(self, f"{key}_role"), getattr(self, f"{key}_type"), - *[getattr(self, f"{key}_email_{i}", None) for i in (1, 2)], - *[getattr(self, f"{key}_phone_{i}", None) for i in (1, 2)], + *[getattr(self, f"{key}_email_{i}") for i in (1, 2)], + *[getattr(self, f"{key}_phone_{i}") for i in (1, 2)], *[ - getattr(self, f"{key}_address_{i}_{a}", None) + getattr(self, f"{key}_address_{i}_{a}") for i in (1, 2) for a in all_attrs ], ] ) - # If any contact data is provided, both name and organization are required - if has_contact_data: - if not name: + # If any contact data is provided, at least one of name or organization is required + if has_other_contact_data: + if not name and not organization: + raise ValueError( + f"At least one of {key}_name or {key}_organization must be provided" + ) + if not getattr(self, f"{key}_role"): raise ValueError( - f"{key}_name is required when other contact fields are provided" + f"{key}_role is required when contact fields are provided" ) - if not organization: + if not getattr(self, f"{key}_type"): raise ValueError( - f"{key}_organization is required when other contact fields are provided" + f"{key}_type is required when contact fields are provided" ) for idx in (1, 2): if any(getattr(self, f"{key}_address_{idx}_{a}") for a in all_attrs): @@ -380,17 +418,6 @@ def validate_model(self): ): raise ValueError("All contact address fields must be provided") - name = getattr(self, f"{key}_name") - if name: - if not getattr(self, f"{key}_role"): - raise ValueError( - f"{key}_role must be provided if name is provided" - ) - if not getattr(self, f"{key}_type"): - raise ValueError( - f"{key}_type must be provided if name is provided" - ) - phone = getattr(self, f"{key}_phone_{idx}") tag = f"{key}_phone_{idx}_type" phone_type = getattr(self, f"{key}_phone_{idx}_type") From 1d3aa13704f0a903838a29fb136bc3eb594a0c41 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 11:59:02 -0600 Subject: [PATCH 025/163] test(features): improve error messages and enhance contact field validations - Refined validation error handling to provide more detailed feedback in test assertions. - Adjusted test setup to ensure accurate validation scenarios for contact and water level fields. - Updated contact-related tests to validate new composite field error messages. --- .../steps/well-inventory-csv-given.py | 6 +++ .../well-inventory-csv-validation-error.py | 40 ++++++++++++------- tests/features/steps/well-inventory-csv.py | 6 ++- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index 1d753cb9..6011ff0d 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -414,11 +414,17 @@ def step_given_row_contains_invalid_well_pump_type_value(context: Context): ) def step_given_row_contains_contact_fields_but_name_and_org_are_blank(context: Context): df = _get_valid_df(context) + # df has 2 rows from well-inventory-valid.csv. + # We want to make SURE both rows are processed and the error is caught for row 1 (index 0). + # ensure rows are valid so row 0's error is the only one + df.loc[:, "contact_1_name"] = "Contact Name" + df.loc[:, "contact_1_organization"] = "Contact Org" df.loc[0, "contact_1_name"] = "" df.loc[0, "contact_1_organization"] = "" # Keep other contact data present so composite contact validation is exercised. df.loc[0, "contact_1_role"] = "Owner" df.loc[0, "contact_1_type"] = "Primary" + _set_content_from_df(context, df) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 928c95e7..8662e303 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -21,14 +21,18 @@ def _handle_validation_error(context, expected_errors): response_json = context.response.json() validation_errors = response_json.get("validation_errors", []) - assert len(validation_errors) == len( - expected_errors - ), f"Expected {len(expected_errors)} validation errors, got {len(validation_errors)}" - for v, e in zip(validation_errors, expected_errors): - assert v["field"] == e["field"], f"Expected {e['field']} for {v['field']}" - assert v["error"] == e["error"], f"Expected {e['error']} for {v['error']}" - if "value" in e: - assert v["value"] == e["value"], f"Expected {e['value']} for {v['value']}" + + for expected in expected_errors: + found = False + for actual in validation_errors: + field_match = str(expected.get("field", "")) in str(actual.get("field", "")) + error_match = str(expected.get("error", "")) in str(actual.get("error", "")) + if field_match and error_match: + found = True + break + assert ( + found + ), f"Expected validation error for field '{expected.get('field')}' with error containing '{expected.get('error')}' not found. Got: {validation_errors}" def _assert_any_validation_error_contains( @@ -127,7 +131,7 @@ def step_step_step_5(context): expected_errors = [ { "field": "composite field error", - "error": "Value error, contact_1_role must be provided if name is provided", + "error": "Value error, contact_1_role is required when contact fields are provided", } ] _handle_validation_error(context, expected_errors) @@ -179,7 +183,7 @@ def step_step_step_8(context): expected_errors = [ { "field": "composite field error", - "error": "Value error, contact_1_type must be provided if name is provided", + "error": "Value error, contact_1_type is required when contact fields are provided", } ] _handle_validation_error(context, expected_errors) @@ -280,18 +284,22 @@ def step_then_response_includes_invalid_well_pump_type_error(context: Context): def step_then_response_includes_contact_name_or_org_required_error(context: Context): response_json = context.response.json() validation_errors = response_json.get("validation_errors", []) - assert validation_errors, "Expected at least one validation error" + assert validation_errors, f"Expected validation errors, got: {response_json}" found = any( "composite field error" in str(err.get("field", "")) and ( - "contact_1_name is required" in str(err.get("error", "")) - or "contact_1_organization is required" in str(err.get("error", "")) + "At least one of contact_1_name or contact_1_organization must be provided" + in str(err.get("error", "")) ) for err in validation_errors ) + if not found: + pass + # print(f"ACTUAL VALIDATION ERRORS: {validation_errors}") + assert ( found - ), "Expected contact validation error requiring contact_1_name or contact_1_organization" + ), f"Expected contact validation error requiring contact_1_name or contact_1_organization. Got: {validation_errors}" @then( @@ -299,7 +307,9 @@ def step_then_response_includes_contact_name_or_org_required_error(context: Cont ) def step_then_response_includes_water_level_datetime_required_error(context: Context): _assert_any_validation_error_contains( - context, "composite field error", "All water level fields must be provided" + context, + "composite field error", + "water_level_date_time is required when depth_to_water_ft is provided", ) diff --git a/tests/features/steps/well-inventory-csv.py b/tests/features/steps/well-inventory-csv.py index 8b23b0be..da870cec 100644 --- a/tests/features/steps/well-inventory-csv.py +++ b/tests/features/steps/well-inventory-csv.py @@ -248,7 +248,11 @@ def step_then_the_response_identifies_the_row_and_field_for_each_error( def step_then_no_wells_are_imported(context: Context): response_json = context.response.json() wells = response_json.get("wells", []) - assert len(wells) == 0, "Expected no wells to be imported" + if len(wells) > 0: + print(f"ACTUAL IMPORTED WELLS: {wells}") + assert ( + len(wells) == 0 + ), f"Expected no wells to be imported, but got {len(wells)}: {wells}" @then("the response includes validation errors indicating duplicated values") From 4d74d1bec091eb6b5d0f49ba1d5cc41129018d3f Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 12:00:05 -0600 Subject: [PATCH 026/163] feat(core): expand lexicon with new terms for water-related categories - Renamed "Water" to "Water Bearing Zone" and refined its definition. - Added new term "Water Quality" under `note_type` category. --- core/lexicon.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/lexicon.json b/core/lexicon.json index 32757116..ffd13d09 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -8182,9 +8182,16 @@ "categories": [ "note_type" ], - "term": "Water", + "term": "Water Bearing Zone", "definition": "Water bearing zone information and other info from ose reports" }, + { + "categories": [ + "note_type" + ], + "term": "Water Quality", + "definition": "Water quality information" + }, { "categories": [ "note_type" From a7e0632b2a7daeaae8360baf7d0ba0eb47c7c9d9 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 12:00:56 -0600 Subject: [PATCH 027/163] feat(schemas): add `monitoring_status` field to `thing` schema --- schemas/thing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/schemas/thing.py b/schemas/thing.py index ad109bf0..cd3483fd 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -143,6 +143,7 @@ class CreateWell(CreateBaseThing, ValidateWell): is_suitable_for_datalogger: bool | None = None is_open: bool | None = None well_status: str | None = None + monitoring_status: str | None = None formation_completion_code: FormationCode | None = None nma_formation_zone: str | None = None From 42bae2d795fda5514475fc5dbec449182d64e1e8 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 12:03:20 -0600 Subject: [PATCH 028/163] feat(thing_helper): add handling for `monitoring_status` in status history updates --- services/thing_helper.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index cc2fbf6e..cfbea0b6 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -221,6 +221,7 @@ def add_thing( datalogger_suitability_status = data.pop("is_suitable_for_datalogger", None) open_status = data.pop("is_open", None) well_status = data.pop("well_status", None) + monitoring_status = data.pop("monitoring_status", None) # ---------- # END UNIVERSAL THING RELATED TABLES @@ -361,6 +362,18 @@ def add_thing( audit_add(user, ws_status) session.add(ws_status) + if monitoring_status is not None: + ms_status = StatusHistory( + target_id=thing.id, + target_table="thing", + status_value=monitoring_status, + status_type="Monitoring Status", + start_date=effective_start, + end_date=None, + ) + audit_add(user, ms_status) + session.add(ms_status) + # ---------- # END WATER WELL SPECIFIC LOGIC # ---------- @@ -425,7 +438,8 @@ def add_thing( session.refresh(note) except Exception as e: - session.rollback() + if commit: + session.rollback() raise e return thing From cc40afbc59b604e6404a9a3b27b28979a6616d73 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 9 Mar 2026 12:52:33 -0600 Subject: [PATCH 029/163] feat: add water elevation materialized view and update configuration --- ...1_add_water_elevation_materialized_view.py | 116 ++++++++++++++++++ cli/cli.py | 1 + core/pygeoapi-config.yml | 23 ++++ tests/test_cli_commands.py | 9 +- tests/test_ogc.py | 22 ++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py diff --git a/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py b/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py new file mode 100644 index 00000000..4e0b1d15 --- /dev/null +++ b/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py @@ -0,0 +1,116 @@ +"""add water elevation materialized view + +Revision ID: m6f7a8b9c0d1 +Revises: c7f8a9b0d1e2 +Create Date: 2026-03-09 10:45:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "m6f7a8b9c0d1" +down_revision: Union[str, Sequence[str], None] = "c7f8a9b0d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well (elevation minus depth to water below ground surface).'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) diff --git a/cli/cli.py b/cli/cli.py index e9f8dc36..a7777fd1 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -52,6 +52,7 @@ class SmokePopulation(str, Enum): PYGEOAPI_MATERIALIZED_VIEWS = ( "ogc_latest_depth_to_water_wells", + "ogc_water_elevation_wells", "ogc_avg_tds_wells", "ogc_depth_to_water_trend_wells", "ogc_water_well_summary", diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 0a205d29..981a40cf 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -149,6 +149,29 @@ resources: table: ogc_depth_to_water_trend_wells geom_field: point + water_elevation_wells: + type: collection + title: Water Elevation (Water Wells) + description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface. + keywords: [water-wells, groundwater-level, water-elevation, depth-to-water] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_water_elevation_wells + geom_field: point + water_well_summary: type: collection title: Water Well Summary diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6f17f410..e8d1e9e0 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -55,6 +55,7 @@ def __exit__(self, exc_type, exc, tb): assert result.exit_code == 0, result.output assert executed_sql == [ "REFRESH MATERIALIZED VIEW ogc_latest_depth_to_water_wells", + "REFRESH MATERIALIZED VIEW ogc_water_elevation_wells", "REFRESH MATERIALIZED VIEW ogc_avg_tds_wells", "REFRESH MATERIALIZED VIEW ogc_depth_to_water_trend_wells", "REFRESH MATERIALIZED VIEW ogc_water_well_summary", @@ -62,7 +63,7 @@ def __exit__(self, exc_type, exc, tb): "REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells", ] assert commit_called["value"] is True - assert "Refreshed 6 materialized view(s)." in result.output + assert "Refreshed 7 materialized view(s)." in result.output def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): @@ -337,10 +338,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 7912cab9..6f35d21a 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -327,6 +327,26 @@ def test_ogc_minor_chemistry_wells_uses_latest_per_analyte(water_well_thing): session.commit() +def test_ogc_water_elevation_wells_computes_elevation_minus_depth_to_water( + water_well_thing, groundwater_level_observation +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() + + row = session.execute( + text( + "SELECT elevation, depth_to_water_below_ground_surface, water_elevation " + "FROM ogc_water_elevation_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.depth_to_water_below_ground_surface) == 5.0 + assert float(row.elevation) == 2464.9 + assert abs(float(row.water_elevation) - 2459.9) < 1e-9 + + def test_ogc_collections(): response = client.get("/ogcapi/collections") assert response.status_code == 200 @@ -338,6 +358,7 @@ def test_ogc_collections(): "springs", "latest_tds_wells", "depth_to_water_trend_wells", + "water_elevation_wells", "water_well_summary", "major_chemistry_results", "minor_chemistry_wells", @@ -348,6 +369,7 @@ def test_ogc_new_collection_items_endpoints(): for collection_id in ( "latest_tds_wells", "depth_to_water_trend_wells", + "water_elevation_wells", "water_well_summary", "major_chemistry_results", "minor_chemistry_wells", From a1fa814c9b6f5835193c1508c3e9b719e509b0f6 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:06:34 +0000 Subject: [PATCH 030/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index e8d1e9e0..499be641 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -338,12 +338,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 36e4fad035d16b4a244e19212071c1eac0aa5450 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 9 Mar 2026 15:02:27 -0600 Subject: [PATCH 031/163] feat: update SQL queries in ngwmn_helper.py for consistent quoting and improved readability --- services/ngwmn_helper.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index 84a8026d..f6ef69cd 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -17,6 +17,7 @@ from sqlalchemy import text + # NSMAP = dict(xsi="http://www.w3.org/2001/XMLSchema-instance", xsd="http://www.w3.org/2001/XMLSchema") @@ -26,27 +27,29 @@ def make_xml_response(db, sql, point_id, func): rs = [] for si in sql: - records = db.execute(text(si), {"point_id": point_id}) rs.append(records.fetchall()) return func(*rs) def make_lithology_response(point_id, db): - sql = "select * from NMA_view_NGWMN_Lithology where PointID=:point_id" + sql = 'select * from "NMA_view_NGWMN_Lithology" where "PointID"=:point_id' return make_xml_response(db, sql, point_id, lithology_xml) def make_well_construction_response(point_id, db): - sql = "select * from NMA_view_NGWMN_WellConstruction where PointID=:point_id" + sql = 'select * from "NMA_view_NGWMN_WellConstruction" where "PointID"=:point_id' return make_xml_response(db, sql, point_id, well_construction_xml) def make_waterlevels_response(point_id, db): - sql = "select * from dbo.view_NGWMN_WaterLevels where PointID=:point_id order by DateMeasured" + sql = ( + 'select * from "NMA_view_NGWMN_WaterLevels" where "PointID"=:point_id ' + 'order by "DateMeasured"' + ) sql2 = ( - "select * from NMA_WaterLevelsContinuous_Pressure_Daily where PointID=:point_id and QCed=1 order by " - "DateMeasured" + 'select * from "NMA_WaterLevelsContinuous_Pressure_Daily" where "PointID"=:point_id and "QCed" is true ' + 'order by "DateMeasured"' ) return make_xml_response(db, (sql, sql2), point_id, water_levels_xml2) From 8629b4ed8500961c761bfa41d1d19feffa6b29d9 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:02:55 +0000 Subject: [PATCH 032/163] Formatting changes --- services/ngwmn_helper.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index f6ef69cd..f2caa92e 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -17,7 +17,6 @@ from sqlalchemy import text - # NSMAP = dict(xsi="http://www.w3.org/2001/XMLSchema-instance", xsd="http://www.w3.org/2001/XMLSchema") From 2384f1196dcd723d616ba72a6e57ea322129c33d Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 9 Mar 2026 16:06:20 -0600 Subject: [PATCH 033/163] feat: enhance SQL queries and add null handling for water data responses --- services/ngwmn_helper.py | 49 +++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index f2caa92e..73df1158 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -17,6 +17,11 @@ from sqlalchemy import text + +def _as_text(v): + return "" if v is None else str(v) + + # NSMAP = dict(xsi="http://www.w3.org/2001/XMLSchema-instance", xsd="http://www.w3.org/2001/XMLSchema") @@ -32,12 +37,19 @@ def make_xml_response(db, sql, point_id, func): def make_lithology_response(point_id, db): - sql = 'select * from "NMA_view_NGWMN_Lithology" where "PointID"=:point_id' + sql = ( + 'select "PointID", "StratTop", "StratBottom", "TERM" ' + 'from "NMA_view_NGWMN_Lithology" where "PointID"=:point_id' + ) return make_xml_response(db, sql, point_id, lithology_xml) def make_well_construction_response(point_id, db): - sql = 'select * from "NMA_view_NGWMN_WellConstruction" where "PointID"=:point_id' + sql = ( + 'select "PointID", "CasingTop", "CasingBottom", "CasingDepthUnits", ' + '"ScreenTop", "ScreenBottom", "ScreenBottomUnit", "ScreenDescription", "CasingDescription" ' + 'from "NMA_view_NGWMN_WellConstruction" where "PointID"=:point_id' + ) return make_xml_response(db, sql, point_id, well_construction_xml) @@ -184,7 +196,7 @@ def make_continuous_water_level(root, r): ("WaterLevelAccuracy", "0.02 ft"), ): e = etree.SubElement(elem, attr) - e.text = str(val) + e.text = _as_text(val) def make_water_level(root, r): @@ -206,37 +218,37 @@ def make_water_level(root, r): ("WaterLevelAccuracy", r[5]), ): e = etree.SubElement(elem, attr) - e.text = str(val) + e.text = _as_text(val) def make_well_construction(root, r): """ - 0 1 2 3 4 5 6, 7, 8 - pointid, castop, casbottom, cadepthunits, screentop, screenbotom, units,screen description, casing description + 0 1 2 3 4 5 6 7 8 + pointid, castop, casbottom, cadepthunits, screentop, screenbottom, screenbottomunit, screen description, casing description :param root: :param r: :return: """ elem = etree.SubElement(root, "Casing") - make_point_id(elem, r) + make_point_id(elem, r, idx=0) e = etree.SubElement(elem, "CasingTop") - e.text = str(r[1]) + e.text = _as_text(r[1]) e = etree.SubElement(elem, "CasingBottom") - e.text = str(r[2]) + e.text = _as_text(r[2]) e = etree.SubElement(elem, "CasingDepthUnits") - e.text = str(r[3]) + e.text = _as_text(r[3]) e = etree.SubElement(elem, "ScreenTop") - e.text = str(r[4]) + e.text = _as_text(r[4]) e = etree.SubElement(elem, "ScreenBottom") - e.text = str(r[5]) + e.text = _as_text(r[5]) e = etree.SubElement(elem, "ScreenDescription") - e.text = str(r[7]) + e.text = _as_text(r[7]) e = etree.SubElement(elem, "ScreenMaterial") e.text = "steel" @@ -244,21 +256,22 @@ def make_well_construction(root, r): def make_lithology(root, r): elem = etree.SubElement(root, "Lithology") - make_point_id(elem, r) + make_point_id(elem, r, idx=0) e = etree.SubElement(elem, "TopDepth") - e.text = str(r[1]) + e.text = _as_text(r[1]) e = etree.SubElement(elem, "BottomDepth") - e.text = str(r[2]) + e.text = _as_text(r[2]) e = etree.SubElement(elem, "Units") e.text = "feet" e = etree.SubElement(elem, "Description") - e.text = str(r[3]) + e.text = _as_text(r[3]) def make_point_id(elem, r, idx=0): e = etree.SubElement(elem, "PointID") - e.text = r[idx] + v = r[idx] + e.text = _as_text(v) From e3e4fde51a74d61e81c36fd5caa173106a4a2c22 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 00:05:30 -0600 Subject: [PATCH 034/163] feat: fix water elevation units to feet in materialized view and update related tests --- ...0d1e2_fix_water_elevation_units_to_feet.py | 122 ++++++++++++++++++ tests/test_ogc.py | 3 +- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py diff --git a/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py new file mode 100644 index 00000000..2a1b94b7 --- /dev/null +++ b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py @@ -0,0 +1,122 @@ +"""fix water elevation units to feet + +Revision ID: n7a8b9c0d1e2 +Revises: m6f7a8b9c0d1 +Create Date: 2026-03-10 11:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "n7a8b9c0d1e2" +down_revision: Union[str, Sequence[str], None] = "m6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +METERS_TO_FEET = 3.28084 + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation, + lo.depth_to_water_below_ground_surface, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well in feet; computed as (elevation_m * 3.28084) - depth_to_water_below_ground_surface_ft.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 6f35d21a..1cf0059c 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -344,7 +344,8 @@ def test_ogc_water_elevation_wells_computes_elevation_minus_depth_to_water( assert float(row.depth_to_water_below_ground_surface) == 5.0 assert float(row.elevation) == 2464.9 - assert abs(float(row.water_elevation) - 2459.9) < 1e-9 + expected_water_elevation_ft = (2464.9 * 3.28084) - 5.0 + assert abs(float(row.water_elevation) - expected_water_elevation_ft) < 1e-9 def test_ogc_collections(): From 75f727d5b15b54fb3b3f61a1788fe751ea645c64 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 00:14:38 -0600 Subject: [PATCH 035/163] feat: enhance water elevation calculations to support both meters and feet --- ...0d1e2_fix_water_elevation_units_to_feet.py | 89 +++++++++++++++++-- tests/test_ogc.py | 52 ++++++++++- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py index 2a1b94b7..7a2b09bc 100644 --- a/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py +++ b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py @@ -40,8 +40,14 @@ def _create_water_elevation_view() -> str: fe.thing_id, o.id AS observation_id, o.observation_datetime, - (o.value - COALESCE(o.measuring_point_height, 0)) - AS depth_to_water_below_ground_surface + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface FROM observation AS o JOIN sample AS s ON s.id = o.sample_id JOIN field_activity AS fa ON fa.id = s.field_activity_id @@ -52,6 +58,16 @@ def _create_water_elevation_view() -> str: AND fa.activity_type = 'groundwater level' AND o.value IS NOT NULL AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ) ), latest_obs AS ( SELECT @@ -68,10 +84,10 @@ def _create_water_elevation_view() -> str: t.thing_type, lo.observation_id, lo.observation_datetime, - l.elevation, - lo.depth_to_water_below_ground_surface, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) - AS water_elevation, + AS water_elevation_ft, l.point FROM latest_obs AS lo JOIN thing AS t ON t.id = lo.thing_id @@ -81,6 +97,54 @@ def _create_water_elevation_view() -> str: """ +def _create_water_elevation_view_m6() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + def upgrade() -> None: bind = op.get_bind() inspector = inspect(bind) @@ -107,7 +171,7 @@ def upgrade() -> None: op.execute( text( "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " - "'Latest water elevation per well in feet; computed as (elevation_m * 3.28084) - depth_to_water_below_ground_surface_ft.'" + "'Latest water elevation per well with explicit units: elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.'" ) ) op.execute( @@ -120,3 +184,16 @@ def upgrade() -> None: def downgrade() -> None: op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view_m6())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well (elevation minus depth to water below ground surface).'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 1cf0059c..f318339d 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -336,16 +336,60 @@ def test_ogc_water_elevation_wells_computes_elevation_minus_depth_to_water( row = session.execute( text( - "SELECT elevation, depth_to_water_below_ground_surface, water_elevation " + "SELECT elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft " "FROM ogc_water_elevation_wells WHERE id = :thing_id" ), {"thing_id": water_well_thing.id}, ).one() - assert float(row.depth_to_water_below_ground_surface) == 5.0 - assert float(row.elevation) == 2464.9 + assert float(row.depth_to_water_below_ground_surface_ft) == 5.0 + assert float(row.elevation_m) == 2464.9 expected_water_elevation_ft = (2464.9 * 3.28084) - 5.0 - assert abs(float(row.water_elevation) - expected_water_elevation_ft) < 1e-9 + assert abs(float(row.water_elevation_ft) - expected_water_elevation_ft) < 1e-9 + + +def test_ogc_water_elevation_wells_normalizes_meter_observations_to_feet( + water_well_thing, groundwater_level_observation +): + with session_ctx() as session: + meter_observation = groundwater_level_observation.__class__( + observation_datetime=datetime(2025, 1, 2, 0, 4, 0), + sample_id=groundwater_level_observation.sample_id, + sensor_id=groundwater_level_observation.sensor_id, + parameter_id=groundwater_level_observation.parameter_id, + release_status="draft", + value=3.0, + unit="m", + measuring_point_height=2.0, + groundwater_level_reason="Water level not affected", + ) + session.add(meter_observation) + session.commit() + + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() + + row = session.execute( + text( + "SELECT depth_to_water_below_ground_surface_ft, water_elevation_ft " + "FROM ogc_water_elevation_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + expected_depth_ft = (3.0 * 3.28084) - 2.0 + expected_water_elevation_ft = (2464.9 * 3.28084) - expected_depth_ft + + assert ( + abs(float(row.depth_to_water_below_ground_surface_ft) - expected_depth_ft) + < 1e-9 + ) + assert abs(float(row.water_elevation_ft) - expected_water_elevation_ft) < 1e-9 + + session.delete(meter_observation) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() def test_ogc_collections(): From e64d6c8d379a723afa534606c4a9280139024412 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 00:33:45 -0600 Subject: [PATCH 036/163] feat: add refresh step for materialized views in production and staging workflows --- .github/workflows/CD_production.yml | 10 ++++++++++ .github/workflows/CD_staging.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 40fbd0e4..eb104d33 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -47,6 +47,16 @@ jobs: run: | uv run alembic upgrade head + - name: Refresh materialized views on production database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run python -m cli.cli refresh-pygeoapi-materialized-views + - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 0596a5f6..ec5cad81 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -47,6 +47,16 @@ jobs: run: | uv run alembic upgrade head + - name: Refresh materialized views on staging database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run python -m cli.cli refresh-pygeoapi-materialized-views + - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then From 7e14b069e12c01e194e29412159b6eff81c59893 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 09:17:43 -0600 Subject: [PATCH 037/163] Assess branch readiness for staging --- .github/app.template.yaml | 9 +- .github/workflows/CD_production.yml | 61 +++- .github/workflows/CD_staging.yml | 57 +++- AGENTS.MD | 7 + README.md | 13 +- alembic/env.py | 2 +- ...build_water_elevation_materialized_view.py | 199 +++++++++++ api/asset.py | 25 +- cli/cli.py | 4 +- core/app.py | 323 +++++++++++------- core/factory.py | 55 +++ core/initializers.py | 51 ++- core/permissions.py | 18 +- db/engine.py | 2 +- docker-compose.yml | 6 + entrypoint.sh | 12 +- main.py | 40 +-- schedule | 22 +- services/asset_helper.py | 8 +- services/env.py | 16 + services/gcs_helper.py | 14 +- services/util.py | 18 +- tests/conftest.py | 74 +++- tests/features/environment.py | 2 +- tests/integration/test_alembic_migrations.py | 29 ++ tests/test_lazy_admin.py | 19 ++ tests/test_request_timing.py | 43 +++ tests/test_thing.py | 10 +- transfers/backfill/backfill.py | 2 +- transfers/transfer.py | 2 +- 30 files changed, 857 insertions(+), 286 deletions(-) create mode 100644 alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py create mode 100644 core/factory.py create mode 100644 services/env.py create mode 100644 tests/test_lazy_admin.py create mode 100644 tests/test_request_timing.py diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 44df2f86..2ed7342a 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -1,8 +1,13 @@ service: ${SERVICE_NAME} runtime: python313 -entrypoint: gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app -instance_class: F4 +entrypoint: ${ENTRYPOINT} service_account: "${CLOUD_SQL_USER}.gserviceaccount.com" +instance_class: F4 +inbound_services: + - warmup +automatic_scaling: + min_instances: ${MIN_INSTANCES} + max_instances: ${MAX_INSTANCES} handlers: - url: /.* secure: always diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index eb104d33..cf7924ef 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -8,7 +8,7 @@ permissions: contents: write jobs: - staging-deploy: + production-deploy: runs-on: ubuntu-latest environment: production @@ -64,9 +64,8 @@ jobs: sudo apt-get install -y gettext-base fi - - name: Render app.yaml + - name: Render App Engine configs env: - SERVICE_NAME: "ocotillo-api" ENVIRONMENT: "production" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" @@ -87,25 +86,59 @@ jobs: SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" run: | + export MAX_INSTANCES="10" + export SERVICE_NAME="ocotillo-api" + export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml - name: Deploy to Google Cloud run: | - gcloud app deploy app.yaml --quiet --project ${{ vars.GCP_PROJECT_ID }} + gcloud app deploy \ + app.yaml \ + --quiet \ + --project ${{ vars.GCP_PROJECT_ID }} - # Clean up old versions - delete only the oldest version, one created and one destroyed - - name: Clean up oldest version + - name: Clean up oldest versions run: | - OLDEST_VERSION=$(gcloud app versions list --service=ocotillo-api --project=${{ vars.GCP_PROJECT_ID}} --format="value(id)" --sort-by="version.createTime" | head -n 1) - if [ ! -z "$OLDEST_VERSION" ]; then - echo "Deleting oldest version: $OLDEST_VERSION" - gcloud app versions delete $OLDEST_VERSION --service=ocotillo-api --project=${{ vars.GCP_PROJECT_ID }} --quiet - echo "Deleted oldest version: $OLDEST_VERSION" + SERVICE="ocotillo-api" + VERSIONS_JSON="$(gcloud app versions list --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --format=json --sort-by="version.createTime" 2>/dev/null || printf '[]')" + export VERSIONS_JSON + DELETE_VERSION="$(python - <<'PY' + import json + import os + + versions = json.loads(os.environ.get("VERSIONS_JSON", "[]") or "[]") + if len(versions) <= 1: + print("") + raise SystemExit(0) + + def traffic_split(version): + for key in ("traffic_split", "trafficSplit"): + value = version.get(key) + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + for version in versions: + if traffic_split(version) == 0.0: + print(version.get("id", "")) + break + else: + print("") + PY + )" + if [ -n "$DELETE_VERSION" ]; then + echo "Deleting old non-serving version for $SERVICE: $DELETE_VERSION" + gcloud app versions delete "$DELETE_VERSION" --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --quiet else - echo "No versions to delete" + echo "No old non-serving versions to delete for $SERVICE" fi - - name: Remove app.yaml + - name: Remove rendered configs run: | rm app.yaml @@ -118,5 +151,5 @@ jobs: # ":" are not alloed in git tags, so replace with "-" - name: Tag commit run: | - git tag -a "production-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "staging gcloud deployment: $(date -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" + git tag -a "production-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "production gcloud deployment: $(date -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" git push origin --tags diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index ec5cad81..ed73059d 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -64,9 +64,8 @@ jobs: sudo apt-get install -y gettext-base fi - - name: Render app.yaml + - name: Render App Engine configs env: - SERVICE_NAME: "ocotillo-api-staging" ENVIRONMENT: "staging" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" @@ -87,25 +86,59 @@ jobs: SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" run: | + export MAX_INSTANCES="10" + export SERVICE_NAME="ocotillo-api-staging" + export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml - name: Deploy to Google Cloud run: | - gcloud app deploy app.yaml --quiet --project ${{ vars.GCP_PROJECT_ID }} + gcloud app deploy \ + app.yaml \ + --quiet \ + --project ${{ vars.GCP_PROJECT_ID }} - # Clean up old versions - delete only the oldest version, one created and one destroyed - - name: Clean up oldest version + - name: Clean up oldest versions run: | - OLDEST_VERSION=$(gcloud app versions list --service=ocotillo-api-staging --project=${{ vars.GCP_PROJECT_ID}} --format="value(id)" --sort-by="version.createTime" | head -n 1) - if [ ! -z "$OLDEST_VERSION" ]; then - echo "Deleting oldest version: $OLDEST_VERSION" - gcloud app versions delete $OLDEST_VERSION --service=ocotillo-api-staging --project=${{ vars.GCP_PROJECT_ID }} --quiet - echo "Deleted oldest version: $OLDEST_VERSION" + SERVICE="ocotillo-api-staging" + VERSIONS_JSON="$(gcloud app versions list --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --format=json --sort-by="version.createTime" 2>/dev/null || printf '[]')" + export VERSIONS_JSON + DELETE_VERSION="$(python - <<'PY' + import json + import os + + versions = json.loads(os.environ.get("VERSIONS_JSON", "[]") or "[]") + if len(versions) <= 1: + print("") + raise SystemExit(0) + + def traffic_split(version): + for key in ("traffic_split", "trafficSplit"): + value = version.get(key) + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + for version in versions: + if traffic_split(version) == 0.0: + print(version.get("id", "")) + break + else: + print("") + PY + )" + if [ -n "$DELETE_VERSION" ]; then + echo "Deleting old non-serving version for $SERVICE: $DELETE_VERSION" + gcloud app versions delete "$DELETE_VERSION" --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --quiet else - echo "No versions to delete" + echo "No old non-serving versions to delete for $SERVICE" fi - - name: Remove app.yaml + - name: Remove rendered configs run: | rm app.yaml diff --git a/AGENTS.MD b/AGENTS.MD index ae0bc08d..afeebcd9 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -25,6 +25,13 @@ these transfers, keep the following rules in mind to avoid hour-long runs: - Data migrations should be safe to re-run without creating duplicate rows or corrupting data. - Use upserts or duplicate checks and update source fields only after successful inserts. +## 4. Do a cleanup and code analysis pass after code changes +- After completing any code modification, do a cleanup and code analysis pass adjusted to the size and risk of the change. +- Check for obvious regressions, dead code, inconsistent config/docs/tests, and adjacent issues introduced by the change. +- Fix any concrete issues you find in that pass instead of stopping at implementation. +- After code cleanup, run `black` on the touched Python files and run `flake8` on the touched Python files before wrapping up. +- Run targeted validation for the modified area after cleanup; use broader validation when the change affects shared boot, deploy, or database paths. + Following this playbook keeps ETL runs measured in seconds/minutes instead of hours. EOF ## Activate python venv diff --git a/README.md b/README.md index 7e35d3ec..44ec7bcd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ supports research, field operations, and public data delivery for the Bureau of ## 🗺️ OGC API - Features The API exposes OGC API - Features endpoints under `/ogcapi` using `pygeoapi`. +In App Engine deployments, `/admin` and `/ogcapi` are served from the same +application as the primary API. The service is intended to scale to zero +outside business hours and be kept warm during the workday with Cloud Scheduler +hits to `/_ah/warmup`. ### Landing & metadata @@ -147,7 +151,7 @@ Minimum vars to set in `.env` for local development: * `POSTGRES_HOST` (`localhost` for local psql/pytest against mapped Docker port) * `POSTGRES_PORT` (`5432`) * `MODE` (`development` recommended locally) -* `SESSION_SECRET_KEY` +* `SESSION_SECRET_KEY` (required if you want to use `/admin`) Auth-related vars (required when auth is enabled, optional when `AUTHENTIK_DISABLE_AUTHENTICATION=1`): * `AUTHENTIK_DISABLE_AUTHENTICATION` @@ -199,15 +203,18 @@ docker compose up --build Notes: * Requires Docker Desktop. -* By default, spins up two containers: `db` (PostGIS/PostgreSQL) and `app` (FastAPI API service). +* By default, spins up two containers: + * `db` for PostGIS/PostgreSQL + * `app` for the primary API, admin UI, and OGC API on `http://localhost:8000` * `db` initializes both application databases in the same Postgres service: * `ocotilloapi_dev` * `ocotilloapi_test` -* `alembic upgrade head` runs on app startup after `docker compose up`. +* `alembic upgrade head` runs in the `app` container on startup. * Compose uses hardcoded DB names: * dev: `ocotilloapi_dev` * test: `ocotilloapi_test` (created by init SQL in `docker/db/init/01-create-test-db.sql`) * The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). +* `SESSION_SECRET_KEY` only needs to be set in `.env` if you plan to use `/admin`; without it, the API and `/ogcapi` still boot, but `/admin` will be unavailable. #### Staging Data diff --git a/alembic/env.py b/alembic/env.py index 62deed2d..944f00e1 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from sqlalchemy import create_engine, engine_from_config, pool, text -from services.util import get_bool_env +from services.env import get_bool_env # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py b/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py new file mode 100644 index 00000000..390ae86f --- /dev/null +++ b/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py @@ -0,0 +1,199 @@ +"""rebuild water elevation materialized view + +Revision ID: o8b9c0d1e2f3 +Revises: n7a8b9c0d1e2 +Create Date: 2026-03-10 15:30:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "o8b9c0d1e2f3" +down_revision: Union[str, Sequence[str], None] = "n7a8b9c0d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +METERS_TO_FEET = 3.28084 + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ) + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation_ft, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def _create_water_elevation_view_pre_feet_fix() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def _required_tables_present() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + +def _rebuild(create_sql: str, comment: str) -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(create_sql)) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " f"'{comment}'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def upgrade() -> None: + _required_tables_present() + _rebuild( + _create_water_elevation_view(), + "Latest water elevation per well with explicit units: " + "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.", + ) + + +def downgrade() -> None: + _rebuild( + _create_water_elevation_view_pre_feet_fix(), + "Latest water elevation per well (elevation minus depth to water below ground surface).", + ) diff --git a/api/asset.py b/api/asset.py index 6e5b8fde..90afdf9c 100644 --- a/api/asset.py +++ b/api/asset.py @@ -32,19 +32,18 @@ from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter -from services.query_helper import simple_get_by_id -from services.gcs_helper import ( - get_storage_bucket, - gcs_upload, - gcs_remove, - check_asset_exists, - add_signed_url, -) from services.exceptions_helper import PydanticStyleException +from services.query_helper import simple_get_by_id router = APIRouter(prefix="/asset", tags=["asset"]) +def get_storage_bucket(): + from services.gcs_helper import get_storage_bucket as get_gcs_storage_bucket + + return get_gcs_storage_bucket() + + def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> None: """ Handle errors raised by the database when adding or updating a asset. @@ -83,6 +82,8 @@ async def upload_asset( bucket=Depends(get_storage_bucket), file: UploadFile = File(...), ) -> dict: + from services.gcs_helper import gcs_upload + uri, blob_name = gcs_upload(file, bucket) return { "uri": uri, @@ -105,6 +106,8 @@ async def add_asset( # check to see if an asset entry already exists for # this storage path and thing_id + from services.gcs_helper import check_asset_exists + existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id) if existing_asset: # If an asset already exists, return it @@ -161,6 +164,8 @@ async def list_assets( def transformer(records: list[Asset]): if thing_id is not None: + from services.gcs_helper import add_signed_url + bucket = get_storage_bucket() records = [add_signed_url(ai, bucket) for ai in records] return records @@ -178,6 +183,8 @@ async def get_asset( """ Retrieve an asset by its ID. """ + from services.gcs_helper import add_signed_url + asset = simple_get_by_id(session, Asset, asset_id) add_signed_url(asset, bucket) @@ -220,6 +227,8 @@ async def remove_asset( session: session_dependency, bucket=Depends(get_storage_bucket), ): + from services.gcs_helper import gcs_remove + asset = simple_get_by_id(session, Asset, asset_id) gcs_remove(asset.uri, bucket) diff --git a/cli/cli.py b/cli/cli.py index a7777fd1..134c3538 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -24,8 +24,8 @@ import typer from dotenv import load_dotenv -# CLI should honor local `.env` values, even if shell/container vars already exist. -load_dotenv(override=True) +# CLI should load `.env` defaults without clobbering an explicitly prepared environment. +load_dotenv(override=False) os.environ.setdefault("OCO_LOG_CONTEXT", "cli") cli = typer.Typer(help="Command line interface for managing the application.") diff --git a/core/app.py b/core/app.py index 978419f6..43fd705a 100644 --- a/core/app.py +++ b/core/app.py @@ -14,10 +14,14 @@ # limitations under the License. # =============================================================================== import os +import asyncio +import time +import logging from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI +from fastapi import Request from fastapi.openapi.docs import ( get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, @@ -26,6 +30,8 @@ from .settings import settings +logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @@ -37,147 +43,204 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: seed_all(10, skip_if_exists=True) - yield - + app.state.instance_ready_at = time.perf_counter() + app.state.first_request_pending = True + logger.info( + "instance startup complete", + extra={ + "event": "instance_startup_complete", + "startup_ms": round( + (app.state.instance_ready_at - app.state.process_boot_started_at) + * 1000, + 2, + ), + }, + ) -app = FastAPI( - title="Sample Location API", - description="API for managing sample locations", - version=settings.version, - lifespan=lifespan, -) + yield -# --- full OpenAPI schema --- -def full_openapi(): - if app.openapi_schema: - return app.openapi_schema - schema = get_openapi( - title="Ocotillo API (Full)", +def create_base_app() -> FastAPI: + app = FastAPI( + title="Sample Location API", + description="API for managing sample locations", version=settings.version, - description="Full API schema (authorized users)", - routes=app.routes, - ) - app.openapi_schema = schema - return app.openapi_schema - - -# --- public OpenAPI schema --- -def public_openapi(): - schema = get_openapi( - title="Ocotillo API (Public)", - version="0.0.1", - description="Public API schema (anonymous users)", - routes=app.routes, + lifespan=lifespan, ) - - # Keep only operations where the endpoint function is marked public - new_paths = {} - for path, path_item in schema["paths"].items(): - new_methods = {} - for method, operation in path_item.items(): - # Recover the actual route handler - - route = next( + app.state.process_boot_started_at = time.perf_counter() + app.state.instance_ready_at = None + app.state.first_request_pending = True + app.state.request_timing_lock = asyncio.Lock() + + @app.middleware("http") + async def log_request_timing(request: Request, call_next): + request_started_at = time.perf_counter() + async with app.state.request_timing_lock: + is_first_request = app.state.first_request_pending + app.state.first_request_pending = False + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + return response + finally: + request_duration_ms = round( + (time.perf_counter() - request_started_at) * 1000, 2 + ) + startup_ms = round( ( - r - for r in app.routes - if r.path == path and method.upper() in r.methods - ), - None, + (app.state.instance_ready_at or request_started_at) + - app.state.process_boot_started_at + ) + * 1000, + 2, + ) + uptime_before_request_ms = round( + ( + ( + request_started_at + - (app.state.instance_ready_at or request_started_at) + ) + ) + * 1000, + 2, + ) + request_kind = "cold" if is_first_request else "warm" + + logger.info( + "request timing", + extra={ + "event": "request_timing", + "request_kind": request_kind, + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "request_duration_ms": request_duration_ms, + "startup_ms": startup_ms, + "uptime_before_request_ms": uptime_before_request_ms, + }, ) - if not route: - continue - - endpoint = getattr(route, "endpoint", None) - if getattr(endpoint, "_is_public", False): - # Strip security info for public docs - operation["security"] = [] - new_methods[method] = operation - - if new_methods: - new_paths[path] = new_methods - - schema["paths"] = new_paths - - # --- Collect all referenced schemas recursively --- - referenced = set() - - def collect_refs(obj): - if isinstance(obj, dict): - for k, v in obj.items(): - if ( - k == "$ref" - and isinstance(v, str) - and v.startswith("#/components/schemas/") - ): - referenced.add(v.split("/")[-1]) - else: - collect_refs(v) - elif isinstance(obj, list): - for item in obj: - collect_refs(item) - - # Step 1: Collect refs from paths - collect_refs(schema["paths"]) - - # Step 2: Recursively resolve inside components - visited = set() - to_visit = set(referenced) - - while to_visit: - name = to_visit.pop() - if name in visited: - continue - visited.add(name) - - model = schema.get("components", {}).get("schemas", {}).get(name) - if not model: - continue - - collect_refs(model) - # Add only new schemas we haven’t visited yet - to_visit |= referenced - visited - - # Step 3: Filter components.schemas to only referenced ones - if "components" in schema and "schemas" in schema["components"]: - schema["components"]["schemas"] = { - n: m for n, m in schema["components"]["schemas"].items() if n in referenced - } - - # 4. Drop security schemes entirely for the public spec - if "components" in schema and "securitySchemes" in schema["components"]: - schema["components"].pop("securitySchemes", None) - return schema - - -# set the public schema as the default -app.openapi = public_openapi - -CLIENT_ID = os.environ.get("AUTHENTIK_CLIENT_ID") - - -@app.get("/docs-auth", include_in_schema=False) -async def custom_swagger_ui(): - return get_swagger_ui_html( - openapi_url="/openapi-auth.json", - title="Swagger UI", - oauth2_redirect_url="/docs-auth/oauth2-redirect", - init_oauth={ - "clientId": CLIENT_ID, - "usePkceWithAuthorizationCodeGrant": True, # if you use PKCE - }, - ) + def full_openapi(): + if app.openapi_schema: + return app.openapi_schema + schema = get_openapi( + title="Ocotillo API (Full)", + version=settings.version, + description="Full API schema (authorized users)", + routes=app.routes, + ) + app.openapi_schema = schema + return app.openapi_schema -@app.get("/openapi-auth.json", include_in_schema=False) -async def get_openapi_auth(): - return full_openapi() + def public_openapi(): + schema = get_openapi( + title="Ocotillo API (Public)", + version="0.0.1", + description="Public API schema (anonymous users)", + routes=app.routes, + ) + + # Keep only operations where the endpoint function is marked public. + new_paths = {} + for path, path_item in schema["paths"].items(): + new_methods = {} + for method, operation in path_item.items(): + route = next( + ( + r + for r in app.routes + if r.path == path and method.upper() in r.methods + ), + None, + ) + if not route: + continue + + endpoint = getattr(route, "endpoint", None) + if getattr(endpoint, "_is_public", False): + operation["security"] = [] + new_methods[method] = operation + + if new_methods: + new_paths[path] = new_methods + + schema["paths"] = new_paths + + referenced = set() + + def collect_refs(obj): + if isinstance(obj, dict): + for key, value in obj.items(): + if ( + key == "$ref" + and isinstance(value, str) + and value.startswith("#/components/schemas/") + ): + referenced.add(value.split("/")[-1]) + else: + collect_refs(value) + elif isinstance(obj, list): + for item in obj: + collect_refs(item) + + collect_refs(schema["paths"]) + + visited = set() + to_visit = set(referenced) + while to_visit: + name = to_visit.pop() + if name in visited: + continue + visited.add(name) + model = schema.get("components", {}).get("schemas", {}).get(name) + if not model: + continue -@app.get("/docs-auth/oauth2-redirect", include_in_schema=False) -async def swagger_ui_redirect(): - return get_swagger_ui_oauth2_redirect_html() + collect_refs(model) + to_visit |= referenced - visited + + if "components" in schema and "schemas" in schema["components"]: + schema["components"]["schemas"] = { + name: model + for name, model in schema["components"]["schemas"].items() + if name in referenced + } + + if "components" in schema and "securitySchemes" in schema["components"]: + schema["components"].pop("securitySchemes", None) + return schema + + app.openapi = public_openapi + + client_id = os.environ.get("AUTHENTIK_CLIENT_ID") + + @app.get("/docs-auth", include_in_schema=False) + async def custom_swagger_ui(): + return get_swagger_ui_html( + openapi_url="/openapi-auth.json", + title="Swagger UI", + oauth2_redirect_url="/docs-auth/oauth2-redirect", + init_oauth={ + "clientId": client_id, + "usePkceWithAuthorizationCodeGrant": True, + }, + ) + + @app.get("/openapi-auth.json", include_in_schema=False) + async def get_openapi_auth(): + return full_openapi() + + @app.get("/docs-auth/oauth2-redirect", include_in_schema=False) + async def swagger_ui_redirect(): + return get_swagger_ui_oauth2_redirect_html() + + @app.get("/_ah/warmup", include_in_schema=False) + async def warmup(): + return {"status": "ok"} + + return app def public_route(func): diff --git a/core/factory.py b/core/factory.py new file mode 100644 index 00000000..69bcfba7 --- /dev/null +++ b/core/factory.py @@ -0,0 +1,55 @@ +import os + +from dotenv import load_dotenv + +from core.app import create_base_app +from core.initializers import ( + configure_apitally_middleware, + configure_cors_middleware, + configure_lazy_admin, + configure_session_middleware, + register_api_routes, +) + +_runtime_initialized = False + + +def initialize_runtime() -> None: + global _runtime_initialized + + if _runtime_initialized: + return + + load_dotenv(override=False) + dsn = os.environ.get("SENTRY_DSN") + if dsn: + import sentry_sdk + + sentry_sdk.init( + dsn=dsn, + traces_sample_rate=float( + os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.1") + ), + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILES_SAMPLE_RATE", "0.0") + ), + profile_lifecycle="trace", + send_default_pii=True, + ) + + _runtime_initialized = True + + +def create_api_app(): + initialize_runtime() + app = create_base_app() + register_api_routes(app) + from core.pygeoapi import mount_pygeoapi + + mount_pygeoapi(app) + if os.environ.get("SESSION_SECRET_KEY"): + configure_session_middleware(app) + configure_cors_middleware(app) + configure_apitally_middleware(app) + configure_lazy_admin(app) + return app diff --git a/core/initializers.py b/core/initializers.py index ba932b9b..98da4e8e 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import asyncio import os from pathlib import Path @@ -20,6 +21,7 @@ from sqlalchemy import text, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.exc import DatabaseError +from starlette.responses import PlainTextResponse from db import Base from db.engine import session_ctx @@ -193,11 +195,10 @@ def init_lexicon(path: str = None) -> None: session.commit() -def register_routes(app): - if getattr(app.state, "routes_registered", False): +def register_api_routes(app): + if getattr(app.state, "api_routes_registered", False): return - from admin.auth_routes import router as admin_auth_router from api.group import router as group_router from api.contact import router as contact_router from api.location import router as location_router @@ -215,15 +216,12 @@ def register_routes(app): from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router - from core.pygeoapi import mount_pygeoapi app.include_router(asset_router) - app.include_router(admin_auth_router) app.include_router(author_router) app.include_router(contact_router) app.include_router(geospatial_router) app.include_router(group_router) - mount_pygeoapi(app) app.include_router(lexicon_router) app.include_router(location_router) app.include_router(observation_router) @@ -234,11 +232,10 @@ def register_routes(app): app.include_router(thing_router) app.include_router(ngwmn_router) add_pagination(app) - app.state.routes_registered = True + app.state.api_routes_registered = True -def configure_middleware(app): - from starlette.middleware.cors import CORSMiddleware +def configure_session_middleware(app): from starlette.middleware.sessions import SessionMiddleware if not getattr(app.state, "session_middleware_configured", False): @@ -248,6 +245,10 @@ def configure_middleware(app): app.add_middleware(SessionMiddleware, secret_key=session_secret_key) app.state.session_middleware_configured = True + +def configure_cors_middleware(app): + from starlette.middleware.cors import CORSMiddleware + if not getattr(app.state, "cors_middleware_configured", False): app.add_middleware( CORSMiddleware, @@ -258,6 +259,8 @@ def configure_middleware(app): ) app.state.cors_middleware_configured = True + +def configure_apitally_middleware(app): apitally_client_id = os.environ.get("APITALLY_CLIENT_ID") if apitally_client_id and not getattr( app.state, "apitally_middleware_configured", False @@ -278,14 +281,44 @@ def configure_middleware(app): app.state.apitally_middleware_configured = True +def configure_middleware(app): + configure_session_middleware(app) + configure_cors_middleware(app) + configure_apitally_middleware(app) + + def configure_admin(app): if getattr(app.state, "admin_configured", False): return from admin import create_admin + from admin.auth_routes import router as admin_auth_router + app.include_router(admin_auth_router) create_admin(app) app.state.admin_configured = True +def configure_lazy_admin(app): + if getattr(app.state, "lazy_admin_configured", False): + return + + app.state.admin_configure_lock = asyncio.Lock() + + @app.middleware("http") + async def ensure_admin_initialized(request, call_next): + if request.url.path.startswith("/admin"): + if not getattr(app.state, "session_middleware_configured", False): + return PlainTextResponse( + "Admin requires SESSION_SECRET_KEY to be configured.", + status_code=503, + ) + async with app.state.admin_configure_lock: + if not getattr(app.state, "admin_configured", False): + configure_admin(app) + return await call_next(request) + + app.state.lazy_admin_configured = True + + # ============= EOF ============================================= diff --git a/core/permissions.py b/core/permissions.py index b5ce731a..952e844f 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -# import os import os +from functools import lru_cache from typing import Optional, List, Union, cast, Callable import httpx @@ -36,18 +36,20 @@ if AUTHENTIK_ISSUER and not auth_disabled: JWKS_URL = f"{AUTHENTIK_ISSUER}jwks/" - # Fetch JWKS (could also cache this) - def get_jwks(): - resp = httpx.get(JWKS_URL) - resp.raise_for_status() - return resp.json() - jwks = get_jwks() +@lru_cache(maxsize=1) +def get_jwks(): + if not AUTHENTIK_ISSUER or auth_disabled: + return {} + + resp = httpx.get(JWKS_URL, timeout=10.0) + resp.raise_for_status() + return resp.json() def get_public_key(token): unverified_header = jwt.get_unverified_header(token) - for key in jwks["keys"]: + for key in get_jwks().get("keys", []): if key["kid"] == unverified_header["kid"]: return RSAAlgorithm.from_jwk(key) raise HTTPException(status_code=401, detail="Invalid signing key") diff --git a/db/engine.py b/db/engine.py index 6e1bfd17..eb841306 100644 --- a/db/engine.py +++ b/db/engine.py @@ -29,7 +29,7 @@ ) from sqlalchemy.util import await_only -from services.util import get_bool_env +from services.env import get_bool_env # Load .env file - don't override env vars already set (e.g., by test framework) load_dotenv(override=False) diff --git a/docker-compose.yml b/docker-compose.yml index 9a557f82..5331fe3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,12 @@ services: - POSTGRES_PORT=5432 - MODE=${MODE} - AUTHENTIK_DISABLE_AUTHENTICATION=${AUTHENTIK_DISABLE_AUTHENTICATION} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY} + - PYGEOAPI_POSTGRES_HOST=db + - PYGEOAPI_POSTGRES_PORT=5432 + - PYGEOAPI_POSTGRES_DB=ocotilloapi_dev + - PYGEOAPI_POSTGRES_USER=${POSTGRES_USER} + - PYGEOAPI_POSTGRES_PASSWORD=${POSTGRES_PASSWORD} ports: - 8000:8000 depends_on: diff --git a/entrypoint.sh b/entrypoint.sh index 3fd13d48..18e0badc 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,6 +4,9 @@ set -eu DB_HOST="${POSTGRES_HOST:-db}" DB_PORT="${POSTGRES_PORT:-5432}" DB_NAME="${POSTGRES_DB:-postgres}" +APP_MODULE="${APP_MODULE:-main:app}" +APP_PORT="${APP_PORT:-8000}" +RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}" # Wait for PostgreSQL to be ready until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -d "$DB_NAME"; do @@ -12,7 +15,10 @@ until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DB_HOST" -p "$DB_PORT" -U done echo "PostgreSQL is ready!" -echo "Applying migrations..." -alembic upgrade head +if [ "$RUN_MIGRATIONS" = "true" ]; then + echo "Applying migrations..." + alembic upgrade head +fi + echo "Starting the application..." -uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" --reload diff --git a/main.py b/main.py index fac816f2..5f2bcb6b 100644 --- a/main.py +++ b/main.py @@ -1,43 +1,7 @@ -import os +from core.factory import create_api_app -from dotenv import load_dotenv -from core.initializers import configure_admin, configure_middleware, register_routes - -load_dotenv() -DSN = os.environ.get("SENTRY_DSN") - -if DSN: - import sentry_sdk - - sentry_sdk.init( - dsn=DSN, - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - traces_sample_rate=1.0, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. - profiles_sample_rate=1.0, - # Set profile_lifecycle to "trace" to automatically - # run the profiler on when there is an active transaction - profile_lifecycle="trace", - # Add data like request headers and IP for users, - # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info - send_default_pii=True, - ) - - -def create_app(): - from core.app import app as core_app - - register_routes(core_app) - configure_middleware(core_app) - configure_admin(core_app) - return core_app - - -app = create_app() +app = create_api_app() if __name__ == "__main__": diff --git a/schedule b/schedule index cadb867e..4b43cb08 100644 --- a/schedule +++ b/schedule @@ -1,9 +1,19 @@ +Use Cloud Scheduler to keep the primary API warm during business hours without +paying for a dedicated instance overnight. -this is used to add a schedule. -This schedule is used to keeping the api runingl - -gcloud scheduler jobs create http keep-alive-job \ - --schedule="*/10 8-18 * * 1-5" \ +Production: +gcloud scheduler jobs create http ocotillo-api-business-hours-warmup \ + --location=us-west4 \ + --schedule="*/5 8-18 * * 1-5" \ + --time-zone="America/Denver" \ --uri="https://ocotillo-api-dot-waterdatainitiative-271000.appspot.com/_ah/warmup" \ - --http-method=GET \ No newline at end of file + --http-method=GET + +Staging: +gcloud scheduler jobs create http ocotillo-api-staging-business-hours-warmup \ + --location=us-west4 \ + --schedule="*/5 8-18 * * 1-5" \ + --time-zone="America/Denver" \ + --uri="https://ocotillo-api-staging-dot-waterdatainitiative-271000.appspot.com/_ah/warmup" \ + --http-method=GET diff --git a/services/asset_helper.py b/services/asset_helper.py index 83c48509..51a4654f 100644 --- a/services/asset_helper.py +++ b/services/asset_helper.py @@ -5,19 +5,21 @@ # 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 typing import TYPE_CHECKING, 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 +if TYPE_CHECKING: + from google.cloud.storage import Bucket + def upload_and_associate( session: Session, ff: BinaryIO, - bucket: Bucket, + bucket: "Bucket", thing: Thing, name: str, **asset_args, diff --git a/services/env.py b/services/env.py new file mode 100644 index 00000000..7bc06caa --- /dev/null +++ b/services/env.py @@ -0,0 +1,16 @@ +import os + + +def to_bool(value: str) -> bool | str: + """Convert common string environment values to booleans.""" + if isinstance(value, bool): + return value + if value.lower() in ("true", "1", "yes"): + return True + if value.lower() in ("false", "0", "no"): + return False + return value + + +def get_bool_env(key, default=False): + return to_bool(os.getenv(key, default)) diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 4a45fa50..237af5cb 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -20,7 +20,6 @@ from hashlib import md5 from fastapi import UploadFile -from google.oauth2 import service_account from sqlalchemy import select from core.settings import settings @@ -29,10 +28,11 @@ GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME") GCS_BUCKET_BASE_URL = f"https://storage.cloud.google.com/{GCS_BUCKET_NAME}/uploads" -from google.cloud import storage +def get_storage_client(): + from google.cloud import storage + from google.oauth2 import service_account -def get_storage_client() -> storage.Client: if settings.mode == "production": key_base64 = os.environ.get("GCS_SERVICE_ACCOUNT_KEY") decoded = base64.b64decode(key_base64).decode("utf-8") @@ -51,7 +51,7 @@ def get_storage_client() -> storage.Client: return client -def get_storage_bucket(client=None, bucket: str = None) -> storage.Bucket: +def get_storage_bucket(client=None, bucket: str = None): if client is None: client = get_storage_client() @@ -70,7 +70,7 @@ def make_blob_name_and_uri(file): return blob_name, uri -def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): +def gcs_upload(file: UploadFile, bucket=None): if bucket is None: bucket = get_storage_bucket() @@ -87,12 +87,12 @@ def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): return uri, blob_name -def gcs_remove(uri: str, bucket: storage.Bucket): +def gcs_remove(uri: str, bucket): blob = bucket.blob(uri) blob.delete() -def add_signed_url(asset: Asset, bucket: storage.Bucket): +def add_signed_url(asset: Asset, bucket): asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( version="v4", expiration=datetime.timedelta(minutes=15), diff --git a/services/util.py b/services/util.py index 7a3df7ee..374666e9 100644 --- a/services/util.py +++ b/services/util.py @@ -1,9 +1,9 @@ import json import logging -import os import time from datetime import datetime from zoneinfo import ZoneInfo + import httpx import pyproj from shapely.ops import transform @@ -47,22 +47,6 @@ def _get_json( return None -def to_bool(value: str) -> bool | str: - """Convert a string to a boolean.""" - if isinstance(value, bool): - return value - if value.lower() in ("true", "1", "yes"): - return True - elif value.lower() in ("false", "0", "no"): - return False - - return value - - -def get_bool_env(key, default=False): - return to_bool(os.getenv(key, default)) - - def transform_srid(geometry, source_srid, target_srid): """ geometry must be a shapely geometry object, like Point, Polygon, or MultiPolygon diff --git a/tests/conftest.py b/tests/conftest.py index a5f037b6..2705e72a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,12 @@ from alembic import command from alembic.config import Config from dotenv import load_dotenv +from sqlalchemy import delete +from sqlalchemy import inspect as sa_inspect from core.initializers import init_lexicon, init_parameter from db import * -from db.engine import session_ctx +from db.engine import engine, session_ctx from db.initialization import ( recreate_public_schema, sync_search_vector_triggers, @@ -41,13 +43,31 @@ def _alembic_config() -> Config: def _reset_schema() -> None: + engine.dispose() with session_ctx() as session: recreate_public_schema(session) + engine.dispose() def _sync_search_vectors() -> None: + engine.dispose() with session_ctx() as session: sync_search_vector_triggers(session) + engine.dispose() + + +def _delete_if_present(session, obj) -> None: + if obj is None: + return + + state = sa_inspect(obj) + identity = state.identity + if identity is None: + return + + persistent = session.get(type(obj), identity[0] if len(identity) == 1 else identity) + if persistent is not None: + session.delete(persistent) @pytest.fixture(scope="session", autouse=True) @@ -55,6 +75,7 @@ def _setup_test_db(): """Reset schema once per session; tests share DB state, so keep isolation in fixtures.""" _reset_schema() command.upgrade(_alembic_config(), "head") + engine.dispose() _sync_search_vectors() init_lexicon() init_parameter() @@ -123,11 +144,11 @@ def location(): session.add(note) session.commit() session.refresh(loc) + location_id = loc.id yield loc - session.delete(note) - session.delete(loc) + session.execute(delete(Location).where(Location.id == location_id)) session.commit() @@ -141,8 +162,9 @@ def second_location(): ) session.add(location) session.commit() + location_id = location.id yield location - session.delete(location) + session.execute(delete(Location).where(Location.id == location_id)) session.commit() @@ -281,8 +303,9 @@ def second_well_screen(water_well_thing): ) session.add(screen) session.commit() + screen_id = screen.id yield screen - session.delete(screen) + session.execute(delete(WellScreen).where(WellScreen.id == screen_id)) session.commit() @@ -298,8 +321,9 @@ def thing_id_link(water_well_thing): ) session.add(id_link) session.commit() + link_id = id_link.id yield id_link - session.delete(id_link) + session.execute(delete(ThingIdLink).where(ThingIdLink.id == link_id)) session.commit() @@ -315,8 +339,9 @@ def second_thing_id_link(water_well_thing): ) session.add(id_link) session.commit() + link_id = id_link.id yield id_link - session.delete(id_link) + session.execute(delete(ThingIdLink).where(ThingIdLink.id == link_id)) session.commit() @@ -339,9 +364,14 @@ def spring_thing(location): assoc.thing_id = spring.id session.add(assoc) session.commit() + spring_id = spring.id yield spring - session.delete(spring) - session.delete(assoc) + session.execute( + delete(LocationThingAssociation).where( + LocationThingAssociation.thing_id == spring_id + ) + ) + session.execute(delete(Thing).where(Thing.id == spring_id)) session.commit() @@ -364,9 +394,14 @@ def second_spring_thing(location): assoc.thing_id = spring.id session.add(assoc) session.commit() + spring_id = spring.id yield spring - session.delete(spring) - session.delete(assoc) + session.execute( + delete(LocationThingAssociation).where( + LocationThingAssociation.thing_id == spring_id + ) + ) + session.execute(delete(Thing).where(Thing.id == spring_id)) session.commit() @@ -386,8 +421,9 @@ def sensor(): ) session.add(sensor) session.commit() + sensor_id = sensor.id yield sensor - session.delete(sensor) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) session.commit() @@ -407,8 +443,9 @@ def second_sensor(): ) session.add(sensor) session.commit() + sensor_id = sensor.id yield sensor - session.delete(sensor) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) session.commit() @@ -697,8 +734,13 @@ def asset_with_associated_thing(water_well_thing): session.refresh(association) yield asset - session.delete(asset) - session.delete(association) + session.execute( + delete(AssetThingAssociation).where( + AssetThingAssociation.asset_id == asset.id, + AssetThingAssociation.thing_id == water_well_thing.id, + ) + ) + session.execute(delete(Asset).where(Asset.id == asset.id)) session.commit() @@ -718,7 +760,7 @@ def second_asset(): session.commit() session.refresh(asset) yield asset - session.delete(asset) + session.execute(delete(Asset).where(Asset.id == asset.id)) session.commit() diff --git a/tests/features/environment.py b/tests/features/environment.py index 4f3a6d2b..9813c38f 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -53,7 +53,7 @@ ) from db.engine import session_ctx from db.initialization import recreate_public_schema, sync_search_vector_triggers -from services.util import get_bool_env +from services.env import get_bool_env def add_context_object_container(name): diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index 92036c77..67c8b6ce 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -223,6 +223,35 @@ def test_postgis_extension_enabled(self): assert postgis == "postgis", "PostGIS extension not enabled" + def test_water_elevation_materialized_view_has_expected_columns(self): + """Water elevation materialized view should match the feet-normalized schema.""" + with session_ctx() as session: + result = session.execute( + text( + """ + SELECT attname + FROM pg_attribute + WHERE attrelid = 'ogc_water_elevation_wells'::regclass + AND attnum > 0 + AND NOT attisdropped + ORDER BY attnum + """ + ) + ) + columns = [row[0] for row in result.fetchall()] + + assert columns == [ + "id", + "name", + "thing_type", + "observation_id", + "observation_datetime", + "elevation_m", + "depth_to_water_below_ground_surface_ft", + "water_elevation_ft", + "point", + ] + # ============================================================================= # Foreign Key Integrity Tests diff --git a/tests/test_lazy_admin.py b/tests/test_lazy_admin.py new file mode 100644 index 00000000..5b70ed88 --- /dev/null +++ b/tests/test_lazy_admin.py @@ -0,0 +1,19 @@ +import os + +from core.factory import create_api_app +from fastapi.testclient import TestClient + + +def test_admin_is_lazy_loaded_on_first_admin_request(): + os.environ["SESSION_SECRET_KEY"] = "test-session-secret-key" + app = create_api_app() + + assert not any(route.path.startswith("/admin") for route in app.routes) + assert getattr(app.state, "admin_configured", False) is False + + with TestClient(app) as client: + response = client.get("/admin", follow_redirects=False) + + assert response.status_code in {200, 302, 307} + assert app.state.admin_configured is True + assert any(route.path.startswith("/admin") for route in app.routes) diff --git a/tests/test_request_timing.py b/tests/test_request_timing.py new file mode 100644 index 00000000..ae6b255e --- /dev/null +++ b/tests/test_request_timing.py @@ -0,0 +1,43 @@ +import logging + +from fastapi.testclient import TestClient + +from core.app import create_base_app + + +def test_request_timing_logs_cold_then_warm(caplog): + app = create_base_app() + + @app.get("/ping") + async def ping(): + return {"status": "ok"} + + with caplog.at_level(logging.INFO, logger="core.app"): + with TestClient(app) as client: + assert client.get("/ping").status_code == 200 + assert client.get("/ping").status_code == 200 + + startup_logs = [ + record for record in caplog.records if record.msg == "instance startup complete" + ] + request_logs = [ + record for record in caplog.records if record.msg == "request timing" + ] + + assert len(startup_logs) == 1 + assert len(request_logs) == 2 + + assert startup_logs[0].event == "instance_startup_complete" + assert startup_logs[0].startup_ms >= 0 + + assert request_logs[0].request_kind == "cold" + assert request_logs[0].path == "/ping" + assert request_logs[0].status_code == 200 + assert request_logs[0].request_duration_ms >= 0 + assert request_logs[0].startup_ms >= 0 + + assert request_logs[1].request_kind == "warm" + assert request_logs[1].path == "/ping" + assert request_logs[1].status_code == 200 + assert request_logs[1].request_duration_ms >= 0 + assert request_logs[1].uptime_before_request_ms >= 0 diff --git a/tests/test_thing.py b/tests/test_thing.py index 6cba4800..dac8c124 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -16,6 +16,7 @@ from datetime import date, timezone import pytest +from sqlalchemy import delete from core.dependencies import ( admin_function, @@ -120,9 +121,12 @@ def test_measuring_point_properties_skip_null_history(): assert well.measuring_point_height == 2.5 assert well.measuring_point_description == "old mp" - session.delete(new_history) - session.delete(old_history) - session.delete(well) + session.execute( + delete(MeasuringPointHistory).where( + MeasuringPointHistory.thing_id == well.id + ) + ) + session.execute(delete(Thing).where(Thing.id == well.id)) session.commit() diff --git a/transfers/backfill/backfill.py b/transfers/backfill/backfill.py index fc7f5026..289f07d5 100644 --- a/transfers/backfill/backfill.py +++ b/transfers/backfill/backfill.py @@ -29,7 +29,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from services.util import get_bool_env +from services.env import get_bool_env from transfers.logger import logger diff --git a/transfers/transfer.py b/transfers/transfer.py index 49e36e9a..419d4870 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -52,7 +52,7 @@ from db.engine import session_ctx from db.initialization import recreate_public_schema, sync_search_vector_triggers -from services.util import get_bool_env +from services.env import get_bool_env from transfers.aquifer_system_transfer import transfer_aquifer_systems from transfers.geologic_formation_transfer import transfer_geologic_formations from transfers.permissions_transfer import transfer_permissions From c6c1997085495f3e1562769c227b2e613237f423 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:18:09 +0000 Subject: [PATCH 038/163] Formatting changes --- main.py | 1 - tests/integration/test_alembic_migrations.py | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 5f2bcb6b..8a56d312 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ from core.factory import create_api_app - app = create_api_app() diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index 67c8b6ce..5bba21c2 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -226,18 +226,14 @@ def test_postgis_extension_enabled(self): def test_water_elevation_materialized_view_has_expected_columns(self): """Water elevation materialized view should match the feet-normalized schema.""" with session_ctx() as session: - result = session.execute( - text( - """ + result = session.execute(text(""" SELECT attname FROM pg_attribute WHERE attrelid = 'ogc_water_elevation_wells'::regclass AND attnum > 0 AND NOT attisdropped ORDER BY attnum - """ - ) - ) + """)) columns = [row[0] for row in result.fetchall()] assert columns == [ From 6f0939296f4a11c93c3dcdf1a91b72c9c74d8ae7 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 09:38:59 -0600 Subject: [PATCH 039/163] Fix OGC API boolean parsing import --- services/query_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/query_helper.py b/services/query_helper.py index 74835a33..379e2791 100644 --- a/services/query_helper.py +++ b/services/query_helper.py @@ -24,8 +24,8 @@ from starlette.status import HTTP_404_NOT_FOUND from db import search as search_func +from services.env import to_bool from services.regex import QUERY_REGEX -from services.util import to_bool def make_where(col: Column, op: str, v: str) -> OperatorExpression: From dbdfea2e02faa4e256164405e49accd800f1ba47 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 10 Mar 2026 09:49:06 -0600 Subject: [PATCH 040/163] Stabilize shared-db API tests --- tests/test_location.py | 33 ++++++++++---------- tests/test_sample.py | 8 +++-- tests/test_sensor.py | 58 +++++++++++++++++++----------------- tests/test_thing.py | 30 +++++++++---------- tests/test_well_inventory.py | 42 +++++++++++++------------- 5 files changed, 88 insertions(+), 83 deletions(-) diff --git a/tests/test_location.py b/tests/test_location.py index 8dda23a4..e849d297 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -161,32 +161,33 @@ def test_get_locations(location): response = client.get("/location") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == location.id - assert data["items"][0]["created_at"] == location.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == location.id), None) + assert item is not None + assert item["created_at"] == location.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) # assert data["items"][0]["name"] == location.name - assert isinstance(data["items"][0]["notes"], list) + assert isinstance(item["notes"], list) # If you know the exact number of notes expected: # assert len(data["items"][0]["notes"]) == expected_count # If you want to check content of a specific note: # if data["items"][0]["notes"]: # assert data["items"][0]["notes"][0]["content"] == expected_content - assert data["items"][0]["point"] == to_shape(location.point).wkt - assert data["items"][0]["elevation"] == location.elevation - assert data["items"][0]["release_status"] == location.release_status - assert "nma_location_notes" in data["items"][0] - assert data["items"][0]["nma_location_notes"] == location.nma_location_notes - assert "nma_data_reliability" in data["items"][0] - assert data["items"][0]["nma_data_reliability"] == location.nma_data_reliability + assert item["point"] == to_shape(location.point).wkt + assert item["elevation"] == location.elevation + assert item["release_status"] == location.release_status + assert "nma_location_notes" in item + assert item["nma_location_notes"] == location.nma_location_notes + assert "nma_data_reliability" in item + assert item["nma_data_reliability"] == location.nma_data_reliability # assert data["items"][0]["elevation_accuracy"] == location.elevation_accuracy # assert data["items"][0]["elevation_method"] == location.elevation_method # assert data["items"][0]["coordinate_accuracy"] == location.coordinate_accuracy # assert data["items"][0]["coordinate_method"] == location.coordinate_method - assert data["items"][0]["state"] == location.state - assert data["items"][0]["county"] == location.county - assert data["items"][0]["quad_name"] == location.quad_name + assert item["state"] == location.state + assert item["county"] == location.county + assert item["quad_name"] == location.quad_name def test_get_location_by_id(location): diff --git a/tests/test_sample.py b/tests/test_sample.py index 341bf6a6..e8e08246 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -285,7 +285,11 @@ def test_get_samples(water_chemistry_sample, groundwater_level_sample): response = client.get("/sample") assert response.status_code == 200 data = response.json() - assert len(data["items"]) == 2 + assert len(data["items"]) >= 2 + + item_ids = {item["id"] for item in data["items"]} + assert water_chemistry_sample.id in item_ids + assert groundwater_level_sample.id in item_ids for item in data["items"]: assert "id" in item @@ -312,7 +316,7 @@ def test_get_samples_by_thing_id( response = client.get(f"/sample?thing_id={water_well_thing.id}") assert response.status_code == 200 data = response.json() - assert data["total"] == 2 + assert data["total"] >= 2 data_ids = [d["id"] for d in data["items"]] sorted_data_ids = sorted(data_ids) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 56bfdc9a..eabaf7e5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -175,20 +175,21 @@ def test_get_sensors(sensor): response = client.get("/sensor") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == sensor.id - assert data["items"][0]["created_at"] == sensor.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == sensor.release_status - assert data["items"][0]["name"] == sensor.name - assert data["items"][0]["sensor_type"] == sensor.sensor_type - assert data["items"][0]["model"] == sensor.model - assert data["items"][0]["serial_no"] == sensor.serial_no - assert data["items"][0]["pcn_number"] == sensor.pcn_number - assert data["items"][0]["owner_agency"] == sensor.owner_agency - assert data["items"][0]["sensor_status"] == sensor.sensor_status - assert data["items"][0]["notes"] == sensor.notes + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == sensor.id), None) + assert item is not None + assert item["created_at"] == sensor.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) + assert item["release_status"] == sensor.release_status + assert item["name"] == sensor.name + assert item["sensor_type"] == sensor.sensor_type + assert item["model"] == sensor.model + assert item["serial_no"] == sensor.serial_no + assert item["pcn_number"] == sensor.pcn_number + assert item["owner_agency"] == sensor.owner_agency + assert item["sensor_status"] == sensor.sensor_status + assert item["notes"] == sensor.notes def test_get_sensors_by_thing_id( @@ -219,20 +220,21 @@ def test_get_sensors_by_parameter_id(sensor, groundwater_level_observation): response = client.get(f"/sensor?parameter_id={_groundwater_level_parameter_id()}") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == sensor.id - assert data["items"][0]["created_at"] == sensor.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == sensor.release_status - assert data["items"][0]["name"] == sensor.name - assert data["items"][0]["sensor_type"] == sensor.sensor_type - assert data["items"][0]["model"] == sensor.model - assert data["items"][0]["serial_no"] == sensor.serial_no - assert data["items"][0]["pcn_number"] == sensor.pcn_number - assert data["items"][0]["owner_agency"] == sensor.owner_agency - assert data["items"][0]["sensor_status"] == sensor.sensor_status - assert data["items"][0]["notes"] == sensor.notes + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == sensor.id), None) + assert item is not None + assert item["created_at"] == sensor.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) + assert item["release_status"] == sensor.release_status + assert item["name"] == sensor.name + assert item["sensor_type"] == sensor.sensor_type + assert item["model"] == sensor.model + assert item["serial_no"] == sensor.serial_no + assert item["pcn_number"] == sensor.pcn_number + assert item["owner_agency"] == sensor.owner_agency + assert item["sensor_status"] == sensor.sensor_status + assert item["notes"] == sensor.notes def test_get_sensor_by_id(sensor): diff --git a/tests/test_thing.py b/tests/test_thing.py index dac8c124..c9dd7d16 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -731,19 +731,19 @@ def test_get_thing_id_links(thing_id_link): response = client.get("/thing/id-link") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == thing_id_link.id - assert data["items"][0]["created_at"] == thing_id_link.created_at.astimezone( + assert data["total"] >= 1 + item = next( + (item for item in data["items"] if item["id"] == thing_id_link.id), None + ) + assert item is not None + assert item["created_at"] == thing_id_link.created_at.astimezone( timezone.utc ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == thing_id_link.release_status - assert data["items"][0]["thing_id"] == thing_id_link.thing_id - assert data["items"][0]["relation"] == thing_id_link.relation - assert data["items"][0]["alternate_id"] == thing_id_link.alternate_id - assert ( - data["items"][0]["alternate_organization"] - == thing_id_link.alternate_organization - ) + assert item["release_status"] == thing_id_link.release_status + assert item["thing_id"] == thing_id_link.thing_id + assert item["relation"] == thing_id_link.relation + assert item["alternate_id"] == thing_id_link.alternate_id + assert item["alternate_organization"] == thing_id_link.alternate_organization def test_get_thing_id_link_by_id(thing_id_link): @@ -797,11 +797,11 @@ def test_get_things(water_well_thing, spring_thing, location): response = client.get("/thing") assert response.status_code == 200 - expected_location = LocationResponse.model_validate(location).model_dump() - # created_at is already serialized to UTC format by UTCAwareDatetime - data = response.json() - assert data["total"] == 2 + assert data["total"] >= 2 + item_ids = {item["id"] for item in data["items"]} + assert water_well_thing.id in item_ids + assert spring_thing.id in item_ids @pytest.mark.skip("Needs to be updated per changes made from feature files") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 010d4d6e..94561a5c 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -15,6 +15,7 @@ from cli.service_adapter import well_inventory_csv from core.constants import SRID_UTM_ZONE_13N, SRID_WGS84 from db import ( + Base, Location, LocationThingAssociation, Thing, @@ -29,6 +30,24 @@ from shapely import Point +def _reset_well_inventory_tables() -> None: + with session_ctx() as session: + for table in reversed(Base.metadata.sorted_tables): + if table.name in ("alembic_version", "parameter"): + continue + if table.name.startswith("lexicon"): + continue + session.execute(table.delete()) + session.commit() + + +@pytest.fixture(autouse=True) +def isolate_well_inventory_tables(): + _reset_well_inventory_tables() + yield + _reset_well_inventory_tables() + + def test_well_inventory_db_contents(): """ Test that the well inventory upload creates the correct database contents. @@ -417,17 +436,6 @@ def test_well_inventory_db_contents(): else: assert participant.participant.name == file_content["field_staff_2"] - # CLEAN UP THE DATABASE AFTER TESTING - session.query(FieldEventParticipant).delete() - session.query(FieldActivity).delete() - session.query(FieldEvent).delete() - session.query(ThingContactAssociation).delete() - session.query(LocationThingAssociation).delete() - session.query(Contact).delete() - session.query(Location).delete() - session.query(Thing).delete() - session.commit() - # ============================================================================= # Error Handling Tests - Cover API error paths @@ -582,7 +590,7 @@ def test_upload_missing_contact_type(self): result = well_inventory_csv(file_path) assert result.exit_code == 1 - def test_upload_missing_contact_type(self): + def test_upload_missing_contact_role(self): """Upload fails when contact is provided without role.""" file_path = Path("tests/features/data/well-inventory-missing-contact-role.csv") if file_path.exists(): @@ -966,15 +974,5 @@ def test_upload_valid_with_comma_in_quotes(self): # Should succeed - commas in quoted fields are valid CSV assert result.exit_code in (0, 1) # 1 if other validation fails - # Clean up if records were created - if result.exit_code == 0: - with session_ctx() as session: - session.query(Thing).delete() - session.query(Location).delete() - session.query(Contact).delete() - session.query(FieldEvent).delete() - session.query(FieldActivity).delete() - session.commit() - # ============= EOF ============================================= From 81faed4e25d5bc44da207a445295bf0aca682368 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 15:13:22 -0600 Subject: [PATCH 041/163] test(features): isolate well inventory scenarios with unique well ids to prevent cross-test collisions - Supports BDD test suite stability - Added hashing mechanism to append unique suffix to `well_name_point_id` for scenario isolation. - Integrated pandas for robust CSV parsing and content modifications when applicable. - Ensured handling preserves existing format for IDs ending with `-xxxx`. - Maintained existing handling for empty or non-CSV files. --- .../steps/well-inventory-csv-given.py | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index 6011ff0d..01eb910e 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -29,17 +29,41 @@ def _set_file_content(context: Context, name): def _set_file_content_from_path(context: Context, path: Path, name: str | None = None): context.file_path = path - with open(path, "r", encoding="utf-8", newline="") as f: - context.file_name = name or path.name - context.file_content = f.read() - if context.file_name.endswith(".csv"): - context.rows = list(csv.DictReader(context.file_content.splitlines())) - context.row_count = len(context.rows) - context.file_type = "text/csv" + import hashlib + import pandas as pd + from io import StringIO + + context.file_name = name or path.name + + if path.suffix == ".csv" and path.exists() and path.stat().st_size > 0: + suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] + df = pd.read_csv(path, dtype=str) + if "well_name_point_id" in df.columns: + df["well_name_point_id"] = df["well_name_point_id"].apply( + lambda x: ( + f"{x}_{suffix}" + if x and not str(x).endswith("-xxxx") and not str(x).strip() == "" + else x + ) + ) + buffer = StringIO() + df.to_csv(buffer, index=False) + context.file_content = buffer.getvalue() + context.rows = list(csv.DictReader(context.file_content.splitlines())) + context.row_count = len(context.rows) + context.file_type = "text/csv" + else: + # For empty files or non-CSV files, don't use pandas + if path.exists(): + with open(path, "r", encoding="utf-8", newline="") as f: + context.file_content = f.read() else: - context.rows = [] - context.row_count = 0 - context.file_type = "text/plain" + context.file_content = "" + context.rows = [] + context.row_count = 0 + context.file_type = ( + "text/csv" if context.file_name.endswith(".csv") else "text/plain" + ) @given( @@ -275,6 +299,17 @@ def step_step_step_16(context: Context): def _get_valid_df(context: Context) -> pd.DataFrame: _set_file_content(context, "well-inventory-valid.csv") df = pd.read_csv(context.file_path, dtype={"contact_2_address_1_postal_code": str}) + + # Add unique suffix to well names to ensure isolation between scenarios + # using a simple hash of the scenario name + import hashlib + + suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] + if "well_name_point_id" in df.columns: + df["well_name_point_id"] = df["well_name_point_id"].apply( + lambda x: f"{x}_{suffix}" if x and not str(x).endswith("-xxxx") else x + ) + return df From 5bbff150c004fbd669ecad0f8ae25f1b7e044e49 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 9 Mar 2026 16:12:31 -0600 Subject: [PATCH 042/163] refactor(helpers): tighten helper transactions to avoid refresh and rollback side effects - Supports transaction management - Moved `session.refresh` calls under `commit` condition to streamline database session operations. - Reorganized `session.rollback` logic to properly align with commit flow. --- services/contact_helper.py | 12 ++++++------ services/thing_helper.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/services/contact_helper.py b/services/contact_helper.py index 2aed7458..05b66200 100644 --- a/services/contact_helper.py +++ b/services/contact_helper.py @@ -114,16 +114,16 @@ def add_contact( if commit: session.commit() + session.refresh(contact) + + for note in contact.notes: + session.refresh(note) else: session.flush() - session.refresh(contact) - - for note in contact.notes: - session.refresh(note) - except Exception as e: - session.rollback() + if commit: + session.rollback() raise e return contact diff --git a/services/thing_helper.py b/services/thing_helper.py index cfbea0b6..221cb121 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -430,12 +430,12 @@ def add_thing( # ---------- if commit: session.commit() + session.refresh(thing) + + for note in thing.notes: + session.refresh(note) else: session.flush() - session.refresh(thing) - - for note in thing.notes: - session.refresh(note) except Exception as e: if commit: From 6c5d46ea7af242a260c80aaf7fa41ed577ba8cad Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 10 Mar 2026 13:12:56 -0600 Subject: [PATCH 043/163] feat(services): improve well inventory handling and align well inventory source fields in support of schema alignment and database mapping - Update well inventory CSV files to correct data inconsistencies and improve schema alignment. - Added support for `Sample`, `Observation`, and `Parameter` objects within well inventory processing. - Enhanced elevation handling with optional and default value logic. - Introduced `release_status`, `monitoring_status`, and validation for derived fields. - Updated notes handling with new cases and refined content categorization. - Improved `depth_to_water` processing with associated sample and observation creation. - Refined lexicon updates and schema field adjustments for better data consistency. --- core/lexicon.json | 2 +- schemas/well_inventory.py | 2 +- services/well_inventory_csv.py | 104 +++++++++++++++++- .../data/well-inventory-duplicate-columns.csv | 4 +- .../data/well-inventory-duplicate-header.csv | 6 +- ...-inventory-invalid-boolean-value-maybe.csv | 4 +- .../well-inventory-invalid-contact-type.csv | 4 +- .../well-inventory-invalid-date-format.csv | 4 +- .../data/well-inventory-invalid-date.csv | 4 +- .../data/well-inventory-invalid-email.csv | 4 +- .../data/well-inventory-invalid-lexicon.csv | 8 +- .../data/well-inventory-invalid-numeric.csv | 10 +- .../well-inventory-invalid-phone-number.csv | 4 +- .../well-inventory-invalid-postal-code.csv | 4 +- .../data/well-inventory-invalid-utm.csv | 5 +- .../well-inventory-missing-address-type.csv | 4 +- .../well-inventory-missing-contact-role.csv | 4 +- .../well-inventory-missing-contact-type.csv | 4 +- .../well-inventory-missing-email-type.csv | 4 +- .../well-inventory-missing-phone-type.csv | 4 +- .../data/well-inventory-missing-required.csv | 8 +- .../data/well-inventory-missing-wl-fields.csv | 4 +- .../well-inventory-valid-comma-in-quotes.csv | 2 +- 23 files changed, 150 insertions(+), 53 deletions(-) diff --git a/core/lexicon.json b/core/lexicon.json index ffd13d09..2b786190 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -8182,7 +8182,7 @@ "categories": [ "note_type" ], - "term": "Water Bearing Zone", + "term": "Water", "definition": "Water bearing zone information and other info from ose reports" }, { diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 49089ce1..8dafa5c2 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -288,7 +288,7 @@ class WellInventoryRow(BaseModel): special_requests: Optional[str] = None ose_well_record_id: Optional[str] = None date_drilled: OptionalPastOrTodayDate = None - completion_source: Optional[str] = None + completion_source: OriginTypeField = None total_well_depth_ft: OptionalFloat = None historic_depth_to_water_ft: OptionalFloat = None depth_source: OriginTypeField = None diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 561210f4..ab627cd9 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -41,6 +41,9 @@ PermissionHistory, Thing, ThingContactAssociation, + Sample, + Observation, + Parameter, ) from db.engine import session_ctx from pydantic import ValidationError @@ -264,12 +267,21 @@ def _make_location(model) -> Location: transformed_point = transform_srid( point, source_srid=source_srid, target_srid=SRID_WGS84 ) - elevation_ft = float(model.elevation_ft) - elevation_m = convert_ft_to_m(elevation_ft) + elevation_ft = model.elevation_ft + elevation_m = ( + convert_ft_to_m(float(elevation_ft)) if elevation_ft is not None else 0.0 + ) + + release_status = "draft" + if model.public_availability_acknowledgement is True: + release_status = "public" + elif model.public_availability_acknowledgement is False: + release_status = "private" loc = Location( point=transformed_point.wkt, elevation=elevation_m, + release_status=release_status, ) return loc @@ -504,11 +516,16 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(directions_note) # add data provenance records + elevation_method = ( + model.elevation_method.value + if hasattr(model.elevation_method, "value") + else (model.elevation_method or "Unknown") + ) dp = DataProvenance( target_id=loc.id, target_table="location", field_name="elevation", - collection_method=model.elevation_method, + collection_method=elevation_method, ) session.add(dp) @@ -524,7 +541,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) She indicated that it would be acceptable to use the depth source for the historic depth to water source. """ if model.depth_source: - historic_depth_to_water_source = model.depth_source.lower() + historic_depth_to_water_source = ( + model.depth_source.value + if hasattr(model.depth_source, "value") + else model.depth_source + ).lower() else: historic_depth_to_water_source = "unknown" @@ -539,7 +560,17 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) (model.contact_special_requests_notes, "General"), (model.well_measuring_notes, "Sampling Procedure"), (model.sampling_scenario_notes, "Sampling Procedure"), + (model.well_notes, "General"), + (model.water_notes, "Water"), (historic_depth_note, "Historical"), + ( + ( + f"Sample possible: {model.sample_possible}" + if model.sample_possible is not None + else None + ), + "Sampling Procedure", + ), ): if note_content is not None: well_notes.append({"content": note_content, "note_type": note_type}) @@ -591,6 +622,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) is_suitable_for_datalogger=model.datalogger_possible, is_open=model.is_open, well_status=model.well_status, + monitoring_status=( + model.monitoring_status.value + if hasattr(model.monitoring_status, "value") + else model.monitoring_status + ), notes=well_notes, well_purposes=well_purposes, monitoring_frequencies=monitoring_frequencies, @@ -661,6 +697,66 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) ) session.add(fa) + if model.depth_to_water_ft is not None: + if model.measurement_date_time is None: + raise ValueError( + "water_level_date_time is required when depth_to_water_ft is provided" + ) + + # get groundwater level parameter + parameter = ( + session.query(Parameter) + .filter( + Parameter.parameter_name == "groundwater level", + Parameter.matrix == "groundwater", + ) + .first() + ) + + if not parameter: + # this shouldn't happen if initialized properly, but just in case + parameter = Parameter( + parameter_name="groundwater level", + matrix="groundwater", + parameter_type="Field Parameter", + default_unit="ft", + ) + session.add(parameter) + session.flush() + + # create Sample + sample_method = ( + model.sample_method.value + if hasattr(model.sample_method, "value") + else (model.sample_method or "Unknown") + ) + sample = Sample( + field_activity_id=fa.id, + sample_date=model.measurement_date_time, + sample_name=f"{well.name_point_id}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}", + sample_matrix="groundwater", + sample_method=sample_method, + notes=model.water_level_notes, + ) + session.add(sample) + session.flush() + + # create Observation + observation = Observation( + sample_id=sample.id, + parameter_id=parameter.id, + observation_value=model.depth_to_water_ft, + observation_unit="ft", + observation_date=model.measurement_date_time, + data_quality=( + model.data_quality.value + if hasattr(model.data_quality, "value") + else (model.data_quality or "Unknown") + ), + notes=model.water_level_notes, + ) + session.add(observation) + # ------------------ # Contacts # ------------------ diff --git a/tests/features/data/well-inventory-duplicate-columns.csv b/tests/features/data/well-inventory-duplicate-columns.csv index cf459663..4f743a19 100644 --- a/tests/features/data/well-inventory-duplicate-columns.csv +++ b/tests/features/data/well-inventory-duplicate-columns.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible,contact_1_email_1 -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,john.smith@example.com -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,emily.davis@example.org +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,john.smith@example.com +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,emily.davis@example.org diff --git a/tests/features/data/well-inventory-duplicate-header.csv b/tests/features/data/well-inventory-duplicate-header.csv index 40c35980..698fc335 100644 --- a/tests/features/data/well-inventory-duplicate-header.csv +++ b/tests/features/data/well-inventory-duplicate-header.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1f,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True \ No newline at end of file +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1f,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True \ No newline at end of file diff --git a/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv b/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv index 75f3a33e..70d5a7a6 100644 --- a/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv +++ b/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,maybe,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,maybe,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-contact-type.csv b/tests/features/data/well-inventory-invalid-contact-type.csv index f06f5b3b..236e5e03 100644 --- a/tests/features/data/well-inventory-invalid-contact-type.csv +++ b/tests/features/data/well-inventory-invalid-contact-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,foo,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,foo,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-date-format.csv b/tests/features/data/well-inventory-invalid-date-format.csv index 806573d9..c65d1d8d 100644 --- a/tests/features/data/well-inventory-invalid-date-format.csv +++ b/tests/features/data/well-inventory-invalid-date-format.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,25-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,25-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-date.csv b/tests/features/data/well-inventory-invalid-date.csv index 697f9c29..b5676025 100644 --- a/tests/features/data/well-inventory-invalid-date.csv +++ b/tests/features/data/well-inventory-invalid-date.csv @@ -1,5 +1,5 @@ well_name_point_id,site_name,date_time,field_staff,contact_role,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method WELL005,Site Alpha,2025-02-30T10:30:0,Jane Doe,Owner,250000,4000000,13N,5120.5,GPS -WELL006,Site Beta,2025-13-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,Survey -WELL007,Site Gamma,not-a-date,Emily Clark,Supervisor,250000,4000000,13N,5150.3,Survey +WELL006,Site Beta,2025-13-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,Survey-grade GPS +WELL007,Site Gamma,not-a-date,Emily Clark,Supervisor,250000,4000000,13N,5150.3,Survey-grade GPS WELL008,Site Delta,2025-04-10 11:00:00,Michael Lee,Technician,250000,4000000,13N,5160.4,GPS diff --git a/tests/features/data/well-inventory-invalid-email.csv b/tests/features/data/well-inventory-invalid-email.csv index 13374bc1..ff67551b 100644 --- a/tests/features/data/well-inventory-invalid-email.csv +++ b/tests/features/data/well-inventory-invalid-email.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smithexample.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smithexample.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-lexicon.csv b/tests/features/data/well-inventory-invalid-lexicon.csv index f9f5dda4..9701bb8f 100644 --- a/tests/features/data/well-inventory-invalid-lexicon.csv +++ b/tests/features/data/well-inventory-invalid-lexicon.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,contact_role,contact_type -ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5,INVALID_ROLE,owner -ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7,manager,INVALID_TYPE -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,INVALID_METHOD,2.6,manager,owner -ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey,2.8,INVALID_ROLE,INVALID_TYPE +ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5,INVALID_ROLE,Primary +ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7,Manager,INVALID_TYPE +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,INVALID_METHOD,2.6,Manager,Primary +ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey-grade GPS,2.8,INVALID_ROLE,INVALID_TYPE diff --git a/tests/features/data/well-inventory-invalid-numeric.csv b/tests/features/data/well-inventory-invalid-numeric.csv index 40675dc6..382ea6f5 100644 --- a/tests/features/data/well-inventory-invalid-numeric.csv +++ b/tests/features/data/well-inventory-invalid-numeric.csv @@ -1,6 +1,6 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft -ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5 -ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7 -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey,2.6 -ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,elev_bad,Survey,2.8 -ProjectE,WELL005,Site5,2025-02-19T12:00:00,Jill Hill,250000,4000000,13N,5300,Survey,not_a_height +ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5 +ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7 +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey-grade GPS,2.6 +ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,elev_bad,Survey-grade GPS,2.8 +ProjectE,WELL005,Site5,2025-02-19T12:00:00,Jill Hill,250000,4000000,13N,5300,Survey-grade GPS,not_a_height diff --git a/tests/features/data/well-inventory-invalid-phone-number.csv b/tests/features/data/well-inventory-invalid-phone-number.csv index 6e3386f8..2060a8fc 100644 --- a/tests/features/data/well-inventory-invalid-phone-number.csv +++ b/tests/features/data/well-inventory-invalid-phone-number.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,55-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,55-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-postal-code.csv b/tests/features/data/well-inventory-invalid-postal-code.csv index 337c325d..24d30f59 100644 --- a/tests/features/data/well-inventory-invalid-postal-code.csv +++ b/tests/features/data/well-inventory-invalid-postal-code.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,8731,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Jemily Javis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,8731,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Jemily Javis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-utm.csv b/tests/features/data/well-inventory-invalid-utm.csv index a1576354..e8f14b2b 100644 --- a/tests/features/data/well-inventory-invalid-utm.csv +++ b/tests/features/data/well-inventory-invalid-utm.csv @@ -1,3 +1,4 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,457100,4159020,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13S,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,457100,4159020,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13S,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-005_MP1,Valid Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True diff --git a/tests/features/data/well-inventory-missing-address-type.csv b/tests/features/data/well-inventory-missing-address-type.csv index 28ecc032..d7b9846e 100644 --- a/tests/features/data/well-inventory-missing-address-type.csv +++ b/tests/features/data/well-inventory-missing-address-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-contact-role.csv b/tests/features/data/well-inventory-missing-contact-role.csv index fc475194..e5948aa9 100644 --- a/tests/features/data/well-inventory-missing-contact-role.csv +++ b/tests/features/data/well-inventory-missing-contact-role.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,David Emily,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,David Emily,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-contact-type.csv b/tests/features/data/well-inventory-missing-contact-type.csv index b4ec4120..6fd4cddc 100644 --- a/tests/features/data/well-inventory-missing-contact-type.csv +++ b/tests/features/data/well-inventory-missing-contact-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-email-type.csv b/tests/features/data/well-inventory-missing-email-type.csv index 4e1f722c..2354c7e7 100644 --- a/tests/features/data/well-inventory-missing-email-type.csv +++ b/tests/features/data/well-inventory-missing-email-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-phone-type.csv b/tests/features/data/well-inventory-missing-phone-type.csv index 739687f5..649ab568 100644 --- a/tests/features/data/well-inventory-missing-phone-type.csv +++ b/tests/features/data/well-inventory-missing-phone-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-required.csv b/tests/features/data/well-inventory-missing-required.csv index 9105a830..4d9fcdf0 100644 --- a/tests/features/data/well-inventory-missing-required.csv +++ b/tests/features/data/well-inventory-missing-required.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft -ProjectA,,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5 -ProjectB,,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7 -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey,2.6 -ProjectD,,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey,2.8 +ProjectA,,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5 +ProjectB,,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7 +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey-grade GPS,2.6 +ProjectD,,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey-grade GPS,2.8 diff --git a/tests/features/data/well-inventory-missing-wl-fields.csv b/tests/features/data/well-inventory-missing-wl-fields.csv index cbfa8546..0908e36f 100644 --- a/tests/features/data/well-inventory-missing-wl-fields.csv +++ b/tests/features/data/well-inventory-missing-wl-fields.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible,depth_to_water_ft -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,100 -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,200 +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,100 +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,200 diff --git a/tests/features/data/well-inventory-valid-comma-in-quotes.csv b/tests/features/data/well-inventory-valid-comma-in-quotes.csv index b66d673e..ab5509a8 100644 --- a/tests/features/data/well-inventory-valid-comma-in-quotes.csv +++ b/tests/features/data/well-inventory-valid-comma-in-quotes.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1D,"""Smith Farm, Domestic Well""",2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith T,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1D,"Smith Farm, Domestic Well",2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith T,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True Middle Rio Grande Groundwater Monitoring,MRG-003_MP1G,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis E,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False From 4a4e24923c23b4887d513496eb4626433a13382c Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 10 Mar 2026 15:06:28 -0600 Subject: [PATCH 044/163] feat(tests): adjust validation scenarios to allow partial imports with 1 well - Updated BDD tests to reflect changes in well inventory bulk upload logic, allowing the import of 1 well despite validation errors. - Modified step definitions for more granular validation on imported well counts. - Enhanced error message detail in responses for validation scenarios. - Adjusted sample CSV files to match new import logic and validation schema updates. - Refined service behavior to improve handling of validation errors and partial imports. --- cli/service_adapter.py | 12 +- services/well_inventory_csv.py | 201 ++++++++++-------- .../data/well-inventory-duplicate.csv | 4 +- .../data/well-inventory-invalid-partial.csv | 6 +- tests/features/steps/well-inventory-csv.py | 23 +- tests/features/well-inventory-csv.feature | 38 ++-- 6 files changed, 166 insertions(+), 118 deletions(-) diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 3e7eb770..c9ae4560 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -61,8 +61,16 @@ def well_inventory_csv(source_file: Path | str): except ValueError as exc: payload = {"detail": str(exc)} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) - exit_code = 0 if not payload.get("validation_errors") else 1 - return WellInventoryResult(exit_code, json.dumps(payload), "", payload) + exit_code = ( + 0 if not payload.get("validation_errors") and not payload.get("detail") else 1 + ) + stderr = "" + if exit_code != 0: + if payload.get("validation_errors"): + stderr = f"Validation errors: {json.dumps(payload.get('validation_errors'), indent=2)}" + else: + stderr = f"Error: {payload.get('detail')}" + return WellInventoryResult(exit_code, json.dumps(payload), stderr, payload) def water_levels_csv(source_file: Path | str, *, pretty_json: bool = False): diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index ab627cd9..34217fa6 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -55,8 +55,10 @@ from services.util import transform_srid, convert_ft_to_m AUTOGEN_DEFAULT_PREFIX = "NM-" -AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$") -AUTOGEN_TOKEN_REGEX = re.compile(r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$") +AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$", re.IGNORECASE) +AUTOGEN_TOKEN_REGEX = re.compile( + r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE +) def _extract_autogen_prefix(well_id: str | None) -> str | None: @@ -87,10 +89,6 @@ def _extract_autogen_prefix(well_id: str | None) -> str | None: prefix = m.group("prefix").upper() return f"{prefix}-" - token_match = AUTOGEN_TOKEN_REGEX.match(value) - if token_match: - return f"{token_match.group('prefix')}-" - return None @@ -150,9 +148,10 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): try: header = text.splitlines()[0] dialect = csv.Sniffer().sniff(header) - except csv.Error: - # raise an error if sniffing fails, which likely means the header is not parseable as CSV - raise ValueError("Unable to parse CSV header") + except Exception: + # fallback to comma if sniffing fails + class dialect: + delimiter = "," if dialect.delimiter != ",": raise ValueError(f"Unsupported delimiter '{dialect.delimiter}'") @@ -162,69 +161,93 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): duplicates = [col for col, count in counts.items() if count > 1] wells = [] + validation_errors = [] if duplicates: validation_errors = [ { - "row": 0, + "row": "header", "field": f"{duplicates}", "error": "Duplicate columns found", "value": duplicates, } ] + return { + "validation_errors": validation_errors, + "summary": { + "total_rows_processed": 0, + "total_rows_imported": 0, + "validation_errors_or_warnings": 1, + }, + "wells": [], + } - else: - models, validation_errors = _make_row_models(rows, session) - if models and not validation_errors: - current_row_id = None - try: - for project, items in groupby( - sorted(models, key=lambda x: x.project), key=lambda x: x.project - ): - # get project and add if does not exist - # BDMS-221 adds group_type - sql = select(Group).where( - and_( - Group.group_type == "Monitoring Plan", Group.name == project - ) - ) - group = session.scalars(sql).one_or_none() - if not group: - group = Group(name=project, group_type="Monitoring Plan") - session.add(group) - session.flush() - - for model in items: - current_row_id = model.well_name_point_id - added = _add_csv_row(session, group, model, user) - wells.append(added) - except ValueError as e: - error_text = str(e) - validation_errors.append( - { - "row": current_row_id or "unknown", - "field": _extract_field_from_value_error(error_text), - "error": error_text, - } - ) - session.rollback() - wells = [] - except DatabaseError as e: - logging.error( - f"Database error while importing row '{current_row_id or 'unknown'}': {e}" - ) - validation_errors.append( - { - "row": current_row_id or "unknown", - "field": "Database error", - "error": "A database error occurred while importing this row.", - } + try: + models, row_validation_errors = _make_row_models(rows, session) + validation_errors.extend(row_validation_errors) + + if models: + # Group by project, preserving row number + # models is a list of (row_number, model) + sorted_models = sorted(models, key=lambda x: x[1].project) + for project, items in groupby(sorted_models, key=lambda x: x[1].project): + # get project and add if does not exist + sql = select(Group).where( + and_(Group.group_type == "Monitoring Plan", Group.name == project) ) - session.rollback() - wells = [] - else: - session.commit() + group = session.scalars(sql).one_or_none() + if not group: + group = Group(name=project, group_type="Monitoring Plan") + session.add(group) + session.flush() + + for row_number, model in items: + current_row_id = model.well_name_point_id + try: + # Use savepoint for "best-effort" import per row + with session.begin_nested(): + added = _add_csv_row(session, group, model, user) + if added: + wells.append(added) + except ( + ValueError, + DatabaseError, + PydanticStyleException, + ValidationError, + ) as e: + if isinstance(e, PydanticStyleException): + error_text = str(e.detail) + field = "error" + elif isinstance(e, ValidationError): + # extract just the error messages + error_text = "; ".join( + [str(err.get("msg")) for err in e.errors()] + ) + field = _extract_field_from_value_error(error_text) + elif isinstance(e, DatabaseError): + error_text = "A database error occurred" + field = "Database error" + else: + error_text = str(e) + field = _extract_field_from_value_error(error_text) + + logging.error( + f"Error while importing row {row_number} ('{current_row_id}'): {error_text}" + ) + validation_errors.append( + { + "row": row_number, + "well_id": current_row_id, + "field": field, + "error": error_text, + } + ) + session.commit() + except Exception as exc: + logging.exception("Unexpected error in _import_well_inventory_csv") + return {"detail": str(exc)} - rows_imported = len(wells) + wells_imported = [w for w in wells if w is not None] + rows_imported = len(wells_imported) rows_processed = len(rows) error_rows = { e.get("row") for e in validation_errors if e.get("row") not in (None, 0) @@ -238,7 +261,7 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): "total_rows_imported": rows_imported, "validation_errors_or_warnings": rows_with_validation_errors_or_warnings, }, - "wells": wells, + "wells": wells_imported, } @@ -409,8 +432,9 @@ def _make_row_models(rows, session): models = [] validation_errors = [] seen_ids: Set[str] = set() - offset = 0 + offsets = {} for idx, row in enumerate(rows): + row_number = idx + 1 try: if all(key == row.get(key) for key in row.keys()): raise ValueError("Duplicate header row") @@ -420,10 +444,12 @@ def _make_row_models(rows, session): well_id = row.get("well_name_point_id") autogen_prefix = _extract_autogen_prefix(well_id) - if autogen_prefix: + if autogen_prefix is not None: + offset = offsets.get(autogen_prefix, 0) well_id, offset = _generate_autogen_well_id( session, autogen_prefix, offset ) + offsets[autogen_prefix] = offset row["well_name_point_id"] = well_id elif not well_id: raise ValueError("Field required") @@ -432,23 +458,24 @@ def _make_row_models(rows, session): raise ValueError("Duplicate value for well_name_point_id") seen_ids.add(well_id) - model = WellInventoryRow(**row) - models.append(model) - - except ValidationError as e: - for err in e.errors(): - loc = err["loc"] - - field = loc[0] if loc else "composite field error" - value = row.get(field) if loc else None - validation_errors.append( - { - "row": idx + 1, - "error": err["msg"], - "field": field, - "value": value, - } - ) + try: + model = WellInventoryRow(**row) + models.append((row_number, model)) + except ValidationError as e: + for err in e.errors(): + loc = err["loc"] + + field = loc[0] if loc else "composite field error" + value = row.get(field) if loc else None + validation_errors.append( + { + "row": row_number, + "well_id": well_id, + "error": err["msg"], + "field": field, + "value": value, + } + ) except ValueError as e: field = "well_name_point_id" # Map specific controlled errors to safe, non-revealing messages @@ -460,7 +487,7 @@ def _make_row_models(rows, session): error_msg = "Duplicate header row" field = "header" else: - error_msg = "Invalid value" + error_msg = str(e) if field == "header": value = ",".join(row.keys()) @@ -468,7 +495,13 @@ def _make_row_models(rows, session): value = row.get(field) validation_errors.append( - {"row": idx + 1, "field": field, "error": error_msg, "value": value} + { + "row": row_number, + "well_id": row.get("well_name_point_id"), + "field": field, + "error": error_msg, + "value": value, + } ) return models, validation_errors @@ -487,7 +520,7 @@ def _add_field_staff( if not contact: payload = dict(name=fs, role="Technician", organization=org, contact_type=ct) - contact = add_contact(session, payload, user) + contact = add_contact(session, payload, user, commit=False) fec = FieldEventParticipant( field_event=field_event, contact_id=contact.id, participant_role=role diff --git a/tests/features/data/well-inventory-duplicate.csv b/tests/features/data/well-inventory-duplicate.csv index 4f8ac75a..514cd6d3 100644 --- a/tests/features/data/well-inventory-duplicate.csv +++ b/tests/features/data/well-inventory-duplicate.csv @@ -1,3 +1,3 @@ project,measuring_point_height_ft,well_name_point_id,site_name,date_time,field_staff,contact_role,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method -foo,10,WELL001,Site Alpha,2025-02-15T10:30:00,Jane Doe,Owner,250000,4000000,13N,5120.5,LiDAR DEM -foob,10,WELL001,Site Beta,2025-03-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,LiDAR DEM +foo,10,DUPWELL001,Site Alpha,2025-02-15T10:30:00,Jane Doe,Owner,250000,4000000,13N,5120.5,LiDAR DEM +foob,10,DUPWELL001,Site Beta,2025-03-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,LiDAR DEM diff --git a/tests/features/data/well-inventory-invalid-partial.csv b/tests/features/data/well-inventory-invalid-partial.csv index 9535fd00..8dcdf3b8 100644 --- a/tests/features/data/well-inventory-invalid-partial.csv +++ b/tests/features/data/well-inventory-invalid-partial.csv @@ -1,4 +1,4 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP3,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith F,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP3,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis G,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False -Middle Rio Grande Groundwater Monitoring,,Old Orchard Well1,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis F,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False \ No newline at end of file +Middle Rio Grande Groundwater Monitoring,MRG-001_MP3,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith F,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP3,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis G,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,,Old Orchard Well1,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis F,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/steps/well-inventory-csv.py b/tests/features/steps/well-inventory-csv.py index da870cec..cf5b658e 100644 --- a/tests/features/steps/well-inventory-csv.py +++ b/tests/features/steps/well-inventory-csv.py @@ -244,15 +244,20 @@ def step_then_the_response_identifies_the_row_and_field_for_each_error( assert "field" in error, "Expected validation error to include field name" -@then("no wells are imported") -def step_then_no_wells_are_imported(context: Context): +@then("{count:d} wells are imported") +@then("{count:d} well is imported") +def step_then_count_wells_are_imported(context: Context, count: int): response_json = context.response.json() wells = response_json.get("wells", []) - if len(wells) > 0: - print(f"ACTUAL IMPORTED WELLS: {wells}") + validation_errors = response_json.get("validation_errors", []) assert ( - len(wells) == 0 - ), f"Expected no wells to be imported, but got {len(wells)}: {wells}" + len(wells) == count + ), f"Expected {count} wells to be imported, but got {len(wells)}: {wells}. Errors: {validation_errors}" + + +@then("no wells are imported") +def step_then_no_wells_are_imported(context: Context): + step_then_count_wells_are_imported(context, 0) @then("the response includes validation errors indicating duplicated values") @@ -368,8 +373,10 @@ def step_then_the_response_includes_a_validation_error_for_the_required_field( response_json = context.response.json() assert "validation_errors" in response_json, "Expected validation errors" vs = response_json["validation_errors"] - assert len(vs) == 2, "Expected 2 validation error" - assert vs[0]["field"] == required_field + assert len(vs) >= 1, "Expected at least 1 validation error" + assert any( + v["field"] == required_field for v in vs + ), f"Expected validation error for {required_field}, but got {vs}" @then("the response includes an error message indicating the row limit was exceeded") diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index 1500a5f9..f52fc0c9 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -205,7 +205,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the invalid postal code format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid phone number format @@ -213,7 +213,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the invalid phone number format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid email format @@ -221,7 +221,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the invalid email format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact without a contact_role @@ -229,7 +229,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the missing "contact_role" field - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact without a "contact_type" @@ -237,7 +237,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the missing "contact_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid "contact_type" @@ -245,7 +245,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid "contact_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an email without an email_type @@ -253,7 +253,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the missing "email_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with a phone without a phone_type @@ -261,7 +261,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the missing "phone_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an address without an address_type @@ -269,7 +269,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the missing "address_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid "address_type" @@ -277,7 +277,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid "address_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid state abbreviation @@ -285,7 +285,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid state value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has an invalid well_hole_status value @@ -293,7 +293,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid "well_hole_status" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has an invalid monitoring_status value @@ -301,7 +301,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid "monitoring_status" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has an invalid well_pump_type value @@ -309,7 +309,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid "well_pump_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has utm_easting utm_northing and utm_zone values that are not within New Mexico @@ -317,7 +317,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating the invalid UTM coordinates - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with neither contact_name nor contact_organization @@ -325,7 +325,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when water_level_date_time is missing but depth_to_water_ft is provided @@ -360,7 +360,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating an invalid boolean value for the "is_open" field - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when duplicate well_name_point_id values are present @@ -369,7 +369,7 @@ Feature: Bulk upload well inventory from CSV via CLI Then the command exits with a non-zero exit code And the response includes validation errors indicating duplicated values And each error identifies the row and field - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid lexicon values @@ -393,7 +393,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 3 wells are imported ########################################################################### From f5530ff34ca49711ecf97127413329a9ee68cbbe Mon Sep 17 00:00:00 2001 From: jakeross Date: Wed, 11 Mar 2026 12:14:06 -0600 Subject: [PATCH 045/163] feat: add restore-local-db command for restoring local databases from SQL dumps --- README.md | 1 + cli/README.md | 2 + cli/cli.py | 30 +++ cli/db_restore.py | 226 +++++++++++++++++++ tests/test_cli_commands.py | 200 +++++++++++++++- transfers/waterlevels_transducer_transfer.py | 1 + 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 cli/db_restore.py diff --git a/README.md b/README.md index 44ec7bcd..d9a42a32 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ Notes: * dev: `ocotilloapi_dev` * test: `ocotilloapi_test` (created by init SQL in `docker/db/init/01-create-test-db.sql`) * The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). +* To restore a local or GCS-backed SQL dump into your local target DB, run `source .venv/bin/activate && python -m cli.cli restore-local-db path/to/dump.sql` or `source .venv/bin/activate && python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz`. * `SESSION_SECRET_KEY` only needs to be set in `.env` if you plan to use `/admin`; without it, the API and `/ogcapi` still boot, but `/admin` will be unavailable. #### Staging Data diff --git a/cli/README.md b/cli/README.md index 42d557c8..2433081c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -15,6 +15,8 @@ python -m cli.cli --help ## Common commands +- `python -m cli.cli restore-local-db path/to/dump.sql` +- `python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz` - `python -m cli.cli transfer-results` - `python -m cli.cli compare-duplicated-welldata` - `python -m cli.cli alembic-upgrade-and-data` diff --git a/cli/cli.py b/cli/cli.py index 134c3538..44e9d02f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -134,6 +134,36 @@ def associate_assets_command( associate_assets(root_directory) +@cli.command("restore-local-db") +def restore_local_db( + source: str = typer.Argument( + ..., + help="Local .sql/.sql.gz path or gs://bucket/path.sql[.gz] URI.", + ), + db_name: str | None = typer.Option( + None, + "--db-name", + help="Override POSTGRES_DB for the restore target.", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + from cli.db_restore import LocalDbRestoreError, restore_local_db_from_sql + + try: + result = restore_local_db_from_sql(source, db_name=db_name) + except LocalDbRestoreError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + + typer.echo( + "Restored " + f"{result.source} into {result.db_name} " + f"on {result.host}:{result.port} as {result.user}." + ) + + @cli.command("transfer-results") def transfer_results( summary_path: Path = typer.Option( diff --git a/cli/db_restore.py b/cli/db_restore.py new file mode 100644 index 00000000..5f30b2e8 --- /dev/null +++ b/cli/db_restore.py @@ -0,0 +1,226 @@ +import getpass +import gzip +import os +import re +import subprocess +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from db.engine import engine, session_ctx +from db.initialization import recreate_public_schema +from services.gcs_helper import get_storage_bucket + +LOCAL_POSTGRES_HOSTS = {"localhost", "127.0.0.1", "::1", "db"} +ROLE_DEPENDENT_SQL_PATTERNS = ( + re.compile(r"^\s*SET\s+ROLE\b", re.IGNORECASE), + re.compile(r"^\s*SET\s+SESSION\s+AUTHORIZATION\b", re.IGNORECASE), + re.compile(r"^\s*ALTER\s+.*\s+OWNER\s+TO\b", re.IGNORECASE), + re.compile(r"^\s*GRANT\b", re.IGNORECASE), + re.compile(r"^\s*REVOKE\b", re.IGNORECASE), + re.compile(r"^\s*ALTER\s+DEFAULT\s+PRIVILEGES\b", re.IGNORECASE), +) + + +class LocalDbRestoreError(RuntimeError): + """Raised when a local database restore cannot be performed safely.""" + + +@dataclass(frozen=True) +class LocalDbRestoreResult: + sql_file: Path + source: str + host: str + port: str + user: str + db_name: str + + +def _is_gcs_uri(source: str) -> bool: + return source.startswith("gs://") + + +def _parse_gcs_uri(source: str) -> tuple[str, str]: + if not _is_gcs_uri(source): + raise LocalDbRestoreError(f"Expected gs:// URI, got {source!r}.") + + path = source[5:] + bucket_name, _, blob_name = path.partition("/") + if not bucket_name or not blob_name: + raise LocalDbRestoreError( + f"Invalid GCS URI {source!r}; expected gs://bucket/path.sql[.gz]." + ) + return bucket_name, blob_name + + +def _validate_restore_source_name(source_name: str) -> None: + if source_name.endswith(".sql") or source_name.endswith(".sql.gz"): + return + + raise LocalDbRestoreError( + "restore-local-db requires a .sql or .sql.gz source; " f"got {source_name!r}." + ) + + +def _decompress_gzip_file(source_path: Path, target_path: Path) -> None: + with gzip.open(source_path, "rb") as compressed: + with open(target_path, "wb") as expanded: + while chunk := compressed.read(1024 * 1024): + expanded.write(chunk) + + +def _sanitize_sql_dump(source_path: Path, target_path: Path) -> None: + with open(source_path, "r", encoding="utf-8") as infile: + with open(target_path, "w", encoding="utf-8") as outfile: + for line in infile: + if any(pattern.search(line) for pattern in ROLE_DEPENDENT_SQL_PATTERNS): + continue + outfile.write(line) + + +@contextmanager +def _stage_restore_source(source: str | Path): + source_text = str(source) + _validate_restore_source_name(source_text) + + with tempfile.TemporaryDirectory(prefix="ocotillo-db-restore-") as temp_dir: + temp_dir_path = Path(temp_dir) + expanded_sql_path = temp_dir_path / "expanded.sql" + staged_sql_path = temp_dir_path / "restore.sql" + + if _is_gcs_uri(source_text): + bucket_name, blob_name = _parse_gcs_uri(source_text) + bucket = get_storage_bucket(bucket=bucket_name) + blob = bucket.blob(blob_name) + downloaded_path = temp_dir_path / Path(blob_name).name + blob.download_to_filename(str(downloaded_path)) + + if source_text.endswith(".sql.gz"): + _decompress_gzip_file(downloaded_path, expanded_sql_path) + else: + expanded_sql_path = downloaded_path + _sanitize_sql_dump(expanded_sql_path, staged_sql_path) + yield staged_sql_path, source_text + return + + source_path = Path(source_text) + if not source_path.exists(): + raise LocalDbRestoreError(f"Restore source not found: {source_path}") + if not source_path.is_file(): + raise LocalDbRestoreError(f"Restore source is not a file: {source_path}") + + if source_text.endswith(".sql.gz"): + _decompress_gzip_file(source_path, expanded_sql_path) + else: + expanded_sql_path = source_path + _sanitize_sql_dump(expanded_sql_path, staged_sql_path) + yield staged_sql_path, str(source_path) + + +def _resolve_restore_target( + db_name: str | None = None, +) -> tuple[str, str, str, str, str]: + driver = (os.environ.get("DB_DRIVER") or "").strip().lower() + if driver == "cloudsql": + raise LocalDbRestoreError( + "restore-local-db only supports local PostgreSQL targets; " + "DB_DRIVER=cloudsql is not allowed." + ) + + host = (os.environ.get("POSTGRES_HOST") or "localhost").strip() + if not host: + host = "localhost" + if host not in LOCAL_POSTGRES_HOSTS: + raise LocalDbRestoreError( + "restore-local-db only supports local PostgreSQL hosts " + f"({', '.join(sorted(LOCAL_POSTGRES_HOSTS))}); got {host!r}." + ) + + port = (os.environ.get("POSTGRES_PORT") or "5432").strip() + if not port: + port = "5432" + + user = (os.environ.get("POSTGRES_USER") or "").strip() + if not user: + user = getpass.getuser() + + target_db = (db_name or os.environ.get("POSTGRES_DB") or "postgres").strip() + if not target_db: + raise LocalDbRestoreError("Target database name is empty.") + + password = os.environ.get("POSTGRES_PASSWORD", "") + return host, port, user, target_db, password + + +def _reset_target_schema() -> None: + try: + engine.dispose() + with session_ctx() as session: + recreate_public_schema(session) + engine.dispose() + except Exception as exc: + raise LocalDbRestoreError( + f"Failed to reset the public schema before restore: {exc}" + ) from exc + + +def restore_local_db_from_sql( + source_file: Path | str, *, db_name: str | None = None +) -> LocalDbRestoreResult: + host, port, user, target_db, password = _resolve_restore_target(db_name) + with _stage_restore_source(source_file) as (staged_sql_file, source_description): + try: + _reset_target_schema() + except LocalDbRestoreError: + raise + except Exception as exc: + raise LocalDbRestoreError( + f"Failed to reset the public schema before restore: {exc}" + ) from exc + command = [ + "psql", + "-v", + "ON_ERROR_STOP=1", + "-h", + host, + "-p", + port, + "-U", + user, + "-d", + target_db, + "-f", + str(staged_sql_file), + ] + + env = os.environ.copy() + if password: + env["PGPASSWORD"] = password + + try: + subprocess.run( + command, + check=True, + env=env, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise LocalDbRestoreError( + "psql is not installed or not available on PATH." + ) from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "").strip() or str(exc) + raise LocalDbRestoreError( + f"Restore failed for database {target_db!r}: {detail}" + ) from exc + + return LocalDbRestoreResult( + sql_file=staged_sql_file, + source=source_description, + host=host, + port=port, + user=user, + db_name=target_db, + ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 499be641..0662c540 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -15,9 +15,11 @@ # =============================================================================== from __future__ import annotations +import gzip import textwrap import uuid from pathlib import Path +from subprocess import CalledProcessError from types import SimpleNamespace from sqlalchemy import select @@ -159,6 +161,198 @@ def fake_associate(source_directory): assert captured["path"] == asset_dir +def test_restore_local_db_invokes_psql(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text( + "SET ROLE ocotillo;\n" + "ALTER TABLE public.sample OWNER TO ocotillo;\n" + "GRANT ALL ON TABLE public.sample TO ocotillo;\n" + "select 1;\n" + ) + captured: dict[str, object] = {} + call_order: list[str] = [] + + def fake_reset(): + call_order.append("reset") + + def fake_run(command, check, env, capture_output, text): + call_order.append("psql") + captured["command"] = command + captured["check"] = check + captured["env"] = env + captured["capture_output"] = capture_output + captured["text"] = text + captured["restored_sql"] = Path(command[-1]).read_text() + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", fake_reset) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_PORT", "5432") + monkeypatch.setenv("POSTGRES_USER", "nm_user") + monkeypatch.setenv("POSTGRES_PASSWORD", "secret") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 0, result.output + assert captured["command"][:-1] == [ + "psql", + "-v", + "ON_ERROR_STOP=1", + "-h", + "localhost", + "-p", + "5432", + "-U", + "nm_user", + "-d", + "ocotilloapi_dev", + "-f", + ] + assert captured["command"][-1].endswith("/restore.sql") + assert captured["check"] is True + assert captured["capture_output"] is True + assert captured["text"] is True + assert captured["env"]["PGPASSWORD"] == "secret" + assert captured["restored_sql"] == "select 1;\n" + assert call_order == ["reset", "psql"] + assert "Restored" in result.output + assert "ocotilloapi_dev" in result.output + + +def test_restore_local_db_rejects_non_sql_files(tmp_path): + source_file = tmp_path / "restore.dump" + source_file.write_text("not sql") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(source_file)]) + + assert result.exit_code == 1 + assert "requires a .sql or .sql.gz source" in result.output + + +def test_restore_local_db_rejects_remote_host(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + called = {"value": False} + + def fake_run(*args, **kwargs): + called["value"] = True + raise AssertionError("subprocess.run should not be called for remote hosts") + + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "db.example.com") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "only supports local PostgreSQL hosts" in result.output + assert called["value"] is False + + +def test_restore_local_db_reports_psql_failures(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + + def fake_run(command, check, env, capture_output, text): + raise CalledProcessError( + 1, + command, + stderr='psql: role "missing" does not exist', + ) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "Restore failed for database 'ocotilloapi_dev'" in result.output + assert 'role "missing" does not exist' in result.output + + +def test_restore_local_db_downloads_and_restores_gcs_gzip(monkeypatch, tmp_path): + source_uri = "gs://ocotillo/sql-exports/latest.sql.gz" + sql_text = ( + "SET SESSION AUTHORIZATION 'ocotillo';\n" + "REVOKE ALL ON SCHEMA public FROM ocotillo;\n" + "select 42;\n" + ) + gz_payload = gzip.compress(sql_text.encode("utf-8")) + captured: dict[str, object] = {} + + class FakeBlob: + def download_to_filename(self, filename): + Path(filename).write_bytes(gz_payload) + + class FakeBucket: + def __init__(self): + self.requested_blob_name = None + + def blob(self, blob_name): + self.requested_blob_name = blob_name + captured["blob_name"] = blob_name + return FakeBlob() + + fake_bucket = FakeBucket() + + def fake_get_storage_bucket(client=None, bucket=None): + captured["bucket_name"] = bucket + return fake_bucket + + def fake_run(command, check, env, capture_output, text): + captured["command"] = command + captured["restored_sql"] = Path(command[-1]).read_text() + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) + monkeypatch.setattr("cli.db_restore.get_storage_bucket", fake_get_storage_bucket) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", source_uri]) + + assert result.exit_code == 0, result.output + assert captured["bucket_name"] == "ocotillo" + assert captured["blob_name"] == "sql-exports/latest.sql.gz" + assert captured["restored_sql"] == "select 42;\n" + assert captured["command"][-2:] == ["-f", captured["command"][-1]] + assert source_uri in result.output + + +def test_restore_local_db_reports_schema_reset_failures(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + called = {"psql": False} + + def fake_reset(): + raise RuntimeError("permission denied to drop schema public") + + def fake_run(*args, **kwargs): + called["psql"] = True + raise AssertionError("psql should not be called when schema reset fails") + + monkeypatch.setattr("cli.db_restore._reset_target_schema", fake_reset) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "permission denied to drop schema public" in result.output + assert called["psql"] is False + + def test_well_inventory_csv_command_calls_service(monkeypatch, tmp_path): inventory_file = tmp_path / "inventory.csv" inventory_file.write_text("header\nvalue\n") @@ -338,10 +532,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/transfers/waterlevels_transducer_transfer.py b/transfers/waterlevels_transducer_transfer.py index 27c5255e..e89f8143 100644 --- a/transfers/waterlevels_transducer_transfer.py +++ b/transfers/waterlevels_transducer_transfer.py @@ -58,6 +58,7 @@ def __init__(self, *args, **kw): def _get_dfs(self): input_df = read_csv(self.source_table, parse_dates=["DateMeasured"]) + input_df = input_df[:1000] cleaned_df = filter_to_valid_point_ids(input_df) cleaned_df = cleaned_df.sort_values(by=["PointID"]) From 3898894f85071aa45e13d6ed692794dce612200d Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:14:54 +0000 Subject: [PATCH 046/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 0662c540..47f451ec 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -532,12 +532,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 6302d80ab1a1cd4c0eae3fcb38e0e270a04b2075 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 11 Mar 2026 15:00:30 -0600 Subject: [PATCH 047/163] test(features): enhance CSV reading to handle empty values and ensure unique well name suffixes in well inventory scenarios - Updated `pd.read_csv` calls with `keep_default_na=False` to retain empty values as-is. - Refined logic for suffix addition by excluding empty and `-xxxx` suffixed IDs. - Improved test isolation by maintaining scenario-specific unique identifiers. --- .../steps/well-inventory-csv-given.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index 01eb910e..bd6ff1ac 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -37,12 +37,14 @@ def _set_file_content_from_path(context: Context, path: Path, name: str | None = if path.suffix == ".csv" and path.exists() and path.stat().st_size > 0: suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] - df = pd.read_csv(path, dtype=str) + df = pd.read_csv(path, dtype=str, keep_default_na=False) if "well_name_point_id" in df.columns: df["well_name_point_id"] = df["well_name_point_id"].apply( lambda x: ( f"{x}_{suffix}" - if x and not str(x).endswith("-xxxx") and not str(x).strip() == "" + if x + and str(x).strip() != "" + and not str(x).lower().endswith("-xxxx") else x ) ) @@ -267,7 +269,11 @@ def step_given_my_csv_file_contains_a_row_missing_the_required_required( ): _set_file_content(context, "well-inventory-valid.csv") - df = pd.read_csv(context.file_path, dtype={"contact_2_address_1_postal_code": str}) + df = pd.read_csv( + context.file_path, + dtype={"contact_2_address_1_postal_code": str}, + keep_default_na=False, + ) df = df.drop(required_field, axis=1) buffer = StringIO() @@ -298,7 +304,11 @@ def step_step_step_16(context: Context): def _get_valid_df(context: Context) -> pd.DataFrame: _set_file_content(context, "well-inventory-valid.csv") - df = pd.read_csv(context.file_path, dtype={"contact_2_address_1_postal_code": str}) + df = pd.read_csv( + context.file_path, + dtype={"contact_2_address_1_postal_code": str}, + keep_default_na=False, + ) # Add unique suffix to well names to ensure isolation between scenarios # using a simple hash of the scenario name @@ -307,7 +317,11 @@ def _get_valid_df(context: Context) -> pd.DataFrame: suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] if "well_name_point_id" in df.columns: df["well_name_point_id"] = df["well_name_point_id"].apply( - lambda x: f"{x}_{suffix}" if x and not str(x).endswith("-xxxx") else x + lambda x: ( + f"{x}_{suffix}" + if x and str(x).strip() != "" and not str(x).lower().endswith("-xxxx") + else x + ) ) return df From 9742c030a0ebdb0dd87373afdecc7864b985d949 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 11 Mar 2026 15:19:11 -0600 Subject: [PATCH 048/163] fix(schemas): fix well inventory schema mismatch for `SampleMethod` and `DataQuality` - Changed `SampleMethodField` to validate against `SampleMethod` instead of `OriginType` - Changed `DataQualityField` to validate against `DataQuality` instead of `OriginType` --- schemas/well_inventory.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 8dafa5c2..ca542db4 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -32,6 +32,8 @@ OriginType, WellPumpType, MonitoringStatus, + SampleMethod, + DataQuality, ) from phonenumbers import NumberParseException from pydantic import ( @@ -181,10 +183,10 @@ def validator(v): BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), ] SampleMethodField: TypeAlias = Annotated[ - Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) + Optional[SampleMethod], BeforeValidator(flexible_lexicon_validator(SampleMethod)) ] DataQualityField: TypeAlias = Annotated[ - Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) + Optional[DataQuality], BeforeValidator(flexible_lexicon_validator(DataQuality)) ] PostalCodeField: TypeAlias = Annotated[ Optional[str], BeforeValidator(postal_code_or_none) From 86aa582fccc2f75dfd87f0fbafa91b932cad7d8b Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 12 Mar 2026 09:29:33 -0600 Subject: [PATCH 049/163] fix(contacts): allow nullable role and contact_type in well inventory import - Make contact.role and contact.contact_type nullable in the ORM and migrations - Update contact schemas and well inventory validation to accept missing values - Allow contact import when name or organization is present without role/type --- ...p9c1d2e3f4a5_make_contact_role_nullable.py | 29 +++++++++++++++ ...q0d1e2f3a4b5_make_contact_type_nullable.py | 35 +++++++++++++++++++ db/contact.py | 6 ++-- schemas/contact.py | 8 ++--- schemas/well_inventory.py | 29 +++++---------- services/well_inventory_csv.py | 17 ++++++--- tests/features/well-inventory-csv.feature | 18 +++++----- tests/test_well_inventory.py | 8 ++--- 8 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py create mode 100644 alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py diff --git a/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py b/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py new file mode 100644 index 00000000..fb53b64d --- /dev/null +++ b/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py @@ -0,0 +1,29 @@ +"""make contact role nullable + +Revision ID: p9c1d2e3f4a5 +Revises: o8b9c0d1e2f3 +Create Date: 2026-03-11 10:30:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "p9c1d2e3f4a5" +down_revision: Union[str, Sequence[str], None] = "o8b9c0d1e2f3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "contact", "role", existing_type=sa.String(length=100), nullable=True + ) + + +def downgrade() -> None: + op.alter_column( + "contact", "role", existing_type=sa.String(length=100), nullable=False + ) diff --git a/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py b/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py new file mode 100644 index 00000000..3923139e --- /dev/null +++ b/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py @@ -0,0 +1,35 @@ +"""make contact type nullable + +Revision ID: q0d1e2f3a4b5 +Revises: p9c1d2e3f4a5 +Create Date: 2026-03-11 17:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "q0d1e2f3a4b5" +down_revision: Union[str, Sequence[str], None] = "p9c1d2e3f4a5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "contact", + "contact_type", + existing_type=sa.String(length=100), + nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "contact", + "contact_type", + existing_type=sa.String(length=100), + nullable=False, + ) diff --git a/db/contact.py b/db/contact.py index 0fb59473..e30b5f57 100644 --- a/db/contact.py +++ b/db/contact.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import List, TYPE_CHECKING +from typing import List, TYPE_CHECKING, Optional from sqlalchemy import Integer, ForeignKey, String, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy @@ -49,8 +49,8 @@ class ThingContactAssociation(Base, AutoBaseMixin): class Contact(Base, AutoBaseMixin, ReleaseMixin, NotesMixin): name: Mapped[str] = mapped_column(String(100), nullable=True) organization: Mapped[str] = lexicon_term(nullable=True) - role: Mapped[str] = lexicon_term(nullable=False) - contact_type: Mapped[str] = lexicon_term(nullable=False) + role: Mapped[Optional[str]] = lexicon_term(nullable=True) + contact_type: Mapped[Optional[str]] = lexicon_term(nullable=True) # primary keys of the nm aquifer tables from which the contacts originate nma_pk_owners: Mapped[str] = mapped_column(String(100), nullable=True) diff --git a/schemas/contact.py b/schemas/contact.py index 590d6db8..29eaad45 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -150,8 +150,8 @@ class CreateContact(BaseCreateModel, ValidateContact): thing_id: int name: str | None = None organization: str | None = None - role: Role - contact_type: ContactType = "Primary" + role: Role | None = None + contact_type: ContactType | None = None nma_pk_owners: str | None = None # description: str | None = None # email: str | None = None @@ -218,8 +218,8 @@ class ContactResponse(BaseResponseModel): name: str | None organization: str | None - role: Role - contact_type: ContactType + role: Role | None + contact_type: ContactType | None incomplete_nma_phones: List[str] = [] emails: List[EmailResponse] = [] phones: List[PhoneResponse] = [] diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index ca542db4..504a6914 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -67,13 +67,6 @@ def owner_default(v): return v -def primary_default(v): - v = blank_to_none(v) - if v is None: - return "Primary" - return v - - US_POSTAL_REGEX = re.compile(r"^\d{5}(-\d{4})?$") @@ -150,7 +143,8 @@ def validator(v): Optional[PhoneType], BeforeValidator(flexible_lexicon_validator(PhoneType)) ] ContactTypeField: TypeAlias = Annotated[ - Optional[ContactType], BeforeValidator(flexible_lexicon_validator(ContactType)) + Optional[ContactType], + BeforeValidator(flexible_lexicon_validator(ContactType)), ] EmailTypeField: TypeAlias = Annotated[ Optional[EmailType], BeforeValidator(flexible_lexicon_validator(EmailType)) @@ -379,13 +373,15 @@ def validate_model(self): all_attrs = ("line_1", "line_2", "type", "state", "city", "postal_code") for jdx in (1, 2): key = f"contact_{jdx}" - # Check if any contact data is provided name = getattr(self, f"{key}_name") organization = getattr(self, f"{key}_organization") - # Check for OTHER contact fields (excluding name and organization) - has_other_contact_data = any( + # Treat name or organization as contact data too, so bare contacts + # still go through the same cross-field rules as fully populated ones. + has_contact_data = any( [ + name, + organization, getattr(self, f"{key}_role"), getattr(self, f"{key}_type"), *[getattr(self, f"{key}_email_{i}") for i in (1, 2)], @@ -398,20 +394,11 @@ def validate_model(self): ] ) - # If any contact data is provided, at least one of name or organization is required - if has_other_contact_data: + if has_contact_data: if not name and not organization: raise ValueError( f"At least one of {key}_name or {key}_organization must be provided" ) - if not getattr(self, f"{key}_role"): - raise ValueError( - f"{key}_role is required when contact fields are provided" - ) - if not getattr(self, f"{key}_type"): - raise ValueError( - f"{key}_type is required when contact fields are provided" - ) for idx in (1, 2): if any(getattr(self, f"{key}_address_{idx}_{a}") for a in all_attrs): if not all( diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 34217fa6..d1813788 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -324,7 +324,8 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: phones = [] addresses = [] name = getattr(model, f"contact_{idx}_name") - if name: + organization = getattr(model, f"contact_{idx}_organization") + if name or organization: for i in (1, 2): email = getattr(model, f"contact_{idx}_email_{i}") etype = getattr(model, f"contact_{idx}_email_{i}_type") @@ -356,9 +357,17 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: return { "thing_id": well.id, "name": name, - "organization": getattr(model, f"contact_{idx}_organization"), - "role": getattr(model, f"contact_{idx}_role"), - "contact_type": getattr(model, f"contact_{idx}_type"), + "organization": organization, + "role": ( + getattr(model, f"contact_{idx}_role").value + if hasattr(getattr(model, f"contact_{idx}_role"), "value") + else getattr(model, f"contact_{idx}_role") + ), + "contact_type": ( + getattr(model, f"contact_{idx}_type").value + if hasattr(getattr(model, f"contact_{idx}_type"), "value") + else getattr(model, f"contact_{idx}_type") + ), "emails": emails, "phones": phones, "addresses": addresses, diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index f52fc0c9..4b8d10ee 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -223,21 +223,19 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating the invalid email format And 1 well is imported - @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact without a contact_role + @positive @validation @BDMS-TBD + Scenario: Upload succeeds when a row has a contact without a contact_role Given my CSV file contains a row with a contact but is missing the required "contact_role" field for that contact When I run the well inventory bulk upload command - Then the command exits with a non-zero exit code - And the response includes a validation error indicating the missing "contact_role" field - And 1 well is imported + Then the command exits with code 0 + And all wells are imported - @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact without a "contact_type" + @positive @validation @BDMS-TBD + Scenario: Upload succeeds when a row has a contact without a "contact_type" Given my CSV file contains a row with a contact but is missing the required "contact_type" field for that contact When I run the well inventory bulk upload command - Then the command exits with a non-zero exit code - And the response includes a validation error indicating the missing "contact_type" value - And 1 well is imported + Then the command exits with code 0 + And all wells are imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid "contact_type" diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 7b0bb537..1db3f648 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -601,18 +601,18 @@ def test_upload_invalid_boolean_value(self): assert result.exit_code == 1 def test_upload_missing_contact_type(self): - """Upload fails when contact is provided without contact_type.""" + """Upload succeeds when contact is provided without contact_type.""" file_path = Path("tests/features/data/well-inventory-missing-contact-type.csv") if file_path.exists(): result = well_inventory_csv(file_path) - assert result.exit_code == 1 + assert result.exit_code == 0 def test_upload_missing_contact_role(self): - """Upload fails when contact is provided without role.""" + """Upload succeeds when contact is provided without role.""" file_path = Path("tests/features/data/well-inventory-missing-contact-role.csv") if file_path.exists(): result = well_inventory_csv(file_path) - assert result.exit_code == 1 + assert result.exit_code == 0 def test_upload_partial_water_level_fields(self): """Upload fails when only some water level fields are provided.""" From 3072e41778f8f6617a2fb43002e8d80daf5eedc9 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 12 Mar 2026 09:46:20 -0600 Subject: [PATCH 050/163] test(well-inventory): preserve structural CSV fixtures in BDD setup - Stop round-tripping CSV fixtures through pandas to avoid rewriting structural test cases - Preserve repeated header rows and duplicate column fixtures so importer validation is exercised correctly - Keep the blank contact name/organization scenario focused on a single invalid row for stable assertions --- .../steps/well-inventory-csv-given.py | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index bd6ff1ac..fd302e20 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -30,26 +30,40 @@ def _set_file_content(context: Context, name): def _set_file_content_from_path(context: Context, path: Path, name: str | None = None): context.file_path = path import hashlib - import pandas as pd - from io import StringIO context.file_name = name or path.name if path.suffix == ".csv" and path.exists() and path.stat().st_size > 0: suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] - df = pd.read_csv(path, dtype=str, keep_default_na=False) - if "well_name_point_id" in df.columns: - df["well_name_point_id"] = df["well_name_point_id"].apply( - lambda x: ( - f"{x}_{suffix}" - if x - and str(x).strip() != "" - and not str(x).lower().endswith("-xxxx") - else x - ) - ) + with open(path, "r", encoding="utf-8", newline="") as f: + rows = list(csv.reader(f)) + + if rows: + header = rows[0] + well_id_indexes = [ + idx + for idx, column_name in enumerate(header) + if column_name == "well_name_point_id" + ] + for row in rows[1:]: + # Preserve repeated header rows and duplicate-column fixtures so + # structural CSV scenarios still reach the importer unchanged. + if row == header: + continue + + for idx in well_id_indexes: + if idx >= len(row): + continue + value = row[idx] + if ( + value + and str(value).strip() != "" + and not str(value).lower().endswith("-xxxx") + ): + row[idx] = f"{value}_{suffix}" + buffer = StringIO() - df.to_csv(buffer, index=False) + csv.writer(buffer).writerows(rows) context.file_content = buffer.getvalue() context.rows = list(csv.DictReader(context.file_content.splitlines())) context.row_count = len(context.rows) @@ -463,13 +477,10 @@ def step_given_row_contains_invalid_well_pump_type_value(context: Context): ) def step_given_row_contains_contact_fields_but_name_and_org_are_blank(context: Context): df = _get_valid_df(context) - # df has 2 rows from well-inventory-valid.csv. - # We want to make SURE both rows are processed and the error is caught for row 1 (index 0). - # ensure rows are valid so row 0's error is the only one - df.loc[:, "contact_1_name"] = "Contact Name" - df.loc[:, "contact_1_organization"] = "Contact Org" + # Keep row 2 unchanged so row 1's invalid contact is the only expected error. df.loc[0, "contact_1_name"] = "" df.loc[0, "contact_1_organization"] = "" + # Keep other contact data present so composite contact validation is exercised. df.loc[0, "contact_1_role"] = "Owner" df.loc[0, "contact_1_type"] = "Primary" From dbe7074961034425687537f7f0648b8828a49a0b Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 12 Mar 2026 09:52:18 -0600 Subject: [PATCH 051/163] test(well-inventory): require distinct matches for expected validation errors - Prevent one actual validation error from satisfying multiple expected assertions (avoids false positives) - Keep validation matching order-independent while requiring distinct matches (preserves flexibility) - Tighten BDD error checks without relying on exact error text (improves test precision) --- .../well-inventory-csv-validation-error.py | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 8662e303..6714acb3 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -22,17 +22,27 @@ def _handle_validation_error(context, expected_errors): response_json = context.response.json() validation_errors = response_json.get("validation_errors", []) - for expected in expected_errors: - found = False - for actual in validation_errors: - field_match = str(expected.get("field", "")) in str(actual.get("field", "")) - error_match = str(expected.get("error", "")) in str(actual.get("error", "")) - if field_match and error_match: - found = True - break - assert ( - found - ), f"Expected validation error for field '{expected.get('field')}' with error containing '{expected.get('error')}' not found. Got: {validation_errors}" + def _matches(expected, actual): + field_match = str(expected.get("field", "")) in str(actual.get("field", "")) + error_match = str(expected.get("error", "")) in str(actual.get("error", "")) + return field_match and error_match + + def _find_match(expected_idx: int, used_indices: set[int]) -> bool: + if expected_idx == len(expected_errors): + return True + + expected = expected_errors[expected_idx] + for actual_idx, actual in enumerate(validation_errors): + if actual_idx in used_indices or not _matches(expected, actual): + continue + if _find_match(expected_idx + 1, used_indices | {actual_idx}): + return True + return False + + assert _find_match(0, set()), ( + f"Expected at least {len(expected_errors)} distinct validation error matches for " + f"{expected_errors}. Got: {validation_errors}" + ) def _assert_any_validation_error_contains( @@ -293,9 +303,6 @@ def step_then_response_includes_contact_name_or_org_required_error(context: Cont ) for err in validation_errors ) - if not found: - pass - # print(f"ACTUAL VALIDATION ERRORS: {validation_errors}") assert ( found From 76a450c3e2de0be9a64429c6bf1062f6ecbee28a Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 12 Mar 2026 10:00:02 -0600 Subject: [PATCH 052/163] test(well-inventory): align BDD expectations with best-effort import behavior - Update partial-success scenarios to expect valid rows to import alongside row-level validation errors - Reflect current importer behavior for invalid lexicon, invalid date, and repeated-header cases - Keep BDD coverage focused on user-visible import outcomes instead of outdated all-or-nothing assumptions --- tests/features/well-inventory-csv.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index 4b8d10ee..8a1b67ef 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -375,7 +375,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 3 wells are imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid date formats @@ -383,7 +383,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid numeric fields @@ -440,7 +440,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes a validation error indicating a repeated header row - And no wells are imported + And 3 wells are imported @negative @validation @header_row @BDMS-TBD Scenario: Upload fails when the header row contains duplicate column names From 10b0a3d95d99f14e9cfd100cf9f6e9aa18cbb494 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Thu, 12 Mar 2026 11:06:32 -0600 Subject: [PATCH 053/163] Update cli/db_restore.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/db_restore.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cli/db_restore.py b/cli/db_restore.py index 5f30b2e8..9de67da7 100644 --- a/cli/db_restore.py +++ b/cli/db_restore.py @@ -64,10 +64,16 @@ def _validate_restore_source_name(source_name: str) -> None: def _decompress_gzip_file(source_path: Path, target_path: Path) -> None: - with gzip.open(source_path, "rb") as compressed: - with open(target_path, "wb") as expanded: - while chunk := compressed.read(1024 * 1024): - expanded.write(chunk) + try: + with gzip.open(source_path, "rb") as compressed: + with open(target_path, "wb") as expanded: + while chunk := compressed.read(1024 * 1024): + expanded.write(chunk) + except (OSError, gzip.BadGzipFile) as exc: + raise LocalDbRestoreError( + f"Failed to decompress gzip source {source_path!r}: " + "file is not a valid gzip-compressed SQL dump or is corrupted." + ) from exc def _sanitize_sql_dump(source_path: Path, target_path: Path) -> None: From 6e54e0cc20bbc926d1c8644b61c8a66f84b5b5fa Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Thu, 12 Mar 2026 11:08:51 -0600 Subject: [PATCH 054/163] Update cli/db_restore.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/db_restore.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cli/db_restore.py b/cli/db_restore.py index 9de67da7..bd1200c0 100644 --- a/cli/db_restore.py +++ b/cli/db_restore.py @@ -77,12 +77,24 @@ def _decompress_gzip_file(source_path: Path, target_path: Path) -> None: def _sanitize_sql_dump(source_path: Path, target_path: Path) -> None: - with open(source_path, "r", encoding="utf-8") as infile: - with open(target_path, "w", encoding="utf-8") as outfile: - for line in infile: - if any(pattern.search(line) for pattern in ROLE_DEPENDENT_SQL_PATTERNS): - continue - outfile.write(line) + try: + with open(source_path, "r", encoding="utf-8") as infile: + with open(target_path, "w", encoding="utf-8") as outfile: + for line in infile: + if any( + pattern.search(line) for pattern in ROLE_DEPENDENT_SQL_PATTERNS + ): + continue + outfile.write(line) + except UnicodeError as exc: + raise LocalDbRestoreError( + f"Failed to read SQL dump {source_path} as UTF-8. " + "Ensure the dump file is UTF-8 encoded and not truncated." + ) from exc + except OSError as exc: + raise LocalDbRestoreError( + f"I/O error while processing SQL dump {source_path} -> {target_path}: {exc}" + ) from exc @contextmanager From 3024a430f922b4b9a8235edfe40d499009709630 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 12 Mar 2026 11:11:30 -0600 Subject: [PATCH 055/163] refactor: remove unused variable from LocalDbRestoreResult --- cli/db_restore.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/db_restore.py b/cli/db_restore.py index bd1200c0..c746a18e 100644 --- a/cli/db_restore.py +++ b/cli/db_restore.py @@ -235,7 +235,6 @@ def restore_local_db_from_sql( ) from exc return LocalDbRestoreResult( - sql_file=staged_sql_file, source=source_description, host=host, port=port, From b4be5f1a97a6cf73a4f680e45bf620c4922ffc2e Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 12 Mar 2026 11:13:20 -0600 Subject: [PATCH 056/163] refactor: remove input data limit from _get_dfs method --- transfers/waterlevels_transducer_transfer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/transfers/waterlevels_transducer_transfer.py b/transfers/waterlevels_transducer_transfer.py index e89f8143..27c5255e 100644 --- a/transfers/waterlevels_transducer_transfer.py +++ b/transfers/waterlevels_transducer_transfer.py @@ -58,7 +58,6 @@ def __init__(self, *args, **kw): def _get_dfs(self): input_df = read_csv(self.source_table, parse_dates=["DateMeasured"]) - input_df = input_df[:1000] cleaned_df = filter_to_valid_point_ids(input_df) cleaned_df = cleaned_df.sort_values(by=["PointID"]) From 024b5ceaf9507f8de4f77861221273fe721ca93b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:11:48 +0000 Subject: [PATCH 057/163] build(deps-dev): bump black from 26.1.0 to 26.3.1 (#598) Bumps [black](https://github.com/psf/black) from 26.1.0 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/26.1.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/uv.lock b/uv.lock index eb03c232..2747159a 100644 --- a/uv.lock +++ b/uv.lock @@ -341,7 +341,7 @@ wheels = [ [[package]] name = "black" -version = "26.1.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -351,19 +351,19 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] From ce742fd353f87fb6b93a1d6c5c527714f9726c1a Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 13 Mar 2026 09:55:47 -0600 Subject: [PATCH 058/163] test(well-inventory): align autogen placeholder tests with case-insensitive parsing - Update unit expectations to accept lowercase placeholder tokens that are now supported - Document normalization of mixed-case and spaced placeholder formats to uppercase prefixes - Keep test coverage aligned with importer behavior and reduce confusion around valid autogen inputs --- tests/test_well_inventory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 1db3f648..e58abb9d 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -856,10 +856,12 @@ def test_extract_autogen_prefix_pattern(self): assert _extract_autogen_prefix("XY-") == "XY-" assert _extract_autogen_prefix("AB-") == "AB-" - # New supported form (2-3 uppercase letter prefixes) + # Placeholder tokens are accepted case-insensitively and normalized. assert _extract_autogen_prefix("WL-XXXX") == "WL-" assert _extract_autogen_prefix("SAC-XXXX") == "SAC-" assert _extract_autogen_prefix("ABC -xxxx") == "ABC-" + assert _extract_autogen_prefix("wl-xxxx") == "WL-" + assert _extract_autogen_prefix("abc - XXXX") == "ABC-" # Blank values use default prefix assert _extract_autogen_prefix("") == "NM-" @@ -871,7 +873,6 @@ def test_extract_autogen_prefix_pattern(self): assert _extract_autogen_prefix("X-") is None assert _extract_autogen_prefix("123-") is None assert _extract_autogen_prefix("USER-XXXX") is None - assert _extract_autogen_prefix("wl-xxxx") is None def test_make_row_models_missing_well_name_point_id_column_errors(self): """Missing well_name_point_id column should fail validation (blank cell is separate).""" From ad86bf69f0cd117eb1efd83b050b5fd1b8f8db59 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 13 Mar 2026 10:07:55 -0600 Subject: [PATCH 059/163] test(well-inventory): update expected values for `SampleMethod` and `DataQuality` - Adjust test data to reflect updated descriptions for `sample_method` and `data_quality` fields. --- tests/test_well_inventory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index e58abb9d..6e24dc72 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -949,12 +949,12 @@ def test_water_level_aliases_are_mapped(self): row.update( { "measuring_person": "Tech 1", - "sample_method": "Tape", + "sample_method": "Steel-tape measurement", "water_level_date_time": "2025-02-15T10:30:00", "mp_height_ft": 2.5, "level_status": "Static", "depth_to_water_ft": 11.2, - "data_quality": "Good", + "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Initial reading", } ) From 55872b291114a0cce0d9bfa5d077170e0a2bdfc7 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 13 Mar 2026 10:32:46 -0600 Subject: [PATCH 060/163] test(well-inventory): expand contact tests for missing name and organization scenarios - Add test to ensure contact creation returns None when both name and organization are missing - Add test to verify contact creation with organization only, ensuring proper dict structure - Update assertions for comprehensive validation of contact fields --- tests/test_well_inventory.py | 77 ++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 6e24dc72..a4f0004e 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -168,6 +168,7 @@ def test_well_inventory_db_contents(): [ file_content["well_measuring_notes"], file_content["sampling_scenario_notes"], + f"Sample possible: {file_content['sample_possible']}", ] ) assert sorted(c.content for c in thing._get_notes("Historical")) == sorted( @@ -744,15 +745,38 @@ def test_make_contact_with_full_info(self): assert len(contact_dict["addresses"]) == 1 assert len(contact_dict["notes"]) == 2 - def test_make_contact_with_no_name(self): - """Test contact dict returns None when name is empty.""" + def test_make_contact_with_no_name_or_organization(self): + """Test contact dict returns None when name and organization are empty.""" from services.well_inventory_csv import _make_contact from unittest.mock import MagicMock model = MagicMock() model.result_communication_preference = None model.contact_special_requests_notes = None - model.contact_1_name = None # No name provided + model.contact_1_name = None + model.contact_1_organization = None + model.contact_1_role = None + model.contact_1_type = None + model.contact_1_email_1 = None + model.contact_1_email_1_type = None + model.contact_1_email_2 = None + model.contact_1_email_2_type = None + model.contact_1_phone_1 = None + model.contact_1_phone_1_type = None + model.contact_1_phone_2 = None + model.contact_1_phone_2_type = None + model.contact_1_address_1_line_1 = None + model.contact_1_address_1_line_2 = None + model.contact_1_address_1_city = None + model.contact_1_address_1_state = None + model.contact_1_address_1_postal_code = None + model.contact_1_address_1_type = None + model.contact_1_address_2_line_1 = None + model.contact_1_address_2_line_2 = None + model.contact_1_address_2_city = None + model.contact_1_address_2_state = None + model.contact_1_address_2_postal_code = None + model.contact_1_address_2_type = None well = MagicMock() well.id = 1 @@ -761,6 +785,53 @@ def test_make_contact_with_no_name(self): assert contact_dict is None + def test_make_contact_with_organization_only(self): + """Test contact dict creation when organization is present without a name.""" + from services.well_inventory_csv import _make_contact + from unittest.mock import MagicMock + + model = MagicMock() + model.result_communication_preference = None + model.contact_special_requests_notes = None + model.contact_1_name = None + model.contact_1_organization = "Test Org" + model.contact_1_role = None + model.contact_1_type = None + model.contact_1_email_1 = None + model.contact_1_email_1_type = None + model.contact_1_email_2 = None + model.contact_1_email_2_type = None + model.contact_1_phone_1 = None + model.contact_1_phone_1_type = None + model.contact_1_phone_2 = None + model.contact_1_phone_2_type = None + model.contact_1_address_1_line_1 = None + model.contact_1_address_1_line_2 = None + model.contact_1_address_1_city = None + model.contact_1_address_1_state = None + model.contact_1_address_1_postal_code = None + model.contact_1_address_1_type = None + model.contact_1_address_2_line_1 = None + model.contact_1_address_2_line_2 = None + model.contact_1_address_2_city = None + model.contact_1_address_2_state = None + model.contact_1_address_2_postal_code = None + model.contact_1_address_2_type = None + + well = MagicMock() + well.id = 1 + + contact_dict = _make_contact(model, well, 1) + + assert contact_dict is not None + assert contact_dict["name"] is None + assert contact_dict["organization"] == "Test Org" + assert contact_dict["thing_id"] == 1 + assert contact_dict["emails"] == [] + assert contact_dict["phones"] == [] + assert contact_dict["addresses"] == [] + assert contact_dict["notes"] == [] + def test_make_well_permission(self): """Test well permission creation.""" from services.well_inventory_csv import _make_well_permission From 517c36b1fb412588f8c504a93042000c8b058f10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 01:18:56 +0000 Subject: [PATCH 061/163] build(deps): bump pyjwt from 2.11.0 to 2.12.0 (#601) Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.11.0 to 2.12.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.12.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0cbf8cc1..aca10f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ dependencies = [ "pydantic==2.12.5", "pydantic-core==2.41.5", "pygments==2.19.2", - "pyjwt==2.11.0", + "pyjwt==2.12.0", "pygeoapi==0.22.0", "pyproj==3.7.2", "pyshp==2.3.1", diff --git a/requirements.txt b/requirements.txt index 24cd75ff..38cb5238 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1222,9 +1222,9 @@ pygments==2.19.2 \ # ocotilloapi # pytest # rich -pyjwt==2.11.0 \ - --hash=sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623 \ - --hash=sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469 +pyjwt==2.12.0 \ + --hash=sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02 \ + --hash=sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e # via ocotilloapi pyparsing==3.3.2 \ --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ diff --git a/uv.lock b/uv.lock index 2747159a..abcfb515 100644 --- a/uv.lock +++ b/uv.lock @@ -1569,7 +1569,7 @@ requires-dist = [ { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygeoapi", specifier = "==0.22.0" }, { name = "pygments", specifier = "==2.19.2" }, - { name = "pyjwt", specifier = "==2.11.0" }, + { name = "pyjwt", specifier = "==2.12.0" }, { name = "pyproj", specifier = "==3.7.2" }, { name = "pyshp", specifier = "==2.3.1" }, { name = "pytest", specifier = "==9.0.2" }, @@ -2190,11 +2190,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, ] [[package]] From 8c6c25ad68dca9f4fa9a99875c75f118e6e63061 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:06:35 +0000 Subject: [PATCH 062/163] build(deps): bump pillow from 11.3.0 to 12.1.1 Bumps [pillow](https://github.com/python-pillow/Pillow) from 11.3.0 to 12.1.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/11.3.0...12.1.1) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.1.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 141 +++++++++++++++++++++++++++++++---------------- uv.lock | 107 ++++++++++++++++++----------------- 3 files changed, 148 insertions(+), 102 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aca10f4d..a95c2eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "pandas-stubs~=2.3.2", "pg8000==1.31.5", "phonenumbers==9.0.24", - "pillow==11.3.0", + "pillow==12.1.1", "pluggy==1.6.0", "pre-commit==4.5.1", "propcache==0.4.1", diff --git a/requirements.txt b/requirements.txt index 38cb5238..269c3d02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -926,55 +926,98 @@ phonenumbers==9.0.24 \ --hash=sha256:97c38e4b5b8af992c75de01bd9c0f84e61701a9c900fd84f49744714910a4dc3 \ --hash=sha256:fa86ab7112ef8b286a811392311bd76bbbae7d1d271c2ed26cf73f2e9fa4d3c6 # via ocotilloapi -pillow==11.3.0 \ - --hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \ - --hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \ - --hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \ - --hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \ - --hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \ - --hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \ - --hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \ - --hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \ - --hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \ - --hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \ - --hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \ - --hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \ - --hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \ - --hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \ - --hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \ - --hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \ - --hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \ - --hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \ - --hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \ - --hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \ - --hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \ - --hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \ - --hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \ - --hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \ - --hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \ - --hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \ - --hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \ - --hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \ - --hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \ - --hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \ - --hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \ - --hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \ - --hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \ - --hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \ - --hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \ - --hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \ - --hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \ - --hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \ - --hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \ - --hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \ - --hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \ - --hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \ - --hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \ - --hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \ - --hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \ - --hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \ - --hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \ - --hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c +pillow==12.1.1 \ + --hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \ + --hash=sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da \ + --hash=sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f \ + --hash=sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642 \ + --hash=sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713 \ + --hash=sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850 \ + --hash=sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9 \ + --hash=sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0 \ + --hash=sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9 \ + --hash=sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8 \ + --hash=sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6 \ + --hash=sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd \ + --hash=sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5 \ + --hash=sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c \ + --hash=sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35 \ + --hash=sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1 \ + --hash=sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff \ + --hash=sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38 \ + --hash=sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4 \ + --hash=sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af \ + --hash=sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60 \ + --hash=sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986 \ + --hash=sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13 \ + --hash=sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717 \ + --hash=sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e \ + --hash=sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b \ + --hash=sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15 \ + --hash=sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a \ + --hash=sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb \ + --hash=sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d \ + --hash=sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b \ + --hash=sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e \ + --hash=sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a \ + --hash=sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f \ + --hash=sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a \ + --hash=sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce \ + --hash=sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc \ + --hash=sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f \ + --hash=sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586 \ + --hash=sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f \ + --hash=sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9 \ + --hash=sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8 \ + --hash=sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40 \ + --hash=sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60 \ + --hash=sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c \ + --hash=sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0 \ + --hash=sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334 \ + --hash=sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af \ + --hash=sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735 \ + --hash=sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524 \ + --hash=sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf \ + --hash=sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b \ + --hash=sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2 \ + --hash=sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9 \ + --hash=sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7 \ + --hash=sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e \ + --hash=sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4 \ + --hash=sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4 \ + --hash=sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b \ + --hash=sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397 \ + --hash=sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c \ + --hash=sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e \ + --hash=sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029 \ + --hash=sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3 \ + --hash=sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052 \ + --hash=sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984 \ + --hash=sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293 \ + --hash=sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523 \ + --hash=sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f \ + --hash=sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b \ + --hash=sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80 \ + --hash=sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f \ + --hash=sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79 \ + --hash=sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23 \ + --hash=sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8 \ + --hash=sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e \ + --hash=sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3 \ + --hash=sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e \ + --hash=sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36 \ + --hash=sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f \ + --hash=sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5 \ + --hash=sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f \ + --hash=sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6 \ + --hash=sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32 \ + --hash=sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20 \ + --hash=sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202 \ + --hash=sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0 \ + --hash=sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3 \ + --hash=sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563 \ + --hash=sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090 \ + --hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289 # via ocotilloapi platformdirs==4.3.8 \ --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ diff --git a/uv.lock b/uv.lock index abcfb515..30c04ef1 100644 --- a/uv.lock +++ b/uv.lock @@ -1555,7 +1555,7 @@ requires-dist = [ { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, { name = "phonenumbers", specifier = "==9.0.24" }, - { name = "pillow", specifier = "==11.3.0" }, + { name = "pillow", specifier = "==12.1.1" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "pre-commit", specifier = "==4.5.1" }, { name = "propcache", specifier = "==0.4.1" }, @@ -1757,57 +1757,60 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] [[package]] From c74bbc74297f9dc93e3434a9cff9d2ac5f7ac45d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:40:49 +0000 Subject: [PATCH 063/163] build(deps): bump astral-sh/setup-uv in the gha-minor-and-patch group (#603) Bumps the gha-minor-and-patch group with 1 update: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv). Updates `astral-sh/setup-uv` from 7.3.1 to 7.6.0 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v7.3.1...v7.6) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 7.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gha-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index cf7924ef..5e793928 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v7.6.0 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index ed73059d..7700065d 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v7.6.0 with: version: "latest" diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 7b885d5c..825c77ce 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -59,7 +59,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv (with cache) - uses: astral-sh/setup-uv@bd870193dd98cea382bc44a732c2e0d17379a16d # v4 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0cb02ced..79702dee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v7.6.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -155,7 +155,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v7.6.0 with: enable-cache: true cache-dependency-glob: uv.lock From 68d3843cf225e6c4b9d0899357ae847e3a32846e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:46:07 +0000 Subject: [PATCH 064/163] build(deps): bump authlib from 1.6.8 to 1.6.9 (#604) Bumps [authlib](https://github.com/authlib/authlib) from 1.6.8 to 1.6.9. - [Release notes](https://github.com/authlib/authlib/releases) - [Changelog](https://github.com/authlib/authlib/blob/main/docs/changelog.rst) - [Commits](https://github.com/authlib/authlib/compare/v1.6.8...v1.6.9) --- updated-dependencies: - dependency-name: authlib dependency-version: 1.6.9 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aca10f4d..9eae5aa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "asn1crypto==1.5.1", "asyncpg==0.31.0", "attrs==25.4.0", - "authlib==1.6.8", + "authlib==1.6.9", "bcrypt==4.3.0", "cachetools==5.5.2", "certifi==2025.8.3", diff --git a/requirements.txt b/requirements.txt index 38cb5238..1d5cafce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -155,9 +155,9 @@ attrs==25.4.0 \ # ocotilloapi # rasterio # referencing -authlib==1.6.8 \ - --hash=sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb \ - --hash=sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888 +authlib==1.6.9 \ + --hash=sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04 \ + --hash=sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3 # via ocotilloapi babel==2.17.0 \ --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ diff --git a/uv.lock b/uv.lock index abcfb515..08d84a7e 100644 --- a/uv.lock +++ b/uv.lock @@ -244,14 +244,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.8" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -1514,7 +1514,7 @@ requires-dist = [ { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "attrs", specifier = "==25.4.0" }, - { name = "authlib", specifier = "==1.6.8" }, + { name = "authlib", specifier = "==1.6.9" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "cachetools", specifier = "==5.5.2" }, { name = "certifi", specifier = "==2025.8.3" }, From 7143ed318db436ce29b39f735e602185529628ca Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 16 Mar 2026 11:54:44 -0600 Subject: [PATCH 065/163] refactor(enums): update `MonitoringStatus` to use `status_value` lexicon category --- core/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/enums.py b/core/enums.py index 43c16c2d..663f367e 100644 --- a/core/enums.py +++ b/core/enums.py @@ -48,7 +48,7 @@ ) LimitType: type[Enum] = build_enum_from_lexicon_category("limit_type") MeasurementMethod: type[Enum] = build_enum_from_lexicon_category("measurement_method") -MonitoringStatus: type[Enum] = build_enum_from_lexicon_category("monitoring_status") +MonitoringStatus: type[Enum] = build_enum_from_lexicon_category("status_value") ParameterName: type[Enum] = build_enum_from_lexicon_category("parameter_name") Organization: type[Enum] = build_enum_from_lexicon_category("organization") OriginType: type[Enum] = build_enum_from_lexicon_category("origin_type") From 8e583ea410a1d20ffb9f71b7fb392998d921fd0c Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 16 Mar 2026 12:07:26 -0600 Subject: [PATCH 066/163] refactor(well-inventory): update field mappings and naming conventions for Sample and Observation - Replace `name_point_id` with `name` in `sample_name` generation - Rename `observation_*` fields for consistency with updated schemas --- services/well_inventory_csv.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index d1813788..3deea4f4 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -775,7 +775,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) sample = Sample( field_activity_id=fa.id, sample_date=model.measurement_date_time, - sample_name=f"{well.name_point_id}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}", + sample_name=f"{well.name}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}", sample_matrix="groundwater", sample_method=sample_method, notes=model.water_level_notes, @@ -787,10 +787,10 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) observation = Observation( sample_id=sample.id, parameter_id=parameter.id, - observation_value=model.depth_to_water_ft, - observation_unit="ft", - observation_date=model.measurement_date_time, - data_quality=( + value=model.depth_to_water_ft, + unit="ft", + observation_datetime=model.measurement_date_time, + nma_data_quality=( model.data_quality.value if hasattr(model.data_quality, "value") else (model.data_quality or "Unknown") From a7bad5305da4a96477317f46f75093d1b72c4fcd Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 16 Mar 2026 12:07:35 -0600 Subject: [PATCH 067/163] fix(cli): handle UTF-8 BOM in CSV decoding for well inventory import - Adjust `content.decode` to use `utf-8-sig` for correct header parsing of UTF-8 files with BOM - Prevent encoding issues when processing imported files --- cli/service_adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/service_adapter.py b/cli/service_adapter.py index c9ae4560..0dd52d37 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -50,7 +50,8 @@ def well_inventory_csv(source_file: Path | str): payload = {"detail": "Empty file"} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) try: - text = content.decode("utf-8") + # Accept UTF-8 CSVs saved with a BOM so the first header is parsed correctly. + text = content.decode("utf-8-sig") except UnicodeDecodeError: payload = {"detail": "File encoding error"} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) From 6d2d81096c2fd1c2dd829e603895ed0c2e770432 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 16 Mar 2026 12:53:32 -0600 Subject: [PATCH 068/163] fix(well-inventory): preserve attempted water-level records when depth-to-water is blank - Treat blank depth_to_water_ft values as missing instead of invalid numeric input - Create water-level sample and observation records when water_level_date_time is present even if no depth value was obtained - Preserve attempted measurements for dry, obstructed, or otherwise unreadable wells without dropping the observation record --- schemas/well_inventory.py | 2 +- services/well_inventory_csv.py | 8 ++--- tests/test_well_inventory.py | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 504a6914..75d3edc3 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -326,7 +326,7 @@ class WellInventoryRow(BaseModel): validation_alias=AliasChoices("mp_height", "mp_height_ft"), ) level_status: Optional[str] = None - depth_to_water_ft: Optional[float] = None + depth_to_water_ft: OptionalFloat = None data_quality: DataQualityField = None water_level_notes: Optional[str] = None diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 3deea4f4..a2ca44f0 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -739,12 +739,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) ) session.add(fa) - if model.depth_to_water_ft is not None: - if model.measurement_date_time is None: - raise ValueError( - "water_level_date_time is required when depth_to_water_ft is provided" - ) - + if model.measurement_date_time is not None: # get groundwater level parameter parameter = ( session.query(Parameter) @@ -790,6 +785,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) value=model.depth_to_water_ft, unit="ft", observation_datetime=model.measurement_date_time, + measuring_point_height=model.mp_height, nma_data_quality=( model.data_quality.value if hasattr(model.data_quality, "value") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index a4f0004e..dd7ccdcc 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -19,6 +19,8 @@ Location, LocationThingAssociation, Thing, + Sample, + Observation, Contact, ThingContactAssociation, FieldEvent, @@ -455,6 +457,43 @@ def test_well_inventory_db_contents(): assert participant.participant.name == file_content["field_staff_2"] +def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): + """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height_ft": 2.5, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + samples = session.query(Sample).all() + observations = session.query(Observation).all() + + assert len(samples) == 1 + assert len(observations) == 1 + assert samples[0].sample_date == datetime.fromisoformat("2025-02-15T10:30:00") + assert observations[0].observation_datetime == datetime.fromisoformat( + "2025-02-15T10:30:00" + ) + assert observations[0].value is None + assert observations[0].measuring_point_height == 2.5 + + # ============================================================================= # Error Handling Tests - Cover API error paths # ============================================================================= @@ -1037,6 +1076,24 @@ def test_water_level_aliases_are_mapped(self): "2025-02-15T10:30:00" ) assert model.mp_height == 2.5 + assert model.depth_to_water_ft == 11.2 + assert model.water_level_notes == "Initial reading" + + def test_blank_depth_to_water_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "", + } + ) + + model = WellInventoryRow(**row) + + assert model.measurement_date_time == datetime.fromisoformat( + "2025-02-15T10:30:00" + ) + assert model.depth_to_water_ft is None def test_canonical_name_wins_when_alias_and_canonical_present(self): row = _minimal_valid_well_inventory_row() From 4f2b3cd1182cae757b6a3c1a57312436938803c2 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 17 Mar 2026 10:22:40 -0600 Subject: [PATCH 069/163] fix(well-inventory): improve error handling for database exceptions - Use detailed error messages from `DatabaseError` for better debugging --- services/well_inventory_csv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index a2ca44f0..e5ab09ea 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -224,7 +224,8 @@ class dialect: ) field = _extract_field_from_value_error(error_text) elif isinstance(e, DatabaseError): - error_text = "A database error occurred" + error_text = str(getattr(e, "orig", None) or e) + error_text = " ".join(error_text.split()) field = "Database error" else: error_text = str(e) From b2df9ab6fcd49db9dd2e08d99a6358ca5d1c89fb Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 17 Mar 2026 11:14:06 -0600 Subject: [PATCH 070/163] fix(well-inventory): normalize blank contact values and add missing organization terms - Treat blank contact organization and well status values as missing instead of persisting empty strings - Prevent foreign key failures caused by empty organization and status lexicon references during import - Add newly encountered organization terms to the lexicon so valid contact records can persist successfully --- core/lexicon.json | 105 +++++++++++++++++++++++++++++++++++ schemas/well_inventory.py | 17 +++--- tests/test_well_inventory.py | 18 ++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/core/lexicon.json b/core/lexicon.json index 2b786190..82942c48 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -4452,6 +4452,111 @@ "term": "Zamora Accounting Services", "definition": "Zamora Accounting Services" }, + { + "categories": [ + "organization" + ], + "term": "Agua Sana MWCD", + "definition": "Agua Sana MWCD" + }, + { + "categories": [ + "organization" + ], + "term": "Canada Los Alamos MDWCA", + "definition": "Canada Los Alamos MDWCA" + }, + { + "categories": [ + "organization" + ], + "term": "Canjilon Mutual Domestic Water System", + "definition": "Canjilon Mutual Domestic Water System" + }, + { + "categories": [ + "organization" + ], + "term": "Cebolla Mutual Domestic", + "definition": "Cebolla Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "Chihuahuan Desert Rangeland Research Center (CDRRC)", + "definition": "Chihuahuan Desert Rangeland Research Center (CDRRC)" + }, + { + "categories": [ + "organization" + ], + "term": "East Rio Arriba SWCD", + "definition": "East Rio Arriba SWCD" + }, + { + "categories": [ + "organization" + ], + "term": "El Prado Municipal Water", + "definition": "El Prado Municipal Water" + }, + { + "categories": [ + "organization" + ], + "term": "Hachita Mutual Domestic", + "definition": "Hachita Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "Jornada Experimental Range (JER)", + "definition": "Jornada Experimental Range (JER)" + }, + { + "categories": [ + "organization" + ], + "term": "La Canada Way HOA", + "definition": "La Canada Way HOA" + }, + { + "categories": [ + "organization" + ], + "term": "Los Ojos Mutual Domestic", + "definition": "Los Ojos Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "The Nature Conservancy (TNC)", + "definition": "The Nature Conservancy (TNC)" + }, + { + "categories": [ + "organization" + ], + "term": "Smith Ranch LLC", + "definition": "Smith Ranch LLC" + }, + { + "categories": [ + "organization" + ], + "term": "Zia Pueblo", + "definition": "Zia Pueblo" + }, + { + "categories": [ + "organization" + ], + "term": "Our Lady of Guadalupe (OLG)", + "definition": "Our Lady of Guadalupe (OLG)" + }, { "categories": [ "organization" diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 75d3edc3..49c1fbb7 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -190,6 +190,7 @@ def validator(v): EmailField: TypeAlias = Annotated[ Optional[str], BeforeValidator(email_validator_function) ] +OptionalText: TypeAlias = Annotated[Optional[str], BeforeValidator(empty_str_to_none)] OptionalBool: TypeAlias = Annotated[Optional[bool], BeforeValidator(empty_str_to_none)] OptionalPastOrTodayDateTime: TypeAlias = Annotated[ @@ -215,18 +216,18 @@ class WellInventoryRow(BaseModel): utm_zone: str # Optional fields - site_name: Optional[str] = None + site_name: OptionalText = None elevation_ft: OptionalFloat = None elevation_method: Annotated[ Optional[ElevationMethod], BeforeValidator(flexible_lexicon_validator(ElevationMethod)), ] = None measuring_point_height_ft: OptionalFloat = None - field_staff_2: Optional[str] = None - field_staff_3: Optional[str] = None + field_staff_2: OptionalText = None + field_staff_3: OptionalText = None - contact_1_name: Optional[str] = None - contact_1_organization: Optional[str] = None + contact_1_name: OptionalText = None + contact_1_organization: OptionalText = None contact_1_role: ContactRoleField = None contact_1_type: ContactTypeField = None contact_1_phone_1: PhoneField = None @@ -250,8 +251,8 @@ class WellInventoryRow(BaseModel): contact_1_address_2_city: Optional[str] = None contact_1_address_2_postal_code: PostalCodeField = None - contact_2_name: Optional[str] = None - contact_2_organization: Optional[str] = None + contact_2_name: OptionalText = None + contact_2_organization: OptionalText = None contact_2_role: ContactRoleField = None contact_2_type: ContactTypeField = None contact_2_phone_1: PhoneField = None @@ -296,7 +297,7 @@ class WellInventoryRow(BaseModel): measuring_point_description: Optional[str] = None well_purpose: WellPurposeField = None well_purpose_2: WellPurposeField = None - well_status: Optional[str] = Field( + well_status: OptionalText = Field( default=None, validation_alias=AliasChoices("well_status", "well_hole_status"), ) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index dd7ccdcc..d9d814d9 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1095,6 +1095,24 @@ def test_blank_depth_to_water_is_treated_as_none(self): ) assert model.depth_to_water_ft is None + def test_blank_contact_organization_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["contact_1_name"] = "Test Contact" + row["contact_1_organization"] = "" + + model = WellInventoryRow(**row) + + assert model.contact_1_name == "Test Contact" + assert model.contact_1_organization is None + + def test_blank_well_status_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "" + + model = WellInventoryRow(**row) + + assert model.well_status is None + def test_canonical_name_wins_when_alias_and_canonical_present(self): row = _minimal_valid_well_inventory_row() row["well_status"] = "Abandoned" From 27e06954875c47bf660a7ca75e53f6d0beae2930 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 17 Mar 2026 12:05:56 -0600 Subject: [PATCH 071/163] =?UTF-8?q?=E2=80=A2=20fix(well-inventory):=20make?= =?UTF-8?q?=20CSV=20import=20reruns=20idempotent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect previously imported well inventory rows before inserting related records - Skip recreating field activity water-level samples and observations when the same row is reprocessed - Return serializable existing-row results so CLI reruns report cleanly instead of crashing --- services/well_inventory_csv.py | 42 ++++++++++++++++++++++++++++++++++ tests/test_well_inventory.py | 32 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index e5ab09ea..9c62a620 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -438,6 +438,44 @@ def _generate_autogen_well_id(session, prefix: str, offset: int = 0) -> tuple[st return f"{prefix}{new_number:04d}", new_number +def _find_existing_imported_well( + session: Session, model: WellInventoryRow +) -> Thing | None: + if model.measurement_date_time is not None: + sample_name = ( + f"{model.well_name_point_id}-WL-" + f"{model.measurement_date_time.strftime('%Y%m%d%H%M')}" + ) + existing = session.scalars( + select(Thing) + .join(FieldEvent, FieldEvent.thing_id == Thing.id) + .join(FieldActivity, FieldActivity.field_event_id == FieldEvent.id) + .join(Sample, Sample.field_activity_id == FieldActivity.id) + .where( + Thing.name == model.well_name_point_id, + Thing.thing_type == "water well", + FieldActivity.activity_type == "well inventory", + Sample.sample_name == sample_name, + ) + .order_by(Thing.id.asc()) + ).first() + if existing is not None: + return existing + + return session.scalars( + select(Thing) + .join(FieldEvent, FieldEvent.thing_id == Thing.id) + .join(FieldActivity, FieldActivity.field_event_id == FieldEvent.id) + .where( + Thing.name == model.well_name_point_id, + Thing.thing_type == "water well", + FieldEvent.event_date == model.date_time, + FieldActivity.activity_type == "well inventory", + ) + .order_by(Thing.id.asc()) + ).first() + + def _make_row_models(rows, session): models = [] validation_errors = [] @@ -542,6 +580,10 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) name = model.well_name_point_id date_time = model.date_time + existing_well = _find_existing_imported_well(session, model) + if existing_well is not None: + return existing_well.name + # -------------------- # Location and associated tables # -------------------- diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index d9d814d9..b9dab138 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -494,6 +494,38 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): assert observations[0].measuring_point_height == 2.5 +def test_rerunning_same_well_inventory_csv_is_idempotent(): + """Re-importing the same CSV should not create duplicate well inventory records.""" + file = Path("tests/features/data/well-inventory-valid.csv") + assert file.exists(), "Test data file does not exist." + + first = well_inventory_csv(file) + assert first.exit_code == 0, first.stderr + + with session_ctx() as session: + counts_after_first = { + "things": session.query(Thing).count(), + "field_events": session.query(FieldEvent).count(), + "field_activities": session.query(FieldActivity).count(), + "samples": session.query(Sample).count(), + "observations": session.query(Observation).count(), + } + + second = well_inventory_csv(file) + assert second.exit_code == 0, second.stderr + + with session_ctx() as session: + counts_after_second = { + "things": session.query(Thing).count(), + "field_events": session.query(FieldEvent).count(), + "field_activities": session.query(FieldActivity).count(), + "samples": session.query(Sample).count(), + "observations": session.query(Observation).count(), + } + + assert counts_after_second == counts_after_first + + # ============================================================================= # Error Handling Tests - Cover API error paths # ============================================================================= From 7bf0032c9db3c9f9a40ef7c68c0c64ef3dbd51e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:21:31 +0000 Subject: [PATCH 072/163] build(deps): bump pyasn1 from 0.6.2 to 0.6.3 (#606) Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.2 to 0.6.3. - [Release notes](https://github.com/pyasn1/pyasn1/releases) - [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst) - [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.2...v0.6.3) --- updated-dependencies: - dependency-name: pyasn1 dependency-version: 0.6.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9eae5aa8..e2066ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "proto-plus==1.27.1", "protobuf==6.33.5", "psycopg2-binary>=2.9.10", - "pyasn1==0.6.2", + "pyasn1==0.6.3", "pyasn1-modules==0.4.2", "pycparser==2.23", "pydantic==2.12.5", diff --git a/requirements.txt b/requirements.txt index 1d5cafce..cc5dc268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1126,9 +1126,9 @@ psycopg2-binary==2.9.11 \ --hash=sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa \ --hash=sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747 # via ocotilloapi -pyasn1==0.6.2 \ - --hash=sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf \ - --hash=sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b +pyasn1==0.6.3 \ + --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ + --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde # via # ocotilloapi # pyasn1-modules diff --git a/uv.lock b/uv.lock index 08d84a7e..2e388162 100644 --- a/uv.lock +++ b/uv.lock @@ -1562,7 +1562,7 @@ requires-dist = [ { name = "proto-plus", specifier = "==1.27.1" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, - { name = "pyasn1", specifier = "==0.6.2" }, + { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, { name = "pycparser", specifier = "==2.23" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -2000,11 +2000,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] From 1e0fd843795240138f6858b1b10ff41c6175982d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 14:49:21 -0600 Subject: [PATCH 073/163] fix(test): encore ocotilloapi_test for bdd tests --- tests/features/environment.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/features/environment.py b/tests/features/environment.py index 9813c38f..5e1e32b9 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -17,6 +17,11 @@ import random from datetime import datetime, timedelta +# Lock test database before any db module imports +# Ensures BDD tests only use ocotilloapi_test, never ocotilloapi_dev +os.environ["POSTGRES_DB"] = "ocotilloapi_test" +os.environ["POSTGRES_PORT"] = "5432" + from alembic import command from alembic.config import Config from sqlalchemy import select From 3ad295a231abb2970276548c82bb3694d7bb178d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 14:49:56 -0600 Subject: [PATCH 074/163] feat(test): print exit_code when assert fails --- tests/features/steps/cli_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/cli_common.py b/tests/features/steps/cli_common.py index 1483db09..03b8077a 100644 --- a/tests/features/steps/cli_common.py +++ b/tests/features/steps/cli_common.py @@ -62,7 +62,7 @@ def step_impl_command_exit_zero(context): @then("the command exits with a non-zero exit code") def step_impl_command_exit_nonzero(context): - assert context.cli_result.exit_code != 0 + assert context.cli_result.exit_code != 0, context.cli_result.exit_code # ============= EOF ============================================= From e768d8aa36717b25ae959cd49fcede04514831d0 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 14:51:17 -0600 Subject: [PATCH 075/163] fix(contact): Make contact role and type non-nullable Each contact should have a role and contact_type --- ...p9c1d2e3f4a5_make_contact_role_nullable.py | 29 --------------- ...q0d1e2f3a4b5_make_contact_type_nullable.py | 35 ------------------- db/contact.py | 6 ++-- schemas/contact.py | 8 ++--- schemas/well_inventory.py | 10 ++++++ services/well_inventory_csv.py | 13 ++----- .../well-inventory-missing-contact-role.csv | 2 +- .../well-inventory-missing-contact-type.csv | 2 +- .../steps/well-inventory-csv-given.py | 1 + .../well-inventory-csv-validation-error.py | 15 +++++++- tests/features/well-inventory-csv.feature | 18 +++++----- tests/test_well_inventory.py | 8 ++--- 12 files changed, 50 insertions(+), 97 deletions(-) delete mode 100644 alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py delete mode 100644 alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py diff --git a/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py b/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py deleted file mode 100644 index fb53b64d..00000000 --- a/alembic/versions/p9c1d2e3f4a5_make_contact_role_nullable.py +++ /dev/null @@ -1,29 +0,0 @@ -"""make contact role nullable - -Revision ID: p9c1d2e3f4a5 -Revises: o8b9c0d1e2f3 -Create Date: 2026-03-11 10:30:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "p9c1d2e3f4a5" -down_revision: Union[str, Sequence[str], None] = "o8b9c0d1e2f3" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.alter_column( - "contact", "role", existing_type=sa.String(length=100), nullable=True - ) - - -def downgrade() -> None: - op.alter_column( - "contact", "role", existing_type=sa.String(length=100), nullable=False - ) diff --git a/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py b/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py deleted file mode 100644 index 3923139e..00000000 --- a/alembic/versions/q0d1e2f3a4b5_make_contact_type_nullable.py +++ /dev/null @@ -1,35 +0,0 @@ -"""make contact type nullable - -Revision ID: q0d1e2f3a4b5 -Revises: p9c1d2e3f4a5 -Create Date: 2026-03-11 17:10:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "q0d1e2f3a4b5" -down_revision: Union[str, Sequence[str], None] = "p9c1d2e3f4a5" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.alter_column( - "contact", - "contact_type", - existing_type=sa.String(length=100), - nullable=True, - ) - - -def downgrade() -> None: - op.alter_column( - "contact", - "contact_type", - existing_type=sa.String(length=100), - nullable=False, - ) diff --git a/db/contact.py b/db/contact.py index e30b5f57..0fb59473 100644 --- a/db/contact.py +++ b/db/contact.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import List, TYPE_CHECKING, Optional +from typing import List, TYPE_CHECKING from sqlalchemy import Integer, ForeignKey, String, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy @@ -49,8 +49,8 @@ class ThingContactAssociation(Base, AutoBaseMixin): class Contact(Base, AutoBaseMixin, ReleaseMixin, NotesMixin): name: Mapped[str] = mapped_column(String(100), nullable=True) organization: Mapped[str] = lexicon_term(nullable=True) - role: Mapped[Optional[str]] = lexicon_term(nullable=True) - contact_type: Mapped[Optional[str]] = lexicon_term(nullable=True) + role: Mapped[str] = lexicon_term(nullable=False) + contact_type: Mapped[str] = lexicon_term(nullable=False) # primary keys of the nm aquifer tables from which the contacts originate nma_pk_owners: Mapped[str] = mapped_column(String(100), nullable=True) diff --git a/schemas/contact.py b/schemas/contact.py index 29eaad45..d6fe28a0 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -150,8 +150,8 @@ class CreateContact(BaseCreateModel, ValidateContact): thing_id: int name: str | None = None organization: str | None = None - role: Role | None = None - contact_type: ContactType | None = None + role: Role + contact_type: ContactType nma_pk_owners: str | None = None # description: str | None = None # email: str | None = None @@ -218,8 +218,8 @@ class ContactResponse(BaseResponseModel): name: str | None organization: str | None - role: Role | None - contact_type: ContactType | None + role: Role + contact_type: ContactType incomplete_nma_phones: List[str] = [] emails: List[EmailResponse] = [] phones: List[PhoneResponse] = [] diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 75d3edc3..05544629 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -375,6 +375,8 @@ def validate_model(self): key = f"contact_{jdx}" name = getattr(self, f"{key}_name") organization = getattr(self, f"{key}_organization") + role = getattr(self, f"{key}_role") + contact_type = getattr(self, f"{key}_type") # Treat name or organization as contact data too, so bare contacts # still go through the same cross-field rules as fully populated ones. @@ -399,6 +401,14 @@ def validate_model(self): raise ValueError( f"At least one of {key}_name or {key}_organization must be provided" ) + if not role: + raise ValueError( + f"{key}_role is required when contact data is provided" + ) + if not contact_type: + raise ValueError( + f"{key}_type is required when contact data is provided" + ) for idx in (1, 2): if any(getattr(self, f"{key}_address_{idx}_{a}") for a in all_attrs): if not all( diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index a2ca44f0..2d7918c1 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -353,21 +353,12 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: "address_type": address_type, } ) - return { "thing_id": well.id, "name": name, "organization": organization, - "role": ( - getattr(model, f"contact_{idx}_role").value - if hasattr(getattr(model, f"contact_{idx}_role"), "value") - else getattr(model, f"contact_{idx}_role") - ), - "contact_type": ( - getattr(model, f"contact_{idx}_type").value - if hasattr(getattr(model, f"contact_{idx}_type"), "value") - else getattr(model, f"contact_{idx}_type") - ), + "role": getattr(model, f"contact_{idx}_role"), + "contact_type": getattr(model, f"contact_{idx}_type"), "emails": emails, "phones": phones, "addresses": addresses, diff --git a/tests/features/data/well-inventory-missing-contact-role.csv b/tests/features/data/well-inventory-missing-contact-role.csv index e5948aa9..a053650d 100644 --- a/tests/features/data/well-inventory-missing-contact-role.csv +++ b/tests/features/data/well-inventory-missing-contact-role.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith No Role,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,David Emily,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-contact-type.csv b/tests/features/data/well-inventory-missing-contact-type.csv index 6fd4cddc..d3b41faa 100644 --- a/tests/features/data/well-inventory-missing-contact-type.csv +++ b/tests/features/data/well-inventory-missing-contact-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith No Type,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index fd302e20..8d2c6d4e 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -29,6 +29,7 @@ def _set_file_content(context: Context, name): def _set_file_content_from_path(context: Context, path: Path, name: str | None = None): context.file_path = path + print(context.file_path) import hashlib context.file_name = name or path.name diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 6714acb3..543d9879 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -186,6 +186,19 @@ def step_then_the_response_includes_a_validation_error_indicating_the_invalid_em _handle_validation_error(context, expected_errors) +@then( + 'the response includes a validation error indicating the missing "contact_role" value' +) +def step_step_step_8(context): + expected_errors = [ + { + "field": "composite field error", + "error": "Value error, contact_1_role is required when contact data is provided", + } + ] + _handle_validation_error(context, expected_errors) + + @then( 'the response includes a validation error indicating the missing "contact_type" value' ) @@ -193,7 +206,7 @@ def step_step_step_8(context): expected_errors = [ { "field": "composite field error", - "error": "Value error, contact_1_type is required when contact fields are provided", + "error": "Value error, contact_1_type is required when contact data is provided", } ] _handle_validation_error(context, expected_errors) diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index 8a1b67ef..ee094ef2 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -223,19 +223,21 @@ Feature: Bulk upload well inventory from CSV via CLI And the response includes a validation error indicating the invalid email format And 1 well is imported - @positive @validation @BDMS-TBD - Scenario: Upload succeeds when a row has a contact without a contact_role + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact without a "contact_role" Given my CSV file contains a row with a contact but is missing the required "contact_role" field for that contact When I run the well inventory bulk upload command - Then the command exits with code 0 - And all wells are imported + Then the command exits with a non-zero exit code + And the response includes a validation error indicating the missing "contact_role" value + And 1 well is imported - @positive @validation @BDMS-TBD - Scenario: Upload succeeds when a row has a contact without a "contact_type" + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact without a "contact_type" Given my CSV file contains a row with a contact but is missing the required "contact_type" field for that contact When I run the well inventory bulk upload command - Then the command exits with code 0 - And all wells are imported + Then the command exits with a non-zero exit code + And the response includes a validation error indicating the missing "contact_type" value + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has a contact with an invalid "contact_type" diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index dd7ccdcc..9c13d734 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -641,18 +641,18 @@ def test_upload_invalid_boolean_value(self): assert result.exit_code == 1 def test_upload_missing_contact_type(self): - """Upload succeeds when contact is provided without contact_type.""" + """Upload fails when contact is provided without contact_type.""" file_path = Path("tests/features/data/well-inventory-missing-contact-type.csv") if file_path.exists(): result = well_inventory_csv(file_path) - assert result.exit_code == 0 + assert result.exit_code == 1 def test_upload_missing_contact_role(self): - """Upload succeeds when contact is provided without role.""" + """Upload fails when contact is provided without role.""" file_path = Path("tests/features/data/well-inventory-missing-contact-role.csv") if file_path.exists(): result = well_inventory_csv(file_path) - assert result.exit_code == 0 + assert result.exit_code == 1 def test_upload_partial_water_level_fields(self): """Upload fails when only some water level fields are provided.""" From a0ea88d8c355113d445f224d497be1caf18d1ef7 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 14:55:07 -0600 Subject: [PATCH 076/163] fix(test): remove print debugging statement --- tests/features/steps/well-inventory-csv-given.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index 8d2c6d4e..fd302e20 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -29,7 +29,6 @@ def _set_file_content(context: Context, name): def _set_file_content_from_path(context: Context, path: Path, name: str | None = None): context.file_path = path - print(context.file_path) import hashlib context.file_name = name or path.name From 0a306766b06490e34e154aec4601dad17418fc11 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 15:06:42 -0600 Subject: [PATCH 077/163] fix(well inventory): extract role/contact_type from enum --- services/well_inventory_csv.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 2d7918c1..1eb2bac2 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -357,8 +357,8 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: "thing_id": well.id, "name": name, "organization": organization, - "role": getattr(model, f"contact_{idx}_role"), - "contact_type": getattr(model, f"contact_{idx}_type"), + "role": getattr(model, f"contact_{idx}_role").value, + "contact_type": getattr(model, f"contact_{idx}_type").value, "emails": emails, "phones": phones, "addresses": addresses, From 0fada745287ef767646d69856417585ba7cb4cf0 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 17 Mar 2026 15:10:41 -0600 Subject: [PATCH 078/163] fix(test): ensure different step test names --- tests/features/steps/well-inventory-csv-validation-error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 543d9879..492af59c 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -202,7 +202,7 @@ def step_step_step_8(context): @then( 'the response includes a validation error indicating the missing "contact_type" value' ) -def step_step_step_8(context): +def step_step_step_9(context): expected_errors = [ { "field": "composite field error", From 965bcc755c3739c061edbc6a1c2e1635dc8cd99b Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Tue, 17 Mar 2026 16:57:32 -0600 Subject: [PATCH 079/163] test(well-inventory): align invalid well_hole_status scenario with detailed DB errors --- tests/features/steps/well-inventory-csv-validation-error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 6714acb3..903063d6 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -267,7 +267,7 @@ def step_then_response_includes_invalid_state_error(context: Context): ) def step_then_response_includes_invalid_well_hole_status_error(context: Context): _assert_any_validation_error_contains( - context, "Database error", "database error occurred" + context, "Database error", "status_history_status_value_fkey" ) From 1dfc24dce393d026e6b44bf63c88291f8981d774 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 10:48:32 -0600 Subject: [PATCH 080/163] feat(well inventory): add groundwater level field activity for well inventory import This commit adds a new field activity for groundwater level measurements during the well inventory import process if an optional water level is provided. The field activity is created with the type "groundwater level" and includes notes about the measurement. This enhancement allows for better tracking of groundwater level data associated with well inventory events. --- schemas/well_inventory.py | 7 +++- services/well_inventory_csv.py | 12 +++++- tests/test_well_inventory.py | 67 +++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 5bf54787..c3554060 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -34,6 +34,7 @@ MonitoringStatus, SampleMethod, DataQuality, + GroundwaterLevelReason, ) from phonenumbers import NumberParseException from pydantic import ( @@ -182,6 +183,10 @@ def validator(v): DataQualityField: TypeAlias = Annotated[ Optional[DataQuality], BeforeValidator(flexible_lexicon_validator(DataQuality)) ] +GroundwaterLevelReasonField: TypeAlias = Annotated[ + Optional[GroundwaterLevelReason], + BeforeValidator(flexible_lexicon_validator(GroundwaterLevelReason)), +] PostalCodeField: TypeAlias = Annotated[ Optional[str], BeforeValidator(postal_code_or_none) ] @@ -326,7 +331,7 @@ class WellInventoryRow(BaseModel): default=None, validation_alias=AliasChoices("mp_height", "mp_height_ft"), ) - level_status: Optional[str] = None + level_status: GroundwaterLevelReasonField = None depth_to_water_ft: OptionalFloat = None data_quality: DataQualityField = None water_level_notes: Optional[str] = None diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 94c348dd..0c567f3b 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -795,6 +795,15 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(parameter) session.flush() + # create FieldActivity + gwl_field_activity = FieldActivity( + field_event=fe, + activity_type="groundwater level", + notes="Groundwater level measurement activity conducted during well inventory field event.", + ) + session.add(gwl_field_activity) + session.flush() + # create Sample sample_method = ( model.sample_method.value @@ -802,7 +811,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) else (model.sample_method or "Unknown") ) sample = Sample( - field_activity_id=fa.id, + field_activity_id=gwl_field_activity.id, sample_date=model.measurement_date_time, sample_name=f"{well.name}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}", sample_matrix="groundwater", @@ -820,6 +829,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) unit="ft", observation_datetime=model.measurement_date_time, measuring_point_height=model.mp_height, + groundwater_level_reason=model.level_status, nma_data_quality=( model.data_quality.value if hasattr(model.data_quality, "value") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 8ba59be3..0fa4f305 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -67,7 +67,7 @@ def isolate_well_inventory_tables(): _reset_well_inventory_tables() -def test_well_inventory_db_contents(): +def test_well_inventory_db_contents_no_waterlevels(): """ Test that the well inventory upload creates the correct database contents. @@ -457,6 +457,71 @@ def test_well_inventory_db_contents(): assert participant.participant.name == file_content["field_staff_2"] +def test_well_inventory_db_contents_with_waterlevels(tmp_path): + """ + Tests that the following records are made: + + - field event + - field activity for well inventory + - field activity for water level measurement + - field participants + - contact + - location + - thing + - sample + - observation + + """ + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height_ft": 2.5, + "level_status": "Water level not affected", + } + ) + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + field_events = session.query(FieldEvent).all() + field_activities = session.query(FieldActivity).all() + field_event_participants = session.query(FieldEventParticipant).all() + contacts = session.query(Contact).all() + locations = session.query(Location).all() + things = session.query(Thing).all() + samples = session.query(Sample).all() + observations = session.query(Observation).all() + + assert len(field_events) == 1 + assert len(field_activities) == 2 + assert len(field_event_participants) == 1 + assert len(contacts) == 1 + assert len(locations) == 1 + assert len(things) == 1 + assert len(samples) == 1 + assert len(observations) == 1 + + session.query(FieldEvent).delete() + session.query(FieldActivity).delete() + session.query(FieldEventParticipant).delete() + session.query(Contact).delete() + session.query(Location).delete() + session.query(Thing).delete() + session.query(Sample).delete() + session.query(Observation).delete() + + def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" row = _minimal_valid_well_inventory_row() From fe9fc0ddccf514bc6cef16a4349e147516003bb1 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 11:04:51 -0600 Subject: [PATCH 081/163] fix(test): compare dt aware objects for optional water level tests --- tests/test_well_inventory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 0fa4f305..4d30eb32 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -551,9 +551,9 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): assert len(samples) == 1 assert len(observations) == 1 - assert samples[0].sample_date == datetime.fromisoformat("2025-02-15T10:30:00") + assert samples[0].sample_date == datetime.fromisoformat("2025-02-15T10:30:00Z") assert observations[0].observation_datetime == datetime.fromisoformat( - "2025-02-15T10:30:00" + "2025-02-15T10:30:00Z" ) assert observations[0].value is None assert observations[0].measuring_point_height == 2.5 From 0c9e8faeada23c4a5e2b7eb194d5fbbd18336351 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 11:11:34 -0600 Subject: [PATCH 082/163] fix(test): use enums when testing helper functions --- tests/test_well_inventory.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 4d30eb32..34bf3bee 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -14,6 +14,7 @@ import pytest from cli.service_adapter import well_inventory_csv from core.constants import SRID_UTM_ZONE_13N, SRID_WGS84 +from core.enums import Role, ContactType from db import ( Base, Location, @@ -844,8 +845,8 @@ def test_make_contact_with_full_info(self): model.contact_special_requests_notes = "Call before visiting" model.contact_1_name = "John Doe" model.contact_1_organization = "Test Org" - model.contact_1_role = "Owner" - model.contact_1_type = "Primary" + model.contact_1_role = Role.Owner + model.contact_1_type = ContactType.Primary model.contact_1_email_1 = "john@example.com" model.contact_1_email_1_type = "Work" model.contact_1_email_2 = None @@ -931,8 +932,8 @@ def test_make_contact_with_organization_only(self): model.contact_special_requests_notes = None model.contact_1_name = None model.contact_1_organization = "Test Org" - model.contact_1_role = None - model.contact_1_type = None + model.contact_1_role = Role.Owner + model.contact_1_type = ContactType.Primary model.contact_1_email_1 = None model.contact_1_email_1_type = None model.contact_1_email_2 = None From 815cfc62a94aaba194fec042dff402e6e357d590 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:04:17 -0600 Subject: [PATCH 083/163] fix(test): utilize autouse fixture to clean up tests --- tests/test_well_inventory.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 34bf3bee..91a87c41 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -513,15 +513,6 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): assert len(samples) == 1 assert len(observations) == 1 - session.query(FieldEvent).delete() - session.query(FieldActivity).delete() - session.query(FieldEventParticipant).delete() - session.query(Contact).delete() - session.query(Location).delete() - session.query(Thing).delete() - session.query(Sample).delete() - session.query(Observation).delete() - def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" From 3e9dcf39f228065a71beedfdda6467271cfe5720 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:05:58 -0600 Subject: [PATCH 084/163] fix(test): fix failing well inventory tests --- tests/test_well_inventory.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 91a87c41..f38456c9 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1151,7 +1151,7 @@ def test_water_level_aliases_are_mapped(self): "sample_method": "Steel-tape measurement", "water_level_date_time": "2025-02-15T10:30:00", "mp_height_ft": 2.5, - "level_status": "Static", + "level_status": "Other conditions exist that would affect the level (remarks)", "depth_to_water_ft": 11.2, "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Initial reading", @@ -1188,6 +1188,8 @@ def test_blank_contact_organization_is_treated_as_none(self): row = _minimal_valid_well_inventory_row() row["contact_1_name"] = "Test Contact" row["contact_1_organization"] = "" + row["contact_1_role"] = "Owner" + row["contact_1_type"] = "Primary" model = WellInventoryRow(**row) From d6e1dc4c56a3fb8fad6396a92b0b0af71e355acf Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:12:32 -0600 Subject: [PATCH 085/163] fix(well inventory): use correct activity type for water level records If a sample is recorded use the field activity with activity type "groundwater level" instead of "well inventory", otherwise use "well inventory" for the field activity. --- services/well_inventory_csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 0c567f3b..612adbeb 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -445,7 +445,7 @@ def _find_existing_imported_well( .where( Thing.name == model.well_name_point_id, Thing.thing_type == "water well", - FieldActivity.activity_type == "well inventory", + FieldActivity.activity_type == "groundwater level", Sample.sample_name == sample_name, ) .order_by(Thing.id.asc()) From b2bc17dfec99e3c17625b20865fde049126f9044 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:21:24 -0600 Subject: [PATCH 086/163] fix(well inventory): retrieve groundwater level reason enum value, else None This protects the field for when null value are submitted --- services/well_inventory_csv.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 612adbeb..049eddea 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -822,6 +822,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.flush() # create Observation + # TODO: groundwater_level_reason may be conditionally required for null depth_to_water_ft - handle accordingly observation = Observation( sample_id=sample.id, parameter_id=parameter.id, @@ -829,7 +830,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) unit="ft", observation_datetime=model.measurement_date_time, measuring_point_height=model.mp_height, - groundwater_level_reason=model.level_status, + groundwater_level_reason=( + model.level_status.value + if hasattr(model.level_status, "value") + else None + ), nma_data_quality=( model.data_quality.value if hasattr(model.data_quality, "value") From e899412b4f5d038f2c2fd69f11c767e932c8a4af Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:24:20 -0600 Subject: [PATCH 087/163] fix(test): ensure sample references correct field activity --- tests/test_well_inventory.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index f38456c9..16f8bf01 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -506,11 +506,19 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): assert len(field_events) == 1 assert len(field_activities) == 2 + gwl_field_activity = next( + (fa for fa in field_activities if fa.activity_type == "groundwater level"), + None, + ) + assert gwl_field_activity is not None + assert len(field_event_participants) == 1 assert len(contacts) == 1 assert len(locations) == 1 assert len(things) == 1 assert len(samples) == 1 + sample = samples[0] + assert sample.field_activity == gwl_field_activity assert len(observations) == 1 From 44c598ab822706436b3995c5ed05a82c9ccc159e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 12:26:31 -0600 Subject: [PATCH 088/163] feat(test): ensure more robust water level tests --- tests/test_well_inventory.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 16f8bf01..08d2573b 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -506,6 +506,11 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): assert len(field_events) == 1 assert len(field_activities) == 2 + activity_types = {fa.activity_type for fa in field_activities} + assert activity_types == { + "well inventory", + "groundwater level", + }, f"Unexpected activity types: {activity_types}" gwl_field_activity = next( (fa for fa in field_activities if fa.activity_type == "groundwater level"), None, @@ -520,6 +525,8 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): sample = samples[0] assert sample.field_activity == gwl_field_activity assert len(observations) == 1 + observation = observations[0] + assert observation.sample == sample def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): From fe7fba2558b1a4d17aa13c23ddda9d45fc799d11 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 14:16:41 -0600 Subject: [PATCH 089/163] feat(well inventory): require measuring_point_height_ft or mp_height_ft for non-null observations Either of these should be required when a non-null observation is being added to the DB so that dtw bgs can be calculated --- services/well_inventory_csv.py | 25 +++++- tests/test_well_inventory.py | 154 ++++++++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 049eddea..172ac4f2 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -681,6 +681,22 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) } ) + if ( + model.mp_height + and model.measuring_point_height_ft + and model.mp_height != model.measuring_point_height_ft + ): + raise ValueError( + "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" + ) + + if model.measuring_point_height_ft: + universal_mp_height = model.measuring_point_height_ft + elif model.mp_height: + universal_mp_height = model.mp_height + else: + universal_mp_height = None + data = CreateWell( location_id=loc.id, group_id=group.id, @@ -689,7 +705,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_depth=model.total_well_depth_ft, well_depth_source=model.depth_source, well_casing_diameter=model.casing_diameter_ft, - measuring_point_height=model.measuring_point_height_ft, + measuring_point_height=universal_mp_height, measuring_point_description=model.measuring_point_description, well_completion_date=model.date_drilled, well_completion_date_source=model.completion_source, @@ -821,6 +837,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(sample) session.flush() + if model.depth_to_water_ft and not universal_mp_height: + raise ValueError( + "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" + ) + # create Observation # TODO: groundwater_level_reason may be conditionally required for null depth_to_water_ft - handle accordingly observation = Observation( @@ -829,7 +850,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) value=model.depth_to_water_ft, unit="ft", observation_datetime=model.measurement_date_time, - measuring_point_height=model.mp_height, + measuring_point_height=universal_mp_height, groundwater_level_reason=( model.level_status.value if hasattr(model.level_status, "value") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 08d2573b..24493012 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -481,7 +481,7 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 2.5, + "mp_height_ft": 3.5, "level_status": "Water level not affected", } ) @@ -529,6 +529,154 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): assert observation.sample == sample +def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): + """When both mp_height and measuring_point_height_ft are provided, measuring_point_height_ft should be used for the thing's and observation's measuring_point_height.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": 3.5, + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height == 3.5 + assert len(observations) == 1 + assert observations[0].measuring_point_height == 3.5 + + +def test_mp_height_used_for_thing_and_observation_when_mp_height_ft_blank(tmp_path): + """When depth to water is provided but measuring_point_height_ft is blank, the mp_height value should be used for the thing's and observation's measuring_point_height.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": "", + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height": 4.0, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height == 4.0 + assert len(observations) == 1 + assert observations[0].measuring_point_height == 4.0 + + +def test_null_observation_allows_blank_mp_height(tmp_path): + """When depth to water is not provided, a blank measuring_point_height_ft should be allowed and result in a null measuring_point_height on the thing and observation and no associated measuring point height for the well.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": "", + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height is None + assert len(observations) == 1 + assert observations[0].measuring_point_height is None + + +def test_conflicting_mp_heights_raises_error(tmp_path): + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": 3.5, + "mp_height": 4.0, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 1, result.stderr + assert ( + result.payload["validation_errors"][0]["error"] + == "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" + ) + + +def test_no_mp_height_raises_error_when_depth_to_water_provided(tmp_path): + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "measuring_point_height_ft": "", + "mp_height": "", + "depth_to_water_ft": "8", + } + ) + + file_path = tmp_path / "well-inventory-no-mp-height.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 1, result.stderr + assert ( + result.payload["validation_errors"][0]["error"] + == "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" + ) + + def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" row = _minimal_valid_well_inventory_row() @@ -539,7 +687,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 2.5, + "mp_height_ft": 3.5, } ) @@ -563,7 +711,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "2025-02-15T10:30:00Z" ) assert observations[0].value is None - assert observations[0].measuring_point_height == 2.5 + assert observations[0].measuring_point_height == 3.5 def test_rerunning_same_well_inventory_csv_is_idempotent(): From 1763df28badc5e1043e7034d4e4f7db483b52ee1 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 14:49:15 -0600 Subject: [PATCH 090/163] fix(test): make test name more accurate --- tests/test_well_inventory.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 24493012..646e36af 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -562,7 +562,9 @@ def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): assert observations[0].measuring_point_height == 3.5 -def test_mp_height_used_for_thing_and_observation_when_mp_height_ft_blank(tmp_path): +def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft_blank( + tmp_path, +): """When depth to water is provided but measuring_point_height_ft is blank, the mp_height value should be used for the thing's and observation's measuring_point_height.""" row = _minimal_valid_well_inventory_row() row.update( From 4c0db46958cabd0d86fe0ae7c04abf45cd292650 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 14:50:59 -0600 Subject: [PATCH 091/163] fix(well inventory): test if mp height not None to avoid truthiness trap --- services/well_inventory_csv.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 172ac4f2..ed545157 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -682,17 +682,17 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) ) if ( - model.mp_height - and model.measuring_point_height_ft + model.mp_height is not None + and model.measuring_point_height_ft is not None and model.mp_height != model.measuring_point_height_ft ): raise ValueError( "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" ) - if model.measuring_point_height_ft: + if model.measuring_point_height_ft is not None: universal_mp_height = model.measuring_point_height_ft - elif model.mp_height: + elif model.mp_height is not None: universal_mp_height = model.mp_height else: universal_mp_height = None From aac5c9521d4f8d1d7eaf84a139cebd17d78cb56b Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 14:52:00 -0600 Subject: [PATCH 092/163] fix(well inventory): check for Nones to avoid truthiness traps --- services/well_inventory_csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index ed545157..89ece733 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -837,7 +837,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(sample) session.flush() - if model.depth_to_water_ft and not universal_mp_height: + if model.depth_to_water_ft is not None and universal_mp_height is None: raise ValueError( "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" ) From b6e5d800433de1d630e5c014e3766bf8b5b72c36 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 15:36:59 -0600 Subject: [PATCH 093/163] fix(test): make docstring more accurate --- tests/test_well_inventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 646e36af..b7f1fcab 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -530,7 +530,7 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): - """When both mp_height and measuring_point_height_ft are provided, measuring_point_height_ft should be used for the thing's and observation's measuring_point_height.""" + """When measuring_point_height_ft is provided it is used for the thing's and observation's measuring_point_height.""" row = _minimal_valid_well_inventory_row() row.update( { From 4a0f0daf8eabfd8785924f9742ffe2bff8c26cdc Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 15:39:53 -0600 Subject: [PATCH 094/163] fix(test): clarify docstrings --- tests/test_well_inventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index b7f1fcab..69edee32 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -530,7 +530,7 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): - """When measuring_point_height_ft is provided it is used for the thing's and observation's measuring_point_height.""" + """When measuring_point_height_ft is provided it is used for the thing's (MeasuringPointHistory) and observation's measuring_point_height values.""" row = _minimal_valid_well_inventory_row() row.update( { From 6df12f6e70f86286de01c244cdb20022cf0bd86a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 18 Mar 2026 15:54:56 -0600 Subject: [PATCH 095/163] fix(test): clarify docstrings --- tests/test_well_inventory.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 69edee32..fd27e395 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -565,7 +565,7 @@ def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft_blank( tmp_path, ): - """When depth to water is provided but measuring_point_height_ft is blank, the mp_height value should be used for the thing's and observation's measuring_point_height.""" + """When depth to water is provided and measuring_point_height_ft is blank the mp_height value should be used for the thing's (MeasuringPointHistory) and observation's measuring_point_height.""" row = _minimal_valid_well_inventory_row() row.update( { @@ -599,7 +599,7 @@ def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft def test_null_observation_allows_blank_mp_height(tmp_path): - """When depth to water is not provided, a blank measuring_point_height_ft should be allowed and result in a null measuring_point_height on the thing and observation and no associated measuring point height for the well.""" + """When depth to water is not provided (ie null), blank measuring_point_height_ft and mp_height fields should be allowed and result in a null measuring_point_height for the observation and no associated measuring point height (MeasuringPointHistory) for the well.""" row = _minimal_valid_well_inventory_row() row.update( { @@ -632,7 +632,11 @@ def test_null_observation_allows_blank_mp_height(tmp_path): def test_conflicting_mp_heights_raises_error(tmp_path): + """ + When both measuring_point_height_ft and mp_height are provided, an inequality (conflict) should raise an error. + """ row = _minimal_valid_well_inventory_row() + row.update( { "measuring_point_height_ft": 3.5, From c6ae9947b1b8104d995dbdfd9762a0ff0d13dd78 Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 19 Mar 2026 11:08:27 -0600 Subject: [PATCH 096/163] Optimize transducer observation pagination --- ...transducer_observation_deployment_index.py | 30 +++ db/transducer.py | 8 + services/observation_helper.py | 134 ++++++++++---- tests/test_observation.py | 172 +++++++++++++++++- 4 files changed, 311 insertions(+), 33 deletions(-) create mode 100644 alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py diff --git a/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py b/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py new file mode 100644 index 00000000..ea512a86 --- /dev/null +++ b/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py @@ -0,0 +1,30 @@ +"""Add transducer observation deployment lookup index. + +Revision ID: p9c0d1e2f3a4 +Revises: o8b9c0d1e2f3 +Create Date: 2026-03-19 11:05:00.000000 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "p9c0d1e2f3a4" +down_revision = "o8b9c0d1e2f3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_transducer_observation_deployment_parameter_datetime", + "transducer_observation", + ["deployment_id", "parameter_id", "observation_datetime"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_transducer_observation_deployment_parameter_datetime", + table_name="transducer_observation", + ) diff --git a/db/transducer.py b/db/transducer.py index ae9ac01d..1670bb9f 100644 --- a/db/transducer.py +++ b/db/transducer.py @@ -107,6 +107,14 @@ class TransducerObservation(Base, AutoBaseMixin, ReleaseMixin): """ __tablename__ = "transducer_observation" + __table_args__ = ( + Index( + "ix_transducer_observation_deployment_parameter_datetime", + "deployment_id", + "parameter_id", + "observation_datetime", + ), + ) parameter_id: Mapped[int] = mapped_column( ForeignKey("parameter.id", ondelete="CASCADE"), nullable=False, index=True diff --git a/services/observation_helper.py b/services/observation_helper.py index af24af05..c049d5b1 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -52,34 +52,25 @@ def get_transducer_observations( order: str | None = None, filter_: str = Query(alias="filter", default=None), ): + deployment_rows: list[tuple[int, int]] = [] + deployment_to_thing: dict[int, int] = {} + if thing_id: item = session.get(Thing, thing_id) if item is None: empty_query = select(TransducerObservation).where(False) return paginate(query=empty_query, conn=session) - - # Subquery to get latest block for each observation - block_subq = ( - select(TransducerObservationBlock.id) - .where( - TransducerObservationBlock.parameter_id - == TransducerObservation.parameter_id, - TransducerObservationBlock.start_datetime - <= TransducerObservation.observation_datetime, - TransducerObservationBlock.end_datetime - >= TransducerObservation.observation_datetime, - ) - .order_by(desc(TransducerObservationBlock.start_datetime)) - .limit(1) - .correlate(TransducerObservation) - .scalar_subquery() - ) - - query = ( - select(TransducerObservation, TransducerObservationBlock) - .join(Deployment, TransducerObservation.deployment_id == Deployment.id) - .join(TransducerObservationBlock, TransducerObservationBlock.id == block_subq) - ) + deployment_rows = session.execute( + select(Deployment.id, Deployment.thing_id).where( + Deployment.thing_id == thing_id + ) + ).all() + deployment_to_thing = { + deployment_id: deployment_thing_id + for deployment_id, deployment_thing_id in deployment_rows + } + + query = select(TransducerObservation) if start_time: query = query.where(TransducerObservation.observation_datetime >= start_time) @@ -89,23 +80,104 @@ def get_transducer_observations( if parameter_id: query = query.where(TransducerObservation.parameter_id == parameter_id) if thing_id: - query = query.where(Deployment.thing_id == thing_id) + deployment_ids = list(deployment_to_thing) + if not deployment_ids: + empty_query = select(TransducerObservation).where(False) + return paginate(query=empty_query, conn=session) + query = query.where(TransducerObservation.deployment_id.in_(deployment_ids)) - def transformer(result): + def transformer(observations): from schemas.transducer import ( TransducerObservationWithBlockResponse, TransducerObservationResponse, TransducerObservationBlockResponse, ) - return [ - TransducerObservationWithBlockResponse( - observation=TransducerObservationResponse.model_validate(observation), - block=TransducerObservationBlockResponse.model_validate(block), - ).model_dump() - for observation, block in result + if not observations: + return [] + + deployment_ids = {observation.deployment_id for observation in observations} + if not deployment_to_thing or not deployment_ids.issubset(deployment_to_thing): + deployment_rows = session.execute( + select(Deployment.id, Deployment.thing_id).where( + Deployment.id.in_(deployment_ids) + ) + ).all() + deployment_to_thing.update( + { + deployment_id: deployment_thing_id + for deployment_id, deployment_thing_id in deployment_rows + } + ) + + thing_ids = { + deployment_to_thing[observation.deployment_id] + for observation in observations + if observation.deployment_id in deployment_to_thing + } + parameter_ids = {observation.parameter_id for observation in observations} + observation_datetimes = [ + observation.observation_datetime for observation in observations ] + block_rows = session.scalars( + select(TransducerObservationBlock) + .where( + TransducerObservationBlock.thing_id.in_(thing_ids), + TransducerObservationBlock.parameter_id.in_(parameter_ids), + TransducerObservationBlock.start_datetime <= max(observation_datetimes), + TransducerObservationBlock.end_datetime >= min(observation_datetimes), + ) + .order_by( + TransducerObservationBlock.thing_id, + TransducerObservationBlock.parameter_id, + desc(TransducerObservationBlock.start_datetime), + ) + ).all() + + block_map: dict[tuple[int, int], list[TransducerObservationBlock]] = {} + for block in block_rows: + key = (block.thing_id, block.parameter_id) + if key not in block_map: + block_map[key] = [] + block_map[key].append(block) + + response_items = [] + for observation in observations: + thing_id_for_observation = deployment_to_thing.get( + observation.deployment_id + ) + if thing_id_for_observation is None: + continue + + matching_block = next( + ( + block + for block in block_map.get( + (thing_id_for_observation, observation.parameter_id), [] + ) + if block.start_datetime + <= observation.observation_datetime + <= block.end_datetime + ), + None, + ) + if matching_block is None: + continue + + response_items.append( + TransducerObservationWithBlockResponse( + observation=TransducerObservationResponse.model_validate( + observation + ), + block=TransducerObservationBlockResponse.model_validate( + matching_block + ), + ).model_dump() + ) + + return response_items + query = query.order_by(TransducerObservation.observation_datetime.desc()) return paginate(query=query, conn=session, transformer=transformer) diff --git a/tests/test_observation.py b/tests/test_observation.py index daad2678..386c823c 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -14,7 +14,8 @@ # limitations under the License. # =============================================================================== -from datetime import timezone +from datetime import datetime, timedelta, timezone +import uuid import pytest @@ -25,7 +26,18 @@ amp_editor_function, viewer_function, ) -from db import Observation, FieldEvent, FieldActivity, Sample +from db import ( + Deployment, + FieldActivity, + FieldEvent, + LocationThingAssociation, + Observation, + Sample, + Sensor, + Thing, + TransducerObservation, + TransducerObservationBlock, +) from db.engine import session_ctx from main import app from schemas import DT_FMT @@ -384,6 +396,162 @@ def test_get_groundwater_level_observations(groundwater_level_observation): ) +def test_get_transducer_groundwater_level_observations_uses_blocks_for_same_thing( + location, second_location, sensor +): + observation_time = datetime.now(timezone.utc) + matching_block_id = None + observation_id = None + other_block_id = None + target_deployment_id = None + other_deployment_id = None + other_sensor_id = None + other_thing_id = None + target_thing_id = None + + try: + with session_ctx() as session: + target_thing = Thing( + name="Transducer Target Well", + first_visit_date="2023-03-03", + thing_type="water well", + release_status="draft", + well_depth=10, + hole_depth=10, + well_casing_diameter=5.0, + well_casing_depth=10.0, + ) + other_thing = Thing( + name="Transducer Other Well", + first_visit_date="2023-03-04", + thing_type="water well", + release_status="draft", + well_depth=10, + hole_depth=10, + well_casing_diameter=5.0, + well_casing_depth=10.0, + ) + session.add_all([target_thing, other_thing]) + session.flush() + + session.add_all( + [ + LocationThingAssociation( + location_id=location.id, + thing_id=target_thing.id, + effective_start="2025-02-01T00:00:00Z", + ), + LocationThingAssociation( + location_id=second_location.id, + thing_id=other_thing.id, + effective_start="2025-02-01T00:00:00Z", + ), + ] + ) + + other_sensor = Sensor( + name=f"Transducer Other Sensor {uuid.uuid4()}", + sensor_type="Pressure Transducer", + model="Model X", + serial_no=f"serial-{uuid.uuid4()}", + pcn_number=f"pcn-{uuid.uuid4()}", + owner_agency="NMBGMR", + sensor_status="In Service", + notes="other sensor", + release_status="draft", + ) + session.add(other_sensor) + session.flush() + + target_deployment = Deployment( + sensor_id=sensor.id, + thing_id=target_thing.id, + installation_date="2023-01-01", + recording_interval=24, + recording_interval_units="hour", + hanging_cable_length=10, + hanging_point_height=0, + hanging_point_description="target deployment", + notes="target deployment", + ) + other_deployment = Deployment( + sensor_id=other_sensor.id, + thing_id=other_thing.id, + installation_date="2023-01-01", + recording_interval=24, + recording_interval_units="hour", + hanging_cable_length=10, + hanging_point_height=0, + hanging_point_description="other deployment", + notes="other deployment", + ) + session.add_all([target_deployment, other_deployment]) + session.flush() + + target_block = TransducerObservationBlock( + thing_id=target_thing.id, + parameter_id=_groundwater_level_parameter_id(), + start_datetime=observation_time - timedelta(days=10), + end_datetime=observation_time + timedelta(days=10), + review_status="not reviewed", + ) + other_block = TransducerObservationBlock( + thing_id=other_thing.id, + parameter_id=_groundwater_level_parameter_id(), + start_datetime=observation_time - timedelta(days=1), + end_datetime=observation_time + timedelta(days=1), + review_status="not reviewed", + ) + session.add_all([target_block, other_block]) + session.flush() + + observation = TransducerObservation( + parameter_id=_groundwater_level_parameter_id(), + deployment_id=target_deployment.id, + observation_datetime=observation_time, + value=12.34, + ) + session.add(observation) + session.commit() + + matching_block_id = target_block.id + observation_id = observation.id + other_block_id = other_block.id + target_deployment_id = target_deployment.id + other_deployment_id = other_deployment.id + other_sensor_id = other_sensor.id + target_thing_id = target_thing.id + other_thing_id = other_thing.id + + response = client.get( + f"/observation/transducer-groundwater-level?thing_id={target_thing_id}" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["block"]["id"] == matching_block_id + assert data["items"][0]["block"]["id"] != other_block_id + finally: + with session_ctx() as session: + for model, pk in ( + (TransducerObservation, observation_id), + (TransducerObservationBlock, matching_block_id), + (TransducerObservationBlock, other_block_id), + (Deployment, target_deployment_id), + (Deployment, other_deployment_id), + (Sensor, other_sensor_id), + (Thing, target_thing_id), + (Thing, other_thing_id), + ): + if pk is None: + continue + instance = session.get(model, pk) + if instance is not None: + session.delete(instance) + session.commit() + + def test_get_groundwater_level_observation_by_id(groundwater_level_observation): response = client.get( f"/observation/groundwater-level/{groundwater_level_observation.id}" From bf7d3abc6df962ba60b7c138947f765b2621b9eb Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 19 Mar 2026 11:10:46 -0600 Subject: [PATCH 097/163] Set Google credentials path in docker compose --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5331fe3d..fcbd09f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: - PYGEOAPI_POSTGRES_DB=ocotilloapi_dev - PYGEOAPI_POSTGRES_USER=${POSTGRES_USER} - PYGEOAPI_POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - GOOGLE_APPLICATION_CREDENTIALS=/app/gcs_credentials.json ports: - 8000:8000 depends_on: From 5488e858e3a6989ef2e5d75dcb1efe078dfc68df Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 19 Mar 2026 12:28:54 -0600 Subject: [PATCH 098/163] Fix restore local db result payload --- cli/db_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/db_restore.py b/cli/db_restore.py index c746a18e..bd1200c0 100644 --- a/cli/db_restore.py +++ b/cli/db_restore.py @@ -235,6 +235,7 @@ def restore_local_db_from_sql( ) from exc return LocalDbRestoreResult( + sql_file=staged_sql_file, source=source_description, host=host, port=port, From dda88c8f4ed2be3d56e8d9138d337c97ff70a8dc Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 13:17:40 -0600 Subject: [PATCH 099/163] Revert "Merge pull request #610 from DataIntegrationGroup/jab-bdms-626-mp-height" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - This revert removes the conflict check between mp_height and measuring_point_height_ft - Removes the requirement that one of those heights be present for non-null DTW observations - Removes the importer’s use of the merged universal_mp_height logic - Removes all of the new mp-height-specific tests - This reverts commit 6fe2bc1694ae3c7b1a66335a17a7db48d6f42ff3, reversing changes made to 6fb61cfca8fb074226536dec80481c2f75c335a1. --- services/well_inventory_csv.py | 25 +----- tests/test_well_inventory.py | 160 +-------------------------------- 2 files changed, 5 insertions(+), 180 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 89ece733..049eddea 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -681,22 +681,6 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) } ) - if ( - model.mp_height is not None - and model.measuring_point_height_ft is not None - and model.mp_height != model.measuring_point_height_ft - ): - raise ValueError( - "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" - ) - - if model.measuring_point_height_ft is not None: - universal_mp_height = model.measuring_point_height_ft - elif model.mp_height is not None: - universal_mp_height = model.mp_height - else: - universal_mp_height = None - data = CreateWell( location_id=loc.id, group_id=group.id, @@ -705,7 +689,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_depth=model.total_well_depth_ft, well_depth_source=model.depth_source, well_casing_diameter=model.casing_diameter_ft, - measuring_point_height=universal_mp_height, + measuring_point_height=model.measuring_point_height_ft, measuring_point_description=model.measuring_point_description, well_completion_date=model.date_drilled, well_completion_date_source=model.completion_source, @@ -837,11 +821,6 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(sample) session.flush() - if model.depth_to_water_ft is not None and universal_mp_height is None: - raise ValueError( - "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" - ) - # create Observation # TODO: groundwater_level_reason may be conditionally required for null depth_to_water_ft - handle accordingly observation = Observation( @@ -850,7 +829,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) value=model.depth_to_water_ft, unit="ft", observation_datetime=model.measurement_date_time, - measuring_point_height=universal_mp_height, + measuring_point_height=model.mp_height, groundwater_level_reason=( model.level_status.value if hasattr(model.level_status, "value") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index fd27e395..08d2573b 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -481,7 +481,7 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 3.5, + "mp_height_ft": 2.5, "level_status": "Water level not affected", } ) @@ -529,160 +529,6 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): assert observation.sample == sample -def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): - """When measuring_point_height_ft is provided it is used for the thing's (MeasuringPointHistory) and observation's measuring_point_height values.""" - row = _minimal_valid_well_inventory_row() - row.update( - { - "measuring_point_height_ft": 3.5, - "water_level_date_time": "2025-02-15T10:30:00", - "depth_to_water_ft": "8", - "sample_method": "Steel-tape measurement", - "data_quality": "Water level accurate to within two hundreths of a foot", - "water_level_notes": "Attempted measurement", - } - ) - - file_path = tmp_path / "well-inventory-blank-depth.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 0, result.stderr - - with session_ctx() as session: - things = session.query(Thing).all() - observations = session.query(Observation).all() - - assert len(things) == 1 - assert things[0].measuring_point_height == 3.5 - assert len(observations) == 1 - assert observations[0].measuring_point_height == 3.5 - - -def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft_blank( - tmp_path, -): - """When depth to water is provided and measuring_point_height_ft is blank the mp_height value should be used for the thing's (MeasuringPointHistory) and observation's measuring_point_height.""" - row = _minimal_valid_well_inventory_row() - row.update( - { - "measuring_point_height_ft": "", - "water_level_date_time": "2025-02-15T10:30:00", - "depth_to_water_ft": "8", - "sample_method": "Steel-tape measurement", - "data_quality": "Water level accurate to within two hundreths of a foot", - "water_level_notes": "Attempted measurement", - "mp_height": 4.0, - } - ) - - file_path = tmp_path / "well-inventory-blank-depth.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 0, result.stderr - - with session_ctx() as session: - things = session.query(Thing).all() - observations = session.query(Observation).all() - - assert len(things) == 1 - assert things[0].measuring_point_height == 4.0 - assert len(observations) == 1 - assert observations[0].measuring_point_height == 4.0 - - -def test_null_observation_allows_blank_mp_height(tmp_path): - """When depth to water is not provided (ie null), blank measuring_point_height_ft and mp_height fields should be allowed and result in a null measuring_point_height for the observation and no associated measuring point height (MeasuringPointHistory) for the well.""" - row = _minimal_valid_well_inventory_row() - row.update( - { - "measuring_point_height_ft": "", - "water_level_date_time": "2025-02-15T10:30:00", - "depth_to_water_ft": "", - "sample_method": "Steel-tape measurement", - "data_quality": "Water level accurate to within two hundreths of a foot", - "water_level_notes": "Attempted measurement", - } - ) - - file_path = tmp_path / "well-inventory-blank-depth.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 0, result.stderr - - with session_ctx() as session: - things = session.query(Thing).all() - observations = session.query(Observation).all() - - assert len(things) == 1 - assert things[0].measuring_point_height is None - assert len(observations) == 1 - assert observations[0].measuring_point_height is None - - -def test_conflicting_mp_heights_raises_error(tmp_path): - """ - When both measuring_point_height_ft and mp_height are provided, an inequality (conflict) should raise an error. - """ - row = _minimal_valid_well_inventory_row() - - row.update( - { - "measuring_point_height_ft": 3.5, - "mp_height": 4.0, - } - ) - - file_path = tmp_path / "well-inventory-blank-depth.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 1, result.stderr - assert ( - result.payload["validation_errors"][0]["error"] - == "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" - ) - - -def test_no_mp_height_raises_error_when_depth_to_water_provided(tmp_path): - row = _minimal_valid_well_inventory_row() - row.update( - { - "water_level_date_time": "2025-02-15T10:30:00", - "measuring_point_height_ft": "", - "mp_height": "", - "depth_to_water_ft": "8", - } - ) - - file_path = tmp_path / "well-inventory-no-mp-height.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 1, result.stderr - assert ( - result.payload["validation_errors"][0]["error"] - == "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" - ) - - def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" row = _minimal_valid_well_inventory_row() @@ -693,7 +539,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 3.5, + "mp_height_ft": 2.5, } ) @@ -717,7 +563,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "2025-02-15T10:30:00Z" ) assert observations[0].value is None - assert observations[0].measuring_point_height == 3.5 + assert observations[0].measuring_point_height == 2.5 def test_rerunning_same_well_inventory_csv_is_idempotent(): From b80bd32d5084f8c138db92af71a668f005c659cd Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 13:47:49 -0600 Subject: [PATCH 100/163] fix(cli): include staged sql path in local db restore result - Pass sql_file when constructing LocalDbRestoreResult - Restore restore-local-db command success path after result dataclass change - Fix CLI tests covering local SQL and GCS gzip restore flows --- cli/db_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/db_restore.py b/cli/db_restore.py index c746a18e..bd1200c0 100644 --- a/cli/db_restore.py +++ b/cli/db_restore.py @@ -235,6 +235,7 @@ def restore_local_db_from_sql( ) from exc return LocalDbRestoreResult( + sql_file=staged_sql_file, source=source_description, host=host, port=port, From 2932721a68b91a8f68daa7c3dfbcd40c1dd310b3 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 15:54:57 -0600 Subject: [PATCH 101/163] fix(well-inventory): validate invalid well_hole_status before persistence - Promote well_status to lexicon-backed validation with well_hole_status alias support - Prevent invalid well_hole_status values from surfacing as DB constraint errors - Align BDD fixtures and assertions with stable user-facing validation behavior --- schemas/well_inventory.py | 6 +++++- services/well_inventory_csv.py | 6 +++++- tests/features/steps/well-inventory-csv-given.py | 4 +++- .../steps/well-inventory-csv-validation-error.py | 12 ++++++++++-- tests/test_well_inventory.py | 11 +++++++++-- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index c3554060..713ab141 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -177,6 +177,10 @@ def validator(v): Optional[MonitoringStatus], BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), ] +WellStatusField: TypeAlias = Annotated[ + Optional[MonitoringStatus], + BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), +] SampleMethodField: TypeAlias = Annotated[ Optional[SampleMethod], BeforeValidator(flexible_lexicon_validator(SampleMethod)) ] @@ -302,7 +306,7 @@ class WellInventoryRow(BaseModel): measuring_point_description: Optional[str] = None well_purpose: WellPurposeField = None well_purpose_2: WellPurposeField = None - well_status: OptionalText = Field( + well_status: WellStatusField = Field( default=None, validation_alias=AliasChoices("well_status", "well_hole_status"), ) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 049eddea..c740d6f5 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -697,7 +697,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_pump_depth=model.well_pump_depth_ft, is_suitable_for_datalogger=model.datalogger_possible, is_open=model.is_open, - well_status=model.well_status, + well_status=( + model.well_status.value + if hasattr(model.well_status, "value") + else model.well_status + ), monitoring_status=( model.monitoring_status.value if hasattr(model.monitoring_status, "value") diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index fd302e20..aeee0232 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -448,7 +448,9 @@ def step_given_row_contains_invalid_state_value(context: Context): ) def step_given_row_contains_invalid_well_hole_status_value(context: Context): df = _get_valid_df(context) - if "well_status" in df.columns: + if "well_hole_status" in df.columns: + df.loc[0, "well_hole_status"] = "NotARealWellHoleStatus" + elif "well_status" in df.columns: df.loc[0, "well_status"] = "NotARealWellHoleStatus" _set_content_from_df(context, df) diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index a711b732..0c390009 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -279,9 +279,17 @@ def step_then_response_includes_invalid_state_error(context: Context): 'the response includes a validation error indicating an invalid "well_hole_status" value' ) def step_then_response_includes_invalid_well_hole_status_error(context: Context): - _assert_any_validation_error_contains( - context, "Database error", "status_history_status_value_fkey" + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, "Expected at least one validation error" + found = any( + str(error.get("field", "")) in {"well_hole_status", "well_status"} + and "Input should be" in str(error.get("error", "")) + for error in validation_errors ) + assert ( + found + ), f"Expected well_hole_status/well_status validation error. Got: {validation_errors}" @then( diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 08d2573b..94757a50 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1156,7 +1156,14 @@ def test_well_status_accepts_well_hole_status_alias(self): model = WellInventoryRow(**row) - assert model.well_status == "Abandoned" + assert model.well_status.value == "Abandoned" + + def test_invalid_well_status_alias_raises_validation_error(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "NotARealWellHoleStatus" + + with pytest.raises(ValueError, match="Input should be"): + WellInventoryRow(**row) def test_water_level_aliases_are_mapped(self): row = _minimal_valid_well_inventory_row() @@ -1226,7 +1233,7 @@ def test_canonical_name_wins_when_alias_and_canonical_present(self): model = WellInventoryRow(**row) - assert model.well_status == "Abandoned" + assert model.well_status.value == "Abandoned" class TestWellInventoryAPIEdgeCases: From 525a9fd5a8d5807e73225a1c5ded042a6d4dd2ef Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 15:58:55 -0600 Subject: [PATCH 102/163] test(environment): use default test database settings for BDD runs --- tests/features/environment.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/features/environment.py b/tests/features/environment.py index 5e1e32b9..9cdff0d6 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -17,10 +17,10 @@ import random from datetime import datetime, timedelta -# Lock test database before any db module imports -# Ensures BDD tests only use ocotilloapi_test, never ocotilloapi_dev -os.environ["POSTGRES_DB"] = "ocotilloapi_test" -os.environ["POSTGRES_PORT"] = "5432" +# Default BDD runs to the local test database before any db module imports. +# Allow explicit CI/local environment configuration to override these values. +os.environ.setdefault("POSTGRES_DB", "ocotilloapi_test") +os.environ.setdefault("POSTGRES_PORT", "5432") from alembic import command from alembic.config import Config From a22f99a42f3f5efea20bef8aebaee8f2feb147b4 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 19 Mar 2026 18:27:16 -0600 Subject: [PATCH 103/163] Add actively monitored wells OGC collection --- ..._add_thing_id_to_nma_surface_water_data.py | 11 ++- ...a8b9c0_create_pygeoapi_supporting_views.py | 53 +++++++--- ...itored_wells_pygeoapi_materialized_view.py | 96 ++++++++++++++++++ ...6v7w8x9_drop_unused_well_type_ogc_views.py | 99 +++++++++++++++++++ core/pygeoapi-config.yml | 23 +++++ core/pygeoapi.py | 77 --------------- tests/test_ogc.py | 91 +++++++++++++---- 7 files changed, 337 insertions(+), 113 deletions(-) create mode 100644 alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py create mode 100644 alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py diff --git a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py index 8a359768..d791e7a6 100644 --- a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py +++ b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py @@ -33,18 +33,23 @@ def upgrade() -> None: ondelete="CASCADE", ) # Backfill thing_id based on LocationId -> Thing.nma_pk_location - op.execute(""" + op.execute( + """ UPDATE "NMA_SurfaceWaterData" sw SET thing_id = t.id FROM thing t WHERE t.nma_pk_location IS NOT NULL AND sw."LocationId" IS NOT NULL AND t.nma_pk_location = sw."LocationId"::text - """) + """ + ) # Remove any rows that cannot be linked to a Thing, then enforce NOT NULL op.execute('DELETE FROM "NMA_SurfaceWaterData" WHERE thing_id IS NULL') op.alter_column( - "NMA_SurfaceWaterData", "thing_id", existing_type=sa.Integer(), nullable=False + "NMA_SurfaceWaterData", + "thing_id", + existing_type=sa.Integer(), + nullable=False, ) diff --git a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py index e11bf240..60d03fc0 100644 --- a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py +++ b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py @@ -34,7 +34,10 @@ ("monitoring_wells", "monitoring well"), ("observation_wells", "observation well"), ("other_things", "other"), - ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), + ( + "outfalls_wastewater_return_flow", + "outfall of wastewater or return flow", + ), ("perennial_streams", "perennial stream"), ("piezometers", "piezometer"), ("production_wells", "production well"), @@ -107,8 +110,11 @@ def _create_latest_depth_view() -> str: o.observation_datetime, o.value, o.measuring_point_height, - -- Treat NULL measuring_point_height as 0 when computing depth_to_water_bgs - (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs, + -- Treat NULL measuring_point_height as 0 when computing + -- depth_to_water_bgs. + ( + o.value - COALESCE(o.measuring_point_height, 0) + ) AS depth_to_water_bgs, ROW_NUMBER() OVER ( PARTITION BY fe.thing_id ORDER BY o.observation_datetime DESC, o.id DESC @@ -151,7 +157,10 @@ def _create_avg_tds_view() -> str: SELECT csi.thing_id, mc.id AS major_chemistry_id, - COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_date, + COALESCE( + mc."AnalysisDate", + csi."CollectionDate" + )::date AS observation_date, mc."SampleValue" AS sample_value, mc."Units" AS units FROM "NMA_MajorChemistry" AS mc @@ -193,15 +202,16 @@ def _drop_view_or_materialized_view(view_name: str) -> None: def _create_matview_indexes() -> None: # Required so REFRESH MATERIALIZED VIEW CONCURRENTLY can run. + avg_tds_index_sql = ( + "CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)" + ) op.execute( text( "CREATE UNIQUE INDEX ux_ogc_latest_depth_to_water_wells_id " "ON ogc_latest_depth_to_water_wells (id)" ) ) - op.execute( - text("CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)") - ) + op.execute(text(avg_tds_index_sql)) def _create_refresh_function() -> str: @@ -220,7 +230,11 @@ def _create_refresh_function() -> str: WHERE schemaname = 'public' AND matviewname LIKE 'ogc_%' LOOP - matview_fqname := format('%I.%I', matview_record.schemaname, matview_record.matviewname); + matview_fqname := format( + '%I.%I', + matview_record.schemaname, + matview_record.matviewname + ); EXECUTE format('REFRESH MATERIALIZED VIEW %s', matview_fqname); END LOOP; END; @@ -235,10 +249,15 @@ def upgrade() -> None: required_core = {"thing", "location", "location_thing_association"} existing_tables = set(inspector.get_table_names(schema="public")) if not required_core.issubset(existing_tables): - missing_tables = sorted(t for t in required_core if t not in existing_tables) + missing_tables = sorted( + table_name + for table_name in required_core + if table_name not in existing_tables + ) missing_tables_str = ", ".join(missing_tables) raise RuntimeError( - "Cannot create pygeoapi supporting views. The following required core " + "Cannot create pygeoapi supporting views. " + "The following required core " f"tables are missing: {missing_tables_str}" ) @@ -255,7 +274,8 @@ def upgrade() -> None: ) missing_depth_tables_str = ", ".join(missing_depth_tables) raise RuntimeError( - "Cannot create ogc_latest_depth_to_water_wells. The following required " + "Cannot create ogc_latest_depth_to_water_wells. " + "The following required " f"tables are missing: {missing_depth_tables_str}" ) op.execute(text(_create_latest_depth_view())) @@ -269,7 +289,11 @@ def upgrade() -> None: _drop_view_or_materialized_view("ogc_avg_tds_wells") required_tds = {"NMA_MajorChemistry", "NMA_Chemistry_SampleInfo"} if not required_tds.issubset(existing_tables): - missing_tds_tables = sorted(t for t in required_tds if t not in existing_tables) + missing_tds_tables = sorted( + table_name + for table_name in required_tds + if table_name not in existing_tables + ) missing_tds_tables_str = ", ".join(missing_tds_tables) raise RuntimeError( "Cannot create ogc_avg_tds_wells. The following required " @@ -288,7 +312,10 @@ def upgrade() -> None: def downgrade() -> None: - op.execute(text(f"DROP FUNCTION IF EXISTS public.{REFRESH_FUNCTION_NAME}()")) + drop_refresh_function_sql = ( + f"DROP FUNCTION IF EXISTS public.{REFRESH_FUNCTION_NAME}()" + ) + op.execute(text(drop_refresh_function_sql)) _drop_view_or_materialized_view("ogc_avg_tds_wells") _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells") for view_id, _ in THING_COLLECTIONS: diff --git a/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py new file mode 100644 index 00000000..8c674c96 --- /dev/null +++ b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py @@ -0,0 +1,96 @@ +"""add actively monitored wells pygeoapi view + +Revision ID: r2s3t4u5v6w7 +Revises: p9c0d1e2f3a4 +Create Date: 2026-03-19 10:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "r2s3t4u5v6w7" +down_revision: Union[str, Sequence[str], None] = "p9c0d1e2f3a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None +DROP_VIEW_SQL = "DROP VIEW IF EXISTS ogc_actively_monitored_wells" +DROP_MATVIEW_SQL = "DROP MATERIALIZED VIEW IF EXISTS " "ogc_actively_monitored_wells" + + +def _create_actively_monitored_wells_view() -> str: + return """ + CREATE VIEW ogc_actively_monitored_wells AS + SELECT + wws.id, + wws.name, + 'water well'::text AS thing_type, + wws.well_depth, + wws.elevation, + wws.elevation_method, + wws.formation_zone, + wws.total_water_levels, + wws.last_water_level, + wws.last_water_level_datetime, + wws.min_water_level, + wws.max_water_level, + wws.water_level_trend_ft_per_year, + g.id AS group_id, + g.name AS group_name, + g.group_type, + wws.point + FROM "group" AS g + JOIN group_thing_association AS gta ON gta.group_id = g.id + JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id + WHERE lower(trim(g.name)) = 'water level network' + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "group", + "group_thing_association", + } + + if not required_tables.issubset(existing_tables): + missing = sorted( + table_name + for table_name in required_tables + if table_name not in existing_tables + ) + raise RuntimeError( + "Cannot create ogc_actively_monitored_wells. " + f"Missing required tables: {', '.join(missing)}" + ) + + has_summary = bind.execute( + text( + "SELECT 1 FROM pg_matviews " + "WHERE schemaname = 'public' " + "AND matviewname = 'ogc_water_well_summary'" + ) + ).scalar() + if has_summary != 1: + raise RuntimeError( + "Cannot create ogc_actively_monitored_wells. " + "Missing required materialized view: ogc_water_well_summary" + ) + + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(DROP_MATVIEW_SQL)) + op.execute(text(_create_actively_monitored_wells_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_actively_monitored_wells IS " + "'Wells in the Water Level Network group for pygeoapi.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(DROP_MATVIEW_SQL)) diff --git a/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py b/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py new file mode 100644 index 00000000..8800ed4d --- /dev/null +++ b/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py @@ -0,0 +1,99 @@ +"""drop unused well-type OGC views + +Revision ID: s4t5u6v7w8x9 +Revises: r2s3t4u5v6w7 +Create Date: 2026-03-19 14:30:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "s4t5u6v7w8x9" +down_revision: Union[str, Sequence[str], None] = "r2s3t4u5v6w7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REMOVED_THING_COLLECTIONS = [ + ("abandoned_wells", "abandoned well"), + ("artesian_wells", "artesian well"), + ("dry_holes", "dry hole"), + ("dug_wells", "dug well"), + ("exploration_wells", "exploration well"), + ("injection_wells", "injection well"), + ("monitoring_wells", "monitoring well"), + ("observation_wells", "observation well"), + ("piezometers", "piezometer"), + ("production_wells", "production well"), + ("test_wells", "test well"), +] + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _safe_view_id(view_id: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id): + raise ValueError(f"Unsafe view id: {view_id!r}") + return view_id + + +def _drop_view_or_materialized_view(view_name: str) -> None: + op.execute(text(f"DROP VIEW IF EXISTS {view_name}")) + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}")) + + +def _create_thing_view(view_id: str, thing_type: str) -> str: + safe_view_id = _safe_view_id(view_id) + escaped_thing_type = thing_type.replace("'", "''") + return f""" + CREATE VIEW ogc_{safe_view_id} AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ) + SELECT + t.id, + t.name, + t.first_visit_date, + t.nma_pk_welldata, + t.well_depth, + t.hole_depth, + t.well_casing_diameter, + t.well_casing_depth, + t.well_completion_date, + t.well_driller_name, + t.well_construction_method, + t.well_pump_type, + t.well_pump_depth, + t.formation_completion_code, + t.nma_formation_zone, + t.release_status, + l.elevation, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE t.thing_type = '{escaped_thing_type}' + """ + + +def upgrade() -> None: + for view_id, _ in REMOVED_THING_COLLECTIONS: + _drop_view_or_materialized_view(f"ogc_{_safe_view_id(view_id)}") + + +def downgrade() -> None: + for view_id, thing_type in REMOVED_THING_COLLECTIONS: + safe_view_id = _safe_view_id(view_id) + _drop_view_or_materialized_view(f"ogc_{safe_view_id}") + op.execute(text(_create_thing_view(view_id, thing_type))) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 981a40cf..f060e499 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -241,4 +241,27 @@ resources: table: ogc_minor_chemistry_wells geom_field: point + actively_monitored_wells: + type: collection + title: Actively Monitored Wells + description: Wells in the collaborative network currently flagged as actively monitored. + keywords: [water-wells, monitoring, collaborative-network, actively-monitored] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_actively_monitored_wells + geom_field: point + {thing_collections_block} diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 6c679a21..0ac68b1b 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -85,83 +85,6 @@ "description": "Locations where soil gas measurements or samples were collected.", "keywords": ["soil-gas", "sample-location"], }, - { - "id": "abandoned_wells", - "title": "Abandoned Wells", - "thing_type": "abandoned well", - "description": "Wells that are no longer active and are classified as abandoned.", - "keywords": ["abandoned-well", "well"], - }, - { - "id": "artesian_wells", - "title": "Artesian Wells", - "thing_type": "artesian well", - "description": "Wells that tap confined aquifers with artesian pressure conditions.", - "keywords": ["artesian", "well"], - }, - { - "id": "dry_holes", - "title": "Dry Holes", - "thing_type": "dry hole", - "description": "Drilled holes that did not produce usable groundwater.", - "keywords": ["dry-hole", "well"], - }, - { - "id": "dug_wells", - "title": "Dug Wells", - "thing_type": "dug well", - "description": "Large-diameter wells excavated by digging.", - "keywords": ["dug-well", "well"], - }, - { - "id": "exploration_wells", - "title": "Exploration Wells", - "thing_type": "exploration well", - "description": "Wells drilled to characterize geologic and groundwater conditions.", - "keywords": ["exploration-well", "well"], - }, - { - "id": "injection_wells", - "title": "Injection Wells", - "thing_type": "injection well", - "description": "Wells used to inject fluids into subsurface formations.", - "keywords": ["injection-well", "well"], - }, - { - "id": "monitoring_wells", - "title": "Monitoring Wells", - "thing_type": "monitoring well", - "description": "Wells primarily used for long-term groundwater monitoring.", - "keywords": ["monitoring-well", "groundwater", "well"], - }, - { - "id": "observation_wells", - "title": "Observation Wells", - "thing_type": "observation well", - "description": "Observation wells used for periodic water-level measurements.", - "keywords": ["observation-well", "groundwater", "well"], - }, - { - "id": "piezometers", - "title": "Piezometers", - "thing_type": "piezometer", - "description": "Piezometers used to measure hydraulic head at depth.", - "keywords": ["piezometer", "groundwater", "well"], - }, - { - "id": "production_wells", - "title": "Production Wells", - "thing_type": "production well", - "description": "Wells used for groundwater supply and extraction.", - "keywords": ["production-well", "groundwater", "well"], - }, - { - "id": "test_wells", - "title": "Test Wells", - "thing_type": "test well", - "description": "Temporary or investigative test wells.", - "keywords": ["test-well", "well"], - }, ] diff --git a/tests/test_ogc.py b/tests/test_ogc.py index f318339d..7abcc981 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -17,8 +17,10 @@ from importlib.util import find_spec import pytest +from fastapi.testclient import TestClient from sqlalchemy import text +from core.factory import create_api_app from core.dependencies import ( admin_function, editor_function, @@ -27,10 +29,15 @@ viewer_function, amp_viewer_function, ) -from db import NMA_Chemistry_SampleInfo, NMA_MajorChemistry, NMA_MinorTraceChemistry +from db import ( + Group, + GroupThingAssociation, + NMA_Chemistry_SampleInfo, + NMA_MajorChemistry, + NMA_MinorTraceChemistry, +) from db.engine import session_ctx -from main import app -from tests import client, override_authentication +from tests import override_authentication pytestmark = pytest.mark.skipif( find_spec("pygeoapi") is None, @@ -39,7 +46,8 @@ @pytest.fixture(scope="module", autouse=True) -def override_authentication_dependency_fixture(): +def ogc_client(): + app = create_api_app() app.dependency_overrides[admin_function] = override_authentication( default={"name": "foobar", "sub": "1234567890"} ) @@ -55,29 +63,30 @@ def override_authentication_dependency_fixture(): ) app.dependency_overrides[amp_viewer_function] = override_authentication() - yield + with TestClient(app) as client: + yield client app.dependency_overrides = {} -def test_ogc_landing(): - response = client.get("/ogcapi") +def test_ogc_landing(ogc_client): + response = ogc_client.get("/ogcapi") assert response.status_code == 200 payload = response.json() assert payload["title"] assert any(link["rel"] == "self" for link in payload["links"]) -def test_ogc_conformance(): - response = client.get("/ogcapi/conformance") +def test_ogc_conformance(ogc_client): + response = ogc_client.get("/ogcapi/conformance") assert response.status_code == 200 payload = response.json() assert "conformsTo" in payload assert any("ogcapi-features" in item for item in payload["conformsTo"]) -def test_ogc_openapi_has_paths(): - response = client.get("/ogcapi/openapi?f=json") +def test_ogc_openapi_has_paths(ogc_client): + response = ogc_client.get("/ogcapi/openapi?f=json") assert response.status_code == 200 payload = response.json() assert payload["openapi"].startswith("3.") @@ -392,8 +401,48 @@ def test_ogc_water_elevation_wells_normalizes_meter_observations_to_feet( session.commit() -def test_ogc_collections(): - response = client.get("/ogcapi/collections") +def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( + water_well_thing, + groundwater_level_observation, +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary")) + session.commit() + + group = Group( + name="Water Level Network", + group_type="Monitoring Plan", + release_status="draft", + ) + session.add(group) + session.flush() + + group_assoc = GroupThingAssociation( + group_id=group.id, + thing_id=water_well_thing.id, + ) + session.add(group_assoc) + session.commit() + + row = session.execute( + text( + "SELECT group_id, group_name, group_type " + "FROM ogc_actively_monitored_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert row.group_id == group.id + assert row.group_name == "Water Level Network" + assert row.group_type == "Monitoring Plan" + + session.delete(group_assoc) + session.delete(group) + session.commit() + + +def test_ogc_collections(ogc_client): + response = ogc_client.get("/ogcapi/collections") assert response.status_code == 200 payload = response.json() ids = {collection["id"] for collection in payload["collections"]} @@ -407,10 +456,11 @@ def test_ogc_collections(): "water_well_summary", "major_chemistry_results", "minor_chemistry_wells", + "actively_monitored_wells", }.issubset(ids) -def test_ogc_new_collection_items_endpoints(): +def test_ogc_new_collection_items_endpoints(ogc_client): for collection_id in ( "latest_tds_wells", "depth_to_water_trend_wells", @@ -418,8 +468,9 @@ def test_ogc_new_collection_items_endpoints(): "water_well_summary", "major_chemistry_results", "minor_chemistry_wells", + "actively_monitored_wells", ): - response = client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") + response = ogc_client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") assert response.status_code == 200 payload = response.json() assert payload["type"] == "FeatureCollection" @@ -428,22 +479,22 @@ def test_ogc_new_collection_items_endpoints(): @pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449") def test_ogc_locations_items_bbox(location): bbox = "-107.95,33.80,-107.94,33.81" - response = client.get(f"/ogcapi/collections/locations/items?bbox={bbox}") + response = ogc_client.get(f"/ogcapi/collections/locations/items?bbox={bbox}") assert response.status_code == 200 payload = response.json() assert payload["type"] == "FeatureCollection" assert payload["numberReturned"] >= 1 -def test_ogc_wells_items_and_item(water_well_thing): - response = client.get("/ogcapi/collections/water_wells/items?limit=20") +def test_ogc_wells_items_and_item(ogc_client, water_well_thing): + response = ogc_client.get("/ogcapi/collections/water_wells/items?limit=20") assert response.status_code == 200 payload = response.json() assert payload["numberReturned"] >= 1 ids = {str(feature["id"]) for feature in payload["features"]} assert str(water_well_thing.id) in ids - response = client.get( + response = ogc_client.get( f"/ogcapi/collections/water_wells/items/{water_well_thing.id}" ) assert response.status_code == 200 @@ -454,7 +505,7 @@ def test_ogc_wells_items_and_item(water_well_thing): @pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449") def test_ogc_polygon_within_filter(location): polygon = "POLYGON((-107.95 33.80,-107.94 33.80,-107.94 33.81,-107.95 33.81,-107.95 33.80))" - response = client.get( + response = ogc_client.get( "/ogcapi/collections/locations/items", params={ "filter": f"WITHIN(geometry,{polygon})", From e341f5f19fd892a786909be9e95cac38856f4213 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:27:49 +0000 Subject: [PATCH 104/163] Formatting changes --- .../c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py index d791e7a6..02161549 100644 --- a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py +++ b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py @@ -33,16 +33,14 @@ def upgrade() -> None: ondelete="CASCADE", ) # Backfill thing_id based on LocationId -> Thing.nma_pk_location - op.execute( - """ + op.execute(""" UPDATE "NMA_SurfaceWaterData" sw SET thing_id = t.id FROM thing t WHERE t.nma_pk_location IS NOT NULL AND sw."LocationId" IS NOT NULL AND t.nma_pk_location = sw."LocationId"::text - """ - ) + """) # Remove any rows that cannot be linked to a Thing, then enforce NOT NULL op.execute('DELETE FROM "NMA_SurfaceWaterData" WHERE thing_id IS NULL') op.alter_column( From 1460d4f461876049c8534d81b5f3c7820c6f9cf2 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 19:52:24 -0600 Subject: [PATCH 105/163] fix(well-inventory): avoid creating empty project groups on failed imports --- services/well_inventory_csv.py | 18 ++++++++++++------ tests/test_well_inventory.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index c740d6f5..217fd736 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -190,24 +190,30 @@ class dialect: # models is a list of (row_number, model) sorted_models = sorted(models, key=lambda x: x[1].project) for project, items in groupby(sorted_models, key=lambda x: x[1].project): - # get project and add if does not exist + # Reuse an existing project group immediately, but defer creating a + # new one until a row for that project actually imports successfully. sql = select(Group).where( and_(Group.group_type == "Monitoring Plan", Group.name == project) ) group = session.scalars(sql).one_or_none() - if not group: - group = Group(name=project, group_type="Monitoring Plan") - session.add(group) - session.flush() for row_number, model in items: current_row_id = model.well_name_point_id try: # Use savepoint for "best-effort" import per row with session.begin_nested(): - added = _add_csv_row(session, group, model, user) + group_for_row = group + if group_for_row is None: + group_for_row = Group( + name=project, group_type="Monitoring Plan" + ) + session.add(group_for_row) + session.flush() + + added = _add_csv_row(session, group_for_row, model, user) if added: wells.append(added) + group = group_for_row except ( ValueError, DatabaseError, diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 94757a50..d641b4bd 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -20,6 +20,7 @@ Location, LocationThingAssociation, Thing, + Group, Sample, Observation, Contact, @@ -598,6 +599,37 @@ def test_rerunning_same_well_inventory_csv_is_idempotent(): assert counts_after_second == counts_after_first +def test_failed_project_rows_do_not_create_empty_group(tmp_path): + row = _minimal_valid_well_inventory_row() + row.update( + { + "project": "Project Without Successful Rows", + "repeat_measurement_permission": True, + } + ) + + file_path = tmp_path / "well-inventory-failed-project.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 1, result.stderr + + with session_ctx() as session: + group = ( + session.query(Group) + .filter( + Group.name == "Project Without Successful Rows", + Group.group_type == "Monitoring Plan", + ) + .one_or_none() + ) + + assert group is None + + # ============================================================================= # Error Handling Tests - Cover API error paths # ============================================================================= From 3e93bb644f676e127f5d5b376f24d50ce9915fe2 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 19 Mar 2026 20:15:52 -0600 Subject: [PATCH 106/163] fix(well-inventory): treat whitespace-only lexicon values as blank --- schemas/well_inventory.py | 4 +++- tests/test_well_inventory.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 713ab141..8ec5a515 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -125,7 +125,9 @@ def email_validator_function(email_str): def flexible_lexicon_validator(enum_cls): def validator(v): - if v is None or v == "": + if v is None: + return None + if isinstance(v, str) and v.strip() == "": return None if isinstance(v, enum_cls): return v diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index d641b4bd..265832e6 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -1258,6 +1258,14 @@ def test_blank_well_status_is_treated_as_none(self): assert model.well_status is None + def test_whitespace_only_well_status_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = " " + + model = WellInventoryRow(**row) + + assert model.well_status is None + def test_canonical_name_wins_when_alias_and_canonical_present(self): row = _minimal_valid_well_inventory_row() row["well_status"] = "Abandoned" From 919222e81c47fa29c6dbfc2920412a70fb3903f0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 19 Mar 2026 20:48:50 -0600 Subject: [PATCH 107/163] feat: add command to import project area boundaries and create associated OGC view --- ...itored_wells_pygeoapi_materialized_view.py | 20 ++- ...t6u7v8w9x0y1_add_project_areas_ogc_view.py | 64 +++++++++ cli/cli.py | 24 ++++ cli/project_area_import.py | 123 ++++++++++++++++++ core/pygeoapi-config.yml | 23 ++++ tests/test_cli_commands.py | 102 ++++++++++++++- tests/test_ogc.py | 75 ++++++++++- 7 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py create mode 100644 cli/project_area_import.py diff --git a/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py index 8c674c96..4cbd9612 100644 --- a/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py +++ b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py @@ -16,12 +16,27 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None DROP_VIEW_SQL = "DROP VIEW IF EXISTS ogc_actively_monitored_wells" -DROP_MATVIEW_SQL = "DROP MATERIALIZED VIEW IF EXISTS " "ogc_actively_monitored_wells" +DROP_MATVIEW_SQL = "".join( + [ + "DROP MATERIALIZED VIEW IF EXISTS ", + "ogc_actively_monitored_wells", + ] +) def _create_actively_monitored_wells_view() -> str: return """ CREATE VIEW ogc_actively_monitored_wells AS + WITH latest_monitoring_status AS ( + SELECT DISTINCT ON (sh.target_id) + sh.target_id AS thing_id, + sh.status_value + FROM status_history AS sh + WHERE + sh.target_table = 'thing' + AND sh.status_type = 'Monitoring Status' + ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC + ) SELECT wws.id, wws.name, @@ -43,7 +58,9 @@ def _create_actively_monitored_wells_view() -> str: FROM "group" AS g JOIN group_thing_association AS gta ON gta.group_id = g.id JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id WHERE lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' """ @@ -54,6 +71,7 @@ def upgrade() -> None: required_tables = { "group", "group_thing_association", + "status_history", } if not required_tables.issubset(existing_tables): diff --git a/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py b/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py new file mode 100644 index 00000000..c03af311 --- /dev/null +++ b/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py @@ -0,0 +1,64 @@ +"""add project areas OGC view + +Revision ID: t6u7v8w9x0y1 +Revises: s4t5u6v7w8x9 +Create Date: 2026-03-19 16:45:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "t6u7v8w9x0y1" +down_revision: Union[str, Sequence[str], None] = "s4t5u6v7w8x9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +DROP_VIEW_SQL = "DROP VIEW IF EXISTS ogc_project_areas" + + +def _create_project_areas_view() -> str: + return """ + CREATE VIEW ogc_project_areas AS + SELECT + g.id, + g.name, + g.description, + g.group_type, + g.release_status, + g.project_area + FROM "group" AS g + WHERE g.project_area IS NOT NULL + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + if "group" not in existing_tables: + raise RuntimeError( + "Cannot create ogc_project_areas. Missing required table: group" + ) + + group_columns = {column["name"] for column in inspector.get_columns("group")} + if "project_area" not in group_columns: + raise RuntimeError( + "Cannot create ogc_project_areas. " + "Missing required column: group.project_area" + ) + + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(_create_project_areas_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_project_areas IS " + "'Project areas for groups with polygon boundaries for pygeoapi.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_VIEW_SQL)) diff --git a/cli/cli.py b/cli/cli.py index 44e9d02f..be093917 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1005,6 +1005,30 @@ def refresh_pygeoapi_materialized_views( typer.echo(f"Refreshed {len(target_views)} materialized view(s).") +@cli.command("import-project-area-boundaries") +def import_project_area_boundaries_command( + layer_url: str = typer.Option( + ( + "https://maps.nmt.edu/server/rest/services/Water/" + "Water_Resources/MapServer/17" + ), + "--layer-url", + help="ArcGIS Feature Layer URL for project area boundaries.", + ), +): + from cli.project_area_import import import_project_area_boundaries + + result = import_project_area_boundaries(layer_url=layer_url) + typer.echo(f"Fetched {result.fetched} feature(s).") + typer.echo(f"Matched {result.matched} group row(s).") + typer.echo(f"Updated {result.updated} group project area(s).") + if result.unmatched_locations: + typer.echo( + "Unmatched locations: " + ", ".join(result.unmatched_locations), + err=True, + ) + + if __name__ == "__main__": cli() diff --git a/cli/project_area_import.py b/cli/project_area_import.py new file mode 100644 index 00000000..2f748530 --- /dev/null +++ b/cli/project_area_import.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx +from geoalchemy2 import WKTElement +from shapely.geometry import MultiPolygon, Polygon, shape +from sqlalchemy import func, select + +from db import Group +from db.engine import session_ctx + +PROJECT_AREA_LAYER_URL = "".join( + [ + "https://maps.nmt.edu/server/rest/services/Water/", + "Water_Resources/MapServer/17", + ] +) +PROJECT_AREA_PAGE_SIZE = 1000 + + +@dataclass(frozen=True) +class ProjectAreaImportResult: + fetched: int + matched: int + updated: int + unmatched_locations: tuple[str, ...] + + +def _normalize_name(value: str) -> str: + return value.strip().lower() + + +def _geojson_to_multipolygon_wkt(geometry: dict[str, Any]) -> str: + geom = shape(geometry) + if isinstance(geom, Polygon): + geom = MultiPolygon([geom]) + if not isinstance(geom, MultiPolygon): + raise ValueError( + f"Expected Polygon or MultiPolygon geometry, got {geom.geom_type}" + ) + return geom.wkt + + +def _fetch_project_area_features( + client: httpx.Client, + layer_url: str, +) -> list[dict[str, Any]]: + features: list[dict[str, Any]] = [] + offset = 0 + + while True: + response = client.get( + f"{layer_url}/query", + params={ + "where": "1=1", + "outFields": "location", + "returnGeometry": "true", + "f": "geojson", + "resultOffset": offset, + "resultRecordCount": PROJECT_AREA_PAGE_SIZE, + }, + ) + response.raise_for_status() + payload = response.json() + batch = payload.get("features", []) + if not batch: + break + features.extend(batch) + if not payload.get("exceededTransferLimit"): + break + offset += len(batch) + + return features + + +def import_project_area_boundaries( + layer_url: str = PROJECT_AREA_LAYER_URL, +) -> ProjectAreaImportResult: + with httpx.Client(timeout=60.0) as client: + features = _fetch_project_area_features(client, layer_url) + + unmatched_locations: list[str] = [] + matched = 0 + updated = 0 + + with session_ctx() as session: + for feature in features: + attributes = feature.get("properties", {}) + geometry = feature.get("geometry") + location_name = (attributes.get("location") or "").strip() + + if not location_name or geometry is None: + continue + + groups = session.scalars( + select(Group).where( + func.lower(func.trim(Group.name)) == _normalize_name(location_name) + ) + ).all() + + if not groups: + unmatched_locations.append(location_name) + continue + + matched += len(groups) + project_area = WKTElement( + _geojson_to_multipolygon_wkt(geometry), + srid=4326, + ) + for group in groups: + group.project_area = project_area + updated += 1 + + session.commit() + + return ProjectAreaImportResult( + fetched=len(features), + matched=matched, + updated=updated, + unmatched_locations=tuple(sorted(set(unmatched_locations))), + ) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index f060e499..1bae81d9 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -264,4 +264,27 @@ resources: table: ogc_actively_monitored_wells geom_field: point + project_areas: + type: collection + title: Project Areas + description: Project groups with polygon project-area boundaries. + keywords: [project-areas, groups, boundaries] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_project_areas + geom_field: project_area + {thing_collections_block} diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 47f451ec..153f6c58 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -125,6 +125,102 @@ def test_refresh_pygeoapi_materialized_views_rejects_invalid_identifier(): assert "Invalid SQL identifier" in result.output +def test_import_project_area_boundaries_updates_matching_groups(monkeypatch): + class FakeGroup: + def __init__(self): + self.project_area = None + + fake_group = FakeGroup() + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("cli.project_area_import.httpx.Client", FakeClient) + monkeypatch.setattr( + "cli.project_area_import._fetch_project_area_features", + lambda client, layer_url: [ + { + "properties": {"location": "Test Group"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-106.9, 33.9], + [-106.7, 33.9], + [-106.7, 34.1], + [-106.9, 34.1], + [-106.9, 33.9], + ] + ], + }, + }, + { + "properties": {"location": "Missing Group"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-105.0, 33.0], + [-104.8, 33.0], + [-104.8, 33.2], + [-105.0, 33.2], + [-105.0, 33.0], + ] + ], + }, + }, + ], + ) + + class FakeScalarResult: + def __init__(self, groups): + self._groups = groups + + def all(self): + return self._groups + + class FakeSession: + def __init__(self): + self.commit_called = False + self.scalar_calls = 0 + + def scalars(self, stmt): + self.scalar_calls += 1 + if self.scalar_calls == 1: + return FakeScalarResult([fake_group]) + return FakeScalarResult([]) + + def commit(self): + self.commit_called = True + + class FakeSessionCtx: + def __enter__(self): + self.session = FakeSession() + return self.session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("cli.project_area_import.session_ctx", lambda: FakeSessionCtx()) + + runner = CliRunner() + result = runner.invoke(cli, ["import-project-area-boundaries"]) + + assert result.exit_code == 0, result.output + assert "Fetched 2 feature(s)." in result.output + assert "Matched 1 group row(s)." in result.output + assert "Updated 1 group project area(s)." in result.output + assert "Unmatched locations: Missing Group" in result.output + assert fake_group.project_area is not None + + def test_initialize_lexicon_invokes_initializer(monkeypatch): called = {"count": 0} @@ -532,10 +628,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 7abcc981..42a0c984 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -20,7 +20,6 @@ from fastapi.testclient import TestClient from sqlalchemy import text -from core.factory import create_api_app from core.dependencies import ( admin_function, editor_function, @@ -29,12 +28,14 @@ viewer_function, amp_viewer_function, ) +from core.factory import create_api_app from db import ( Group, GroupThingAssociation, NMA_Chemistry_SampleInfo, NMA_MajorChemistry, NMA_MinorTraceChemistry, + StatusHistory, ) from db.engine import session_ctx from tests import override_authentication @@ -422,6 +423,14 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( thing_id=water_well_thing.id, ) session.add(group_assoc) + status_history = StatusHistory( + status_type="Monitoring Status", + status_value="Currently monitored", + start_date=date(2024, 1, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + session.add(status_history) session.commit() row = session.execute( @@ -436,6 +445,59 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( assert row.group_name == "Water Level Network" assert row.group_type == "Monitoring Plan" + session.delete(status_history) + session.delete(group_assoc) + session.delete(group) + session.commit() + + +def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored( + water_well_thing, + groundwater_level_observation, +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary")) + session.commit() + + group = Group( + name="Water Level Network", + group_type="Monitoring Plan", + release_status="draft", + ) + session.add(group) + session.flush() + + group_assoc = GroupThingAssociation( + group_id=group.id, + thing_id=water_well_thing.id, + ) + session.add(group_assoc) + currently_monitored = StatusHistory( + status_type="Monitoring Status", + status_value="Currently monitored", + start_date=date(2024, 1, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + not_currently_monitored = StatusHistory( + status_type="Monitoring Status", + status_value="Not currently monitored", + start_date=date(2024, 2, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + session.add_all([currently_monitored, not_currently_monitored]) + session.commit() + + row = session.execute( + text("SELECT id FROM ogc_actively_monitored_wells WHERE id = :thing_id"), + {"thing_id": water_well_thing.id}, + ).one_or_none() + + assert row is None + + session.delete(not_currently_monitored) + session.delete(currently_monitored) session.delete(group_assoc) session.delete(group) session.commit() @@ -457,6 +519,7 @@ def test_ogc_collections(ogc_client): "major_chemistry_results", "minor_chemistry_wells", "actively_monitored_wells", + "project_areas", }.issubset(ids) @@ -469,6 +532,7 @@ def test_ogc_new_collection_items_endpoints(ogc_client): "major_chemistry_results", "minor_chemistry_wells", "actively_monitored_wells", + "project_areas", ): response = ogc_client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") assert response.status_code == 200 @@ -476,6 +540,15 @@ def test_ogc_new_collection_items_endpoints(ogc_client): assert payload["type"] == "FeatureCollection" +def test_ogc_project_areas_items_expose_groups_with_project_areas(ogc_client, group): + response = ogc_client.get("/ogcapi/collections/project_areas/items?limit=20") + + assert response.status_code == 200 + payload = response.json() + ids = {str(feature["id"]) for feature in payload["features"]} + assert str(group.id) in ids + + @pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449") def test_ogc_locations_items_bbox(location): bbox = "-107.95,33.80,-107.94,33.81" From 4c4a8337e7914f1078762764eda4abce9d77bdf8 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:49:20 +0000 Subject: [PATCH 108/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 153f6c58..6db32370 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -628,12 +628,10 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ 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 CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From a01e0915c25df36a8e046707531f6436499b1d04 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Fri, 20 Mar 2026 16:31:03 -0600 Subject: [PATCH 109/163] fix(well inventory): allow null mp heights --- services/well_inventory_csv.py | 5 ----- tests/test_well_inventory.py | 32 ++++---------------------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 89ece733..1cd8b2e2 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -837,11 +837,6 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(sample) session.flush() - if model.depth_to_water_ft is not None and universal_mp_height is None: - raise ValueError( - "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" - ) - # create Observation # TODO: groundwater_level_reason may be conditionally required for null depth_to_water_ft - handle accordingly observation = Observation( diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index fd27e395..fdb17f6e 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -598,14 +598,14 @@ def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft assert observations[0].measuring_point_height == 4.0 -def test_null_observation_allows_blank_mp_height(tmp_path): - """When depth to water is not provided (ie null), blank measuring_point_height_ft and mp_height fields should be allowed and result in a null measuring_point_height for the observation and no associated measuring point height (MeasuringPointHistory) for the well.""" +def test_null_mp_height_allowed(tmp_path): + """A null measuring_point_height_ft and mp_height area allowed when depth to water is provided, and results in null measuring_point_height for the thing and observation.""" row = _minimal_valid_well_inventory_row() row.update( { "measuring_point_height_ft": "", "water_level_date_time": "2025-02-15T10:30:00", - "depth_to_water_ft": "", + "depth_to_water_ft": 8, "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", @@ -628,6 +628,7 @@ def test_null_observation_allows_blank_mp_height(tmp_path): assert len(things) == 1 assert things[0].measuring_point_height is None assert len(observations) == 1 + assert observations[0].value == 8 assert observations[0].measuring_point_height is None @@ -658,31 +659,6 @@ def test_conflicting_mp_heights_raises_error(tmp_path): ) -def test_no_mp_height_raises_error_when_depth_to_water_provided(tmp_path): - row = _minimal_valid_well_inventory_row() - row.update( - { - "water_level_date_time": "2025-02-15T10:30:00", - "measuring_point_height_ft": "", - "mp_height": "", - "depth_to_water_ft": "8", - } - ) - - file_path = tmp_path / "well-inventory-no-mp-height.csv" - with file_path.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=list(row.keys())) - writer.writeheader() - writer.writerow(row) - - result = well_inventory_csv(file_path) - assert result.exit_code == 1, result.stderr - assert ( - result.payload["validation_errors"][0]["error"] - == "measuring_point_height_ft or mp_height is required when depth_to_water_ft is provided for a non-null observation" - ) - - def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" row = _minimal_valid_well_inventory_row() From 6d03bf42452ff72edc02314d1366245e20664cb9 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Fri, 20 Mar 2026 16:39:43 -0600 Subject: [PATCH 110/163] fix(well inventory): use one mp height for thing and gwl --- services/well_inventory_csv.py | 20 ++++++++++++++++++-- tests/test_well_inventory.py | 6 +++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 217fd736..a2c21c50 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -687,6 +687,22 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) } ) + if ( + model.mp_height is not None + and model.measuring_point_height_ft is not None + and model.mp_height != model.measuring_point_height_ft + ): + raise ValueError( + "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" + ) + + if model.measuring_point_height_ft is not None: + universal_mp_height = model.measuring_point_height_ft + elif model.mp_height is not None: + universal_mp_height = model.mp_height + else: + universal_mp_height = None + data = CreateWell( location_id=loc.id, group_id=group.id, @@ -695,7 +711,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_depth=model.total_well_depth_ft, well_depth_source=model.depth_source, well_casing_diameter=model.casing_diameter_ft, - measuring_point_height=model.measuring_point_height_ft, + measuring_point_height=universal_mp_height, measuring_point_description=model.measuring_point_description, well_completion_date=model.date_drilled, well_completion_date_source=model.completion_source, @@ -839,7 +855,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) value=model.depth_to_water_ft, unit="ft", observation_datetime=model.measurement_date_time, - measuring_point_height=model.mp_height, + measuring_point_height=universal_mp_height, groundwater_level_reason=( model.level_status.value if hasattr(model.level_status, "value") diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 23dfa537..9b4fe0df 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -482,7 +482,7 @@ def test_well_inventory_db_contents_with_waterlevels(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 2.5, + "mp_height_ft": 3.5, "level_status": "Water level not affected", } ) @@ -670,7 +670,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "sample_method": "Steel-tape measurement", "data_quality": "Water level accurate to within two hundreths of a foot", "water_level_notes": "Attempted measurement", - "mp_height_ft": 2.5, + "mp_height_ft": 3.5, } ) @@ -694,7 +694,7 @@ def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): "2025-02-15T10:30:00Z" ) assert observations[0].value is None - assert observations[0].measuring_point_height == 2.5 + assert observations[0].measuring_point_height == 3.5 def test_rerunning_same_well_inventory_csv_is_idempotent(): From cf7ca5a27c5018f781452abe1ab66b25469e8038 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Fri, 20 Mar 2026 16:41:29 -0600 Subject: [PATCH 111/163] fix(test): fix typo in doc string --- tests/test_well_inventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 9b4fe0df..95a950db 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -600,7 +600,7 @@ def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft def test_null_mp_height_allowed(tmp_path): - """A null measuring_point_height_ft and mp_height area allowed when depth to water is provided, and results in null measuring_point_height for the thing and observation.""" + """A null measuring_point_height_ft and mp_height are allowed when depth to water is provided, and results in null measuring_point_height for the thing and observation.""" row = _minimal_valid_well_inventory_row() row.update( { From 467c87e4a2f4074c7e9e53ccf3f5ac79da98bf20 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 23 Mar 2026 09:03:35 -0600 Subject: [PATCH 112/163] feat(cli): add progress updates for well inventory imports - Emit validation and import progress during interactive CLI runs - Report per-project import progress and periodic row counts - Keep non-interactive callers and tests quiet by default --- cli/service_adapter.py | 7 ++- services/well_inventory_csv.py | 88 ++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 0dd52d37..bc7bb6cc 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -56,8 +56,13 @@ def well_inventory_csv(source_file: Path | str): payload = {"detail": "File encoding error"} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) try: + progress_callback = None + if sys.stdout.isatty(): + progress_callback = lambda message: print(message, flush=True) payload = import_well_inventory_csv( - text=text, user={"sub": "cli", "name": "cli"} + text=text, + user={"sub": "cli", "name": "cli"}, + progress_callback=progress_callback, ) except ValueError as exc: payload = {"detail": str(exc)} diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 217fd736..7a27077a 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -21,7 +21,7 @@ from datetime import date from io import StringIO from itertools import groupby -from typing import Set +from typing import Callable, Set from shapely import Point from sqlalchemy import select, and_ @@ -59,6 +59,7 @@ AUTOGEN_TOKEN_REGEX = re.compile( r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE ) +PROGRESS_INTERVAL = 25 def _extract_autogen_prefix(well_id: str | None) -> str | None: @@ -97,7 +98,19 @@ def import_well_inventory_csv(*args, **kw) -> dict: return _import_well_inventory_csv(session, *args, **kw) -def _import_well_inventory_csv(session: Session, text: str, user: str): +def _emit_progress( + progress_callback: Callable[[str], None] | None, message: str +) -> None: + if progress_callback is not None: + progress_callback(message) + + +def _import_well_inventory_csv( + session: Session, + text: str, + user: str, + progress_callback: Callable[[str], None] | None = None, +): # if not file.content_type.startswith("text/csv") or not file.filename.endswith( # ".csv" # ): @@ -144,6 +157,9 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): raise ValueError("No data rows found") if len(rows) > 2000: raise ValueError(f"Too many rows {len(rows)}>2000") + _emit_progress( + progress_callback, f"Loaded {len(rows)} data rows. Validating input..." + ) try: header = text.splitlines()[0] @@ -182,14 +198,35 @@ class dialect: } try: - models, row_validation_errors = _make_row_models(rows, session) + models, row_validation_errors = _make_row_models( + rows, session, progress_callback=progress_callback + ) validation_errors.extend(row_validation_errors) + _emit_progress( + progress_callback, + ( + "Validation complete: " + f"{len(models)} rows ready to import, " + f"{len(row_validation_errors)} validation errors found." + ), + ) if models: + total_model_rows = len(models) + attempted_count = 0 + imported_count = 0 # Group by project, preserving row number # models is a list of (row_number, model) sorted_models = sorted(models, key=lambda x: x[1].project) for project, items in groupby(sorted_models, key=lambda x: x[1].project): + project_rows = list(items) + _emit_progress( + progress_callback, + ( + f"Importing project '{project}' " + f"({len(project_rows)} row{'s' if len(project_rows) != 1 else ''})..." + ), + ) # Reuse an existing project group immediately, but defer creating a # new one until a row for that project actually imports successfully. sql = select(Group).where( @@ -197,7 +234,7 @@ class dialect: ) group = session.scalars(sql).one_or_none() - for row_number, model in items: + for row_number, model in project_rows: current_row_id = model.well_name_point_id try: # Use savepoint for "best-effort" import per row @@ -214,6 +251,7 @@ class dialect: if added: wells.append(added) group = group_for_row + imported_count += 1 except ( ValueError, DatabaseError, @@ -248,7 +286,26 @@ class dialect: "error": error_text, } ) + finally: + attempted_count += 1 + if ( + attempted_count == total_model_rows + or attempted_count % PROGRESS_INTERVAL == 0 + ): + _emit_progress( + progress_callback, + ( + "Import progress: " + f"{attempted_count}/{total_model_rows} validated rows attempted, " + f"{imported_count} imported, " + f"{len(validation_errors)} issues recorded." + ), + ) session.commit() + else: + _emit_progress( + progress_callback, "No valid rows were available for import." + ) except Exception as exc: logging.exception("Unexpected error in _import_well_inventory_csv") return {"detail": str(exc)} @@ -261,6 +318,15 @@ class dialect: } rows_with_validation_errors_or_warnings = len(error_rows) + _emit_progress( + progress_callback, + ( + "Import finished: " + f"{rows_imported}/{rows_processed} rows imported, " + f"{rows_with_validation_errors_or_warnings} rows with issues." + ), + ) + return { "validation_errors": validation_errors, "summary": { @@ -473,11 +539,12 @@ def _find_existing_imported_well( ).first() -def _make_row_models(rows, session): +def _make_row_models(rows, session, progress_callback=None): models = [] validation_errors = [] seen_ids: Set[str] = set() offsets = {} + total_rows = len(rows) for idx, row in enumerate(rows): row_number = idx + 1 try: @@ -548,6 +615,17 @@ def _make_row_models(rows, session): "value": value, } ) + finally: + if row_number == total_rows or row_number % PROGRESS_INTERVAL == 0: + _emit_progress( + progress_callback, + ( + "Validation progress: " + f"{row_number}/{total_rows} rows checked, " + f"{len(models)} valid, " + f"{len(validation_errors)} issues found." + ), + ) return models, validation_errors From 0ec4da9f1c2bc991c85fa8dc7d307a26f1026738 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 23 Mar 2026 10:16:44 -0600 Subject: [PATCH 113/163] feat(well-inventory): emit per-row progress during imports --- services/well_inventory_csv.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 00905a87..1bc112fe 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -236,6 +236,13 @@ class dialect: for row_number, model in project_rows: current_row_id = model.well_name_point_id + _emit_progress( + progress_callback, + ( + f"Starting row {attempted_count + 1}/{total_model_rows}: " + f"{current_row_id}" + ), + ) try: # Use savepoint for "best-effort" import per row with session.begin_nested(): From f482b5ab85539db2c21eb9bc46404f3ce50dfb2f Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 23 Mar 2026 13:11:13 -0600 Subject: [PATCH 114/163] fix(well-inventory): normalize "Complete" monitoring frequency to "Not currently monitored" - Treat source monitoring_frequency value Complete as no monitoring frequency - Map Complete rows to monitoring_status = Not currently monitored - Add schema and import regression coverage for the normalization - Add unit and BDD coverage for the normalization behavior --- schemas/well_inventory.py | 19 ++++++++++++ .../steps/well-inventory-csv-given.py | 8 +++++ tests/features/steps/well-inventory-csv.py | 18 +++++++++++ tests/features/well-inventory-csv.feature | 8 +++++ tests/test_well_inventory.py | 31 +++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 8ec5a515..56eb93eb 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -342,6 +342,25 @@ class WellInventoryRow(BaseModel): data_quality: DataQualityField = None water_level_notes: Optional[str] = None + @model_validator(mode="before") + @classmethod + def normalize_complete_monitoring_frequency(cls, data): + """Normalize `Complete` monitoring_frequency by clearing monitoring_frequency and setting monitoring_status to `Not currently monitored`.""" + if not isinstance(data, dict): + return data + + monitoring_frequency = data.get("monitoring_frequency") + if ( + isinstance(monitoring_frequency, str) + and monitoring_frequency.strip().lower() == "complete" + ): + normalized = dict(data) + normalized["monitoring_frequency"] = None + normalized["monitoring_status"] = "Not currently monitored" + return normalized + + return data + @field_validator("date_time", mode="before") def make_date_time_tz_aware(cls, v): if isinstance(v, str): diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index aeee0232..4f6b6278 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -465,6 +465,14 @@ def step_given_row_contains_invalid_monitoring_status_value(context: Context): _set_content_from_df(context, df) +@given('my CSV file contains a row with monitoring_frequency set to "Complete"') +def step_given_row_contains_complete_monitoring_frequency(context: Context): + df = _get_valid_df(context) + df.loc[0, "monitoring_frequency"] = "Complete" + context.complete_monitoring_frequency_well_id = df.loc[0, "well_name_point_id"] + _set_content_from_df(context, df) + + @given( 'my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand"' ) diff --git a/tests/features/steps/well-inventory-csv.py b/tests/features/steps/well-inventory-csv.py index cf5b658e..bba4b679 100644 --- a/tests/features/steps/well-inventory-csv.py +++ b/tests/features/steps/well-inventory-csv.py @@ -6,6 +6,7 @@ from behave import given, when, then from behave.runner import Context from cli.service_adapter import well_inventory_csv +from db import Thing from db.engine import session_ctx from db.lexicon import LexiconCategory from services.util import convert_dt_tz_naive_to_tz_aware @@ -415,3 +416,20 @@ def step_then_all_wells_are_imported_with_system_generated_unique_well_name( assert len(well_ids) == len( set(well_ids) ), "Expected unique well_name_point_id values" + + +@then( + 'the imported well with monitoring_frequency "Complete" is marked not currently monitored' +) +def step_then_complete_monitoring_frequency_maps_to_not_currently_monitored( + context: Context, +): + with session_ctx() as session: + thing = session.scalars( + select(Thing).where( + Thing.name == context.complete_monitoring_frequency_well_id + ) + ).one() + + assert thing.monitoring_status == "Not currently monitored" + assert thing.monitoring_frequencies == [] diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index ee094ef2..0ee85bba 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -182,6 +182,14 @@ Feature: Bulk upload well inventory from CSV via CLI Then the command exits with code 0 And all wells are imported + @positive @validation @BDMS-TBD + Scenario: Upload treats Complete monitoring_frequency as not currently monitored + Given my CSV file contains a row with monitoring_frequency set to "Complete" + When I run the well inventory bulk upload command + Then the command exits with code 0 + And all wells are imported + And the imported well with monitoring_frequency "Complete" is marked not currently monitored + @positive @validation @autogenerate_ids @BDMS-TBD Scenario: Upload succeeds and system auto-generates well_name_point_id for uppercase prefix placeholders and blanks Given my CSV file contains all valid columns but uses uppercase "-xxxx" placeholders and blank values for well_name_point_id diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 95a950db..e3a5f45a 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -760,6 +760,28 @@ def test_failed_project_rows_do_not_create_empty_group(tmp_path): assert group is None +def test_complete_monitoring_frequency_sets_not_currently_monitored_without_frequency( + tmp_path, +): + row = _minimal_valid_well_inventory_row() + row["monitoring_frequency"] = "Complete" + + file_path = tmp_path / "well-inventory-complete-monitoring-frequency.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + thing = session.query(Thing).one() + + assert thing.monitoring_status == "Not currently monitored" + assert thing.monitoring_frequencies == [] + + # ============================================================================= # Error Handling Tests - Cover API error paths # ============================================================================= @@ -1388,6 +1410,15 @@ def test_blank_well_status_is_treated_as_none(self): assert model.well_status is None + def test_complete_monitoring_frequency_is_normalized(self): + row = _minimal_valid_well_inventory_row() + row["monitoring_frequency"] = "Complete" + + model = WellInventoryRow(**row) + + assert model.monitoring_frequency is None + assert model.monitoring_status.value == "Not currently monitored" + def test_whitespace_only_well_status_is_treated_as_none(self): row = _minimal_valid_well_inventory_row() row["well_hole_status"] = " " From 5fabcd11ed441f77aed0727e76ec5b8a92ad385f Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 23 Mar 2026 13:31:33 -0600 Subject: [PATCH 115/163] fix(well-inventory): stop defaulting missing observation data quality to Unknown Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- services/well_inventory_csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 1bc112fe..2a3dcc2f 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -949,7 +949,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) nma_data_quality=( model.data_quality.value if hasattr(model.data_quality, "value") - else (model.data_quality or "Unknown") + else model.data_quality or None ), notes=model.water_level_notes, ) From dbceb7566b72faa5c489cc7d9ad7b036bb5c1359 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 23 Mar 2026 14:19:25 -0600 Subject: [PATCH 116/163] feat: enhance project area import with created and skipped counts --- cli/cli.py | 2 ++ cli/project_area_import.py | 50 ++++++++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/cli/cli.py b/cli/cli.py index be093917..8a91f5f4 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1021,7 +1021,9 @@ def import_project_area_boundaries_command( result = import_project_area_boundaries(layer_url=layer_url) typer.echo(f"Fetched {result.fetched} feature(s).") typer.echo(f"Matched {result.matched} group row(s).") + typer.echo(f"Created {result.created} group(s).") typer.echo(f"Updated {result.updated} group project area(s).") + typer.echo(f"Skipped {result.skipped} unchanged group(s).") if result.unmatched_locations: typer.echo( "Unmatched locations: " + ", ".join(result.unmatched_locations), diff --git a/cli/project_area_import.py b/cli/project_area_import.py index 2f748530..ade08a86 100644 --- a/cli/project_area_import.py +++ b/cli/project_area_import.py @@ -25,6 +25,8 @@ class ProjectAreaImportResult: fetched: int matched: int updated: int + created: int + skipped: int unmatched_locations: tuple[str, ...] @@ -32,6 +34,12 @@ def _normalize_name(value: str) -> str: return value.strip().lower() +def _geoms_equal(geom1: str, geom2: str) -> bool: + from shapely import wkt + + return wkt.loads(geom1).equals(wkt.loads(geom2)) + + def _geojson_to_multipolygon_wkt(geometry: dict[str, Any]) -> str: geom = shape(geometry) if isinstance(geom, Polygon): @@ -77,6 +85,7 @@ def _fetch_project_area_features( def import_project_area_boundaries( layer_url: str = PROJECT_AREA_LAYER_URL, + group_type: str = "Geographic Area", ) -> ProjectAreaImportResult: with httpx.Client(timeout=60.0) as client: features = _fetch_project_area_features(client, layer_url) @@ -84,6 +93,8 @@ def import_project_area_boundaries( unmatched_locations: list[str] = [] matched = 0 updated = 0 + created = 0 + skipped = 0 with session_ctx() as session: for feature in features: @@ -94,24 +105,45 @@ def import_project_area_boundaries( if not location_name or geometry is None: continue + normalized_name = _normalize_name(location_name) groups = session.scalars( select(Group).where( - func.lower(func.trim(Group.name)) == _normalize_name(location_name) + func.lower(func.trim(Group.name)) == normalized_name, + Group.group_type == group_type, ) ).all() - if not groups: - unmatched_locations.append(location_name) - continue - - matched += len(groups) project_area = WKTElement( _geojson_to_multipolygon_wkt(geometry), srid=4326, ) + + if not groups: + new_group = Group( + name=location_name, + group_type=group_type, + project_area=project_area, + ) + session.add(new_group) + created += 1 + matched += 1 + continue + + matched += len(groups) for group in groups: - group.project_area = project_area - updated += 1 + old_wkt = None + if group.project_area is not None: + from shapely import wkb + + old_wkt = wkb.loads(bytes(group.project_area.data)).wkt + + new_wkt = project_area.desc + + if old_wkt is None or not _geoms_equal(old_wkt, new_wkt): + group.project_area = project_area + updated += 1 + else: + skipped += 1 session.commit() @@ -119,5 +151,7 @@ def import_project_area_boundaries( fetched=len(features), matched=matched, updated=updated, + created=created, + skipped=skipped, unmatched_locations=tuple(sorted(set(unmatched_locations))), ) From a242b36ed9058d82b0ed9f005cea5c92e3d00f50 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 25 Mar 2026 15:25:46 -0600 Subject: [PATCH 117/163] feat: implement dynamic loading of pygeoapi app and improve description formatting --- core/pygeoapi.py | 57 ++++++++++++++++++++++++++---------- tests/test_pygeoapi_mount.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/test_pygeoapi_mount.py diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 0ac68b1b..7783af10 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -1,5 +1,7 @@ +import importlib import os import re +import sys import textwrap from importlib.util import find_spec from pathlib import Path @@ -12,28 +14,38 @@ "id": "water_wells", "title": "Water Wells", "thing_type": "water well", - "description": "Groundwater wells used for monitoring, production, and hydrogeologic investigations.", + "description": ( + "Groundwater wells used for monitoring, production, and " + "hydrogeologic investigations." + ), "keywords": ["well", "groundwater", "water-well"], }, { "id": "springs", "title": "Springs", "thing_type": "spring", - "description": "Natural spring features and associated spring monitoring points.", + "description": ( + "Natural spring features and associated spring monitoring points." + ), "keywords": ["springs", "groundwater-discharge"], }, { "id": "diversions_surface_water", "title": "Surface Water Diversions", "thing_type": "diversion of surface water, etc.", - "description": "Diversion structures such as ditches, canals, and intake points.", + "description": ( + "Diversion structures such as ditches, canals, and intake points." + ), "keywords": ["surface-water", "diversion"], }, { "id": "ephemeral_streams", "title": "Ephemeral Streams", "thing_type": "ephemeral stream", - "description": "Stream reaches that flow only in direct response to precipitation events.", + "description": ( + "Stream reaches that flow only in direct response to " + "precipitation events." + ), "keywords": ["ephemeral-stream", "surface-water"], }, { @@ -54,7 +66,9 @@ "id": "other_things", "title": "Other Thing Types", "thing_type": "other", - "description": "Feature records that do not match another defined thing type.", + "description": ( + "Feature records that do not match another defined thing type." + ), "keywords": ["other"], }, { @@ -68,21 +82,23 @@ "id": "perennial_streams", "title": "Perennial Streams", "thing_type": "perennial stream", - "description": "Stream reaches with continuous or near-continuous flow.", + "description": ("Stream reaches with continuous or near-continuous flow."), "keywords": ["perennial-stream", "surface-water"], }, { "id": "rock_sample_locations", "title": "Rock Sample Locations", "thing_type": "rock sample location", - "description": "Locations where rock samples were collected or documented.", + "description": ("Locations where rock samples were collected or documented."), "keywords": ["rock-sample"], }, { "id": "soil_gas_sample_locations", "title": "Soil Gas Sample Locations", "thing_type": "soil gas sample location", - "description": "Locations where soil gas measurements or samples were collected.", + "description": ( + "Locations where soil gas measurements or samples were collected." + ), "keywords": ["soil-gas", "sample-location"], }, ] @@ -104,7 +120,8 @@ def _mount_path() -> str: if not path.startswith("/"): path = f"/{path}" - # Remove any trailing slashes so "/ogcapi/" and "ogcapi/" both become "/ogcapi". + # Remove trailing slashes so "/ogcapi/" and "ogcapi/" both become + # "/ogcapi". path = path.rstrip("/") # Disallow traversal/current-directory segments. @@ -114,7 +131,8 @@ def _mount_path() -> str: "Invalid PYGEOAPI_MOUNT_PATH: traversal segments are not allowed." ) - # Allow only slash-delimited segments of alphanumerics, underscore, or hyphen. + # Allow only slash-delimited segments of alphanumerics, underscore, + # or hyphen. if not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", path): raise ValueError( "Invalid PYGEOAPI_MOUNT_PATH: only letters, numbers, underscores, " @@ -208,8 +226,8 @@ def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: ).strip() if not user: raise RuntimeError( - "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and non-empty " - "to generate the pygeoapi configuration." + "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and " + "non-empty to generate the pygeoapi configuration." ) if os.environ.get("PYGEOAPI_POSTGRES_PASSWORD") is None: raise RuntimeError( @@ -261,12 +279,22 @@ def _generate_openapi(config_path: Path, openapi_path: Path) -> None: openapi_path.write_text(openapi, encoding="utf-8") +def _load_pygeoapi_app(): + module_name = "pygeoapi.starlette_app" + if module_name in sys.modules: + module = importlib.reload(sys.modules[module_name]) + else: + module = importlib.import_module(module_name) + return module.APP + + def mount_pygeoapi(app: FastAPI) -> None: if getattr(app.state, "pygeoapi_mounted", False): return if find_spec("pygeoapi") is None: raise RuntimeError( - "pygeoapi is not installed. Rebuild/sync dependencies so /ogcapi can be mounted." + "pygeoapi is not installed. Rebuild/sync dependencies so " + "/ogcapi can be mounted." ) pygeoapi_dir = _pygeoapi_dir() @@ -278,8 +306,7 @@ def mount_pygeoapi(app: FastAPI) -> None: os.environ["PYGEOAPI_CONFIG"] = str(config_path) os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path) - from pygeoapi.starlette_app import APP as pygeoapi_app - + pygeoapi_app = _load_pygeoapi_app() mount_path = _mount_path() app.mount(mount_path, pygeoapi_app) diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py new file mode 100644 index 00000000..c789dc30 --- /dev/null +++ b/tests/test_pygeoapi_mount.py @@ -0,0 +1,50 @@ +import types + +from core import pygeoapi + + +def test_load_pygeoapi_app_imports_when_module_not_loaded(monkeypatch): + fake_module = types.SimpleNamespace(APP=object()) + import_calls = [] + + def fake_import_module(name): + import_calls.append(name) + return fake_module + + monkeypatch.delitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + raising=False, + ) + monkeypatch.setattr( + pygeoapi.importlib, + "import_module", + fake_import_module, + ) + + app = pygeoapi._load_pygeoapi_app() + + assert app is fake_module.APP + assert import_calls == ["pygeoapi.starlette_app"] + + +def test_load_pygeoapi_app_reloads_when_module_already_loaded(monkeypatch): + existing_module = types.SimpleNamespace(APP=object()) + reloaded_module = types.SimpleNamespace(APP=object()) + reload_calls = [] + + def fake_reload(module): + reload_calls.append(module) + return reloaded_module + + monkeypatch.setitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + existing_module, + ) + monkeypatch.setattr(pygeoapi.importlib, "reload", fake_reload) + + app = pygeoapi._load_pygeoapi_app() + + assert app is reloaded_module.APP + assert reload_calls == [existing_module] From 2dd6628dacd34f1a214b49982aea6264ed90a639 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 25 Mar 2026 15:34:04 -0600 Subject: [PATCH 118/163] fix: add unmatched locations to the import process and update test assertions --- cli/project_area_import.py | 1 + tests/test_cli_commands.py | 59 ++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/cli/project_area_import.py b/cli/project_area_import.py index ade08a86..a16d4147 100644 --- a/cli/project_area_import.py +++ b/cli/project_area_import.py @@ -119,6 +119,7 @@ def import_project_area_boundaries( ) if not groups: + unmatched_locations.append(location_name) new_group = Group( name=location_name, group_type=group_type, diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6db32370..375c77df 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -68,7 +68,9 @@ def __exit__(self, exc_type, exc, tb): assert "Refreshed 7 materialized view(s)." in result.output -def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): +def test_refresh_pygeoapi_materialized_views_custom_and_concurrently( + monkeypatch, +): executed_sql: list[str] = [] execution_options: list[dict[str, object]] = [] @@ -190,6 +192,7 @@ class FakeSession: def __init__(self): self.commit_called = False self.scalar_calls = 0 + self.added = [] def scalars(self, stmt): self.scalar_calls += 1 @@ -197,6 +200,9 @@ def scalars(self, stmt): return FakeScalarResult([fake_group]) return FakeScalarResult([]) + def add(self, obj): + self.added.append(obj) + def commit(self): self.commit_called = True @@ -208,15 +214,20 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr("cli.project_area_import.session_ctx", lambda: FakeSessionCtx()) + monkeypatch.setattr( + "cli.project_area_import.session_ctx", + lambda: FakeSessionCtx(), + ) runner = CliRunner() result = runner.invoke(cli, ["import-project-area-boundaries"]) assert result.exit_code == 0, result.output assert "Fetched 2 feature(s)." in result.output - assert "Matched 1 group row(s)." in result.output + assert "Matched 2 group row(s)." in result.output + assert "Created 1 group(s)." in result.output assert "Updated 1 group project area(s)." in result.output + assert "Skipped 0 unchanged group(s)." in result.output assert "Unmatched locations: Missing Group" in result.output assert fake_group.project_area is not None @@ -408,7 +419,10 @@ def fake_run(command, check, env, capture_output, text): return SimpleNamespace(returncode=0) monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) - monkeypatch.setattr("cli.db_restore.get_storage_bucket", fake_get_storage_bucket) + monkeypatch.setattr( + "cli.db_restore.get_storage_bucket", + fake_get_storage_bucket, + ) monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) monkeypatch.setenv("POSTGRES_HOST", "localhost") monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") @@ -471,7 +485,10 @@ def fake_well_inventory(file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -500,7 +517,8 @@ def write_summary(path, comparison): captured["result_count"] = len(comparison.results) monkeypatch.setattr( - "transfers.transfer_results_builder.TransferResultsBuilder", FakeBuilder + "transfers.transfer_results_builder.TransferResultsBuilder", + FakeBuilder, ) summary_path = tmp_path / "metrics" / "summary.md" @@ -558,7 +576,10 @@ def fake_well_inventory(_file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -624,14 +645,28 @@ def fake_upload(file_path, *, pretty_json=False): def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ - End-to-end CLI invocation should create FieldEvent, Sample, and Observation rows. + End-to-end CLI invocation should create FieldEvent, Sample, + and Observation rows. """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ - 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 - CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + header = ( + "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" + ) + row = ( + f"CLI Tester,{well_name},2025-02-15T08:00:00-07:00," + "2025-02-15T10:30:00-07:00,Groundwater Team,electric tape," + f"1.5,stable,42.5,approved,{notes}" + ) + csv_text = textwrap.dedent( + f"""\ + {header} + {row} + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 61fedba44fe5512912a2bd8539ec1042520db192 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:34:27 +0000 Subject: [PATCH 119/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 375c77df..fb351fbd 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -661,12 +661,10 @@ def _write_csv(path: Path, *, well_name: str, notes: str): "2025-02-15T10:30:00-07:00,Groundwater Team,electric tape," f"1.5,stable,42.5,approved,{notes}" ) - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ {header} {row} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 1f09e7f3b594a360fe12230cb0e02b719b32c353 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:24:43 +0000 Subject: [PATCH 120/163] build(deps): bump requests from 2.32.5 to 2.33.0 (#618) Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d7e7e960..3aca006a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "python-jose>=3.5.0", "python-multipart==0.0.22", "pytz==2025.2", - "requests==2.32.5", + "requests==2.33.0", "rsa==4.9.1", "scramp==1.4.8", "sentry-sdk[fastapi]==2.53.0", diff --git a/requirements.txt b/requirements.txt index e8bfcbfd..0dd0649d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1545,9 +1545,9 @@ regex==2026.2.19 \ --hash=sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904 \ --hash=sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c # via dateparser -requests==2.32.5 \ - --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ - --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf +requests==2.33.0 \ + --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ + --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 # via # cloud-sql-python-connector # google-api-core diff --git a/uv.lock b/uv.lock index 8d4d6b22..03b2dc95 100644 --- a/uv.lock +++ b/uv.lock @@ -1601,7 +1601,7 @@ requires-dist = [ { name = "python-jose", specifier = ">=3.5.0" }, { name = "python-multipart", specifier = "==0.0.22" }, { name = "pytz", specifier = "==2025.2" }, - { name = "requests", specifier = "==2.32.5" }, + { name = "requests", specifier = "==2.33.0" }, { name = "rsa", specifier = "==4.9.1" }, { name = "scramp", specifier = "==1.4.8" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.53.0" }, @@ -2540,7 +2540,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2548,9 +2548,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] From 53fee18538d3cfa1cc821d850afbe45c32e83f86 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 26 Mar 2026 12:46:22 -0600 Subject: [PATCH 121/163] feat(water-level-import): normalize standalone CSV schema Introduce a dedicated schema for the standalone water-level importer and move header/value normalization into the schema layer. Changes: - add WaterLevelCsvRow to schemas/water_level_csv.py - support source CSV headers for field_staff_2, field_staff_3, water_level_date_time, and measuring_person - preserve compatibility aliases for measurement_date_time, sampler,and mp_height_ft - normalize blank optional values to None - normalize naive water-level datetimes from America/Denver to UTC - canonicalize sample method aliases such as electric tape and steel tape - ignore source-only columns hold(not saved) and cut(not saved) - wire services/water_level_csv.py to use the shared schema - add focused schema tests for aliasing and datetime normalization Impact: - the standalone importer now understands the new source CSV shape without changing persistence behavior yet - datetime handling is explicit and consistent instead of relying on mixed input formats downstream - later stages can add validation and persistence rules on top of a stable, reusable normalization layer --- schemas/water_level_csv.py | 141 +++++++++++++++++++++- services/water_level_csv.py | 173 +++++++-------------------- tests/test_water_level_csv_schema.py | 72 +++++++++++ 3 files changed, 256 insertions(+), 130 deletions(-) create mode 100644 tests/test_water_level_csv_schema.py diff --git a/schemas/water_level_csv.py b/schemas/water_level_csv.py index 00d71eaf..572ad86c 100644 --- a/schemas/water_level_csv.py +++ b/schemas/water_level_csv.py @@ -13,7 +13,142 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from pydantic import BaseModel +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated + +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, +) +from pydantic.functional_validators import BeforeValidator + +from services.util import convert_dt_tz_naive_to_tz_aware + +WATER_LEVEL_REQUIRED_FIELDS = [ + "well_name_point_id", + "field_event_date_time", + "field_staff", + "water_level_date_time", + "measuring_person", + "sample_method", +] + +WATER_LEVEL_HEADER_ALIASES = { + "measurement_date_time": "water_level_date_time", + "sampler": "measuring_person", + "mp_height_ft": "mp_height", +} + +WATER_LEVEL_IGNORED_FIELDS = { + "hold(not saved)", + "cut(not saved)", +} + +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() +} + + +def empty_str_to_none(value): + if isinstance(value, str) and value.strip() == "": + return None + return value + + +OptionalText = Annotated[str | None, BeforeValidator(empty_str_to_none)] +OptionalFloat = Annotated[float | None, BeforeValidator(empty_str_to_none)] + + +def _normalize_datetime_to_utc(value: datetime | str) -> datetime: + if isinstance(value, str): + value = datetime.fromisoformat(value) + elif not isinstance(value, datetime): + raise ValueError("value must be a datetime or ISO format string") + + if value.tzinfo is None: + value = convert_dt_tz_naive_to_tz_aware(value, "America/Denver") + + return value.astimezone(timezone.utc) + + +class WaterLevelCsvRow(BaseModel): + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + well_name_point_id: str + field_event_date_time: datetime + field_staff: str + field_staff_2: OptionalText = None + field_staff_3: OptionalText = None + water_level_date_time: datetime = Field( + validation_alias=AliasChoices( + "water_level_date_time", + "measurement_date_time", + ) + ) + measuring_person: str = Field( + validation_alias=AliasChoices("measuring_person", "sampler") + ) + sample_method: str + mp_height: OptionalFloat = Field( + default=None, + validation_alias=AliasChoices("mp_height", "mp_height_ft"), + ) + level_status: OptionalText = None + depth_to_water_ft: OptionalFloat = None + data_quality: OptionalText = None + water_level_notes: OptionalText = None + + @property + def measurement_date_time(self) -> datetime: + return self.water_level_date_time + + @property + def sampler(self) -> str: + return self.measuring_person + + @classmethod + def required_fields(cls) -> list[str]: + return list(WATER_LEVEL_REQUIRED_FIELDS) + + @classmethod + def header_aliases(cls) -> dict[str, str]: + return dict(WATER_LEVEL_HEADER_ALIASES) + + @classmethod + def ignored_fields(cls) -> set[str]: + return set(WATER_LEVEL_IGNORED_FIELDS) + + @staticmethod + def canonicalize_sample_method(value: str) -> str: + normalized = value.strip().lower() + if normalized in SAMPLE_METHOD_ALIASES: + return SAMPLE_METHOD_ALIASES[normalized] + if normalized in SAMPLE_METHOD_CANONICAL: + return SAMPLE_METHOD_CANONICAL[normalized] + return value.strip() + + @field_validator("sample_method") + @classmethod + def normalize_sample_method(cls, value: str) -> str: + return cls.canonicalize_sample_method(value) + + @field_validator( + "field_event_date_time", + "water_level_date_time", + mode="before", + ) + @classmethod + def normalize_datetime_field(cls, value: datetime | str) -> datetime: + return _normalize_datetime_to_utc(value) class WaterLevelBulkUploadSummary(BaseModel): @@ -29,8 +164,8 @@ class WaterLevelBulkUploadRow(BaseModel): sample_id: int observation_id: int measurement_date_time: str - level_status: str - data_quality: str + level_status: str | None + data_quality: str | None class WaterLevelBulkUploadResponse(BaseModel): diff --git a/services/water_level_csv.py b/services/water_level_csv.py index f695fcd1..e8a74317 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -27,43 +27,19 @@ from db import Thing, FieldEvent, FieldActivity, Sample, Observation, Parameter from db.engine import session_ctx -from pydantic import BaseModel, ConfigDict, ValidationError, field_validator +from pydantic import ValidationError +from schemas.water_level_csv import ( + WaterLevelCsvRow, + WATER_LEVEL_REQUIRED_FIELDS, + WATER_LEVEL_HEADER_ALIASES, + WATER_LEVEL_IGNORED_FIELDS, +) from sqlalchemy import select from sqlalchemy.orm import Session -# 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", -] - -HEADER_ALIASES: dict[str, str] = { - "measuring_person": "sampler", - "water_level_date_time": "measurement_date_time", -} - -# 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() -} +REQUIRED_FIELDS: List[str] = list(WATER_LEVEL_REQUIRED_FIELDS) +HEADER_ALIASES: dict[str, str] = dict(WATER_LEVEL_HEADER_ALIASES) +IGNORED_FIELDS: set[str] = set(WATER_LEVEL_IGNORED_FIELDS) @dataclass @@ -84,91 +60,13 @@ class _ValidatedRow: 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 + mp_height: float | None + depth_to_water_ft: float | None + level_status: str | None + data_quality: str | None water_level_notes: str | None -class WaterLevelCsvRow(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - - field_staff: str - well_name_point_id: str - field_event_date_time: datetime - measurement_date_time: datetime - sampler: str - sample_method: str - mp_height: float - level_status: str - depth_to_water_ft: float - data_quality: str - water_level_notes: str | None = None - - @field_validator( - "field_staff", - "well_name_point_id", - "sampler", - "sample_method", - "level_status", - "data_quality", - ) - @classmethod - def _require_value(cls, value: str) -> str: - if value is None or value == "": - raise ValueError("value is required") - return value - - @field_validator("sampler") - @classmethod - def _validate_sampler(cls, value: str) -> str: - if value.lower() not in VALID_SAMPLERS: - raise ValueError( - f"Invalid sampler '{value}'. Expected one of: {sorted(VALID_SAMPLERS)}" - ) - return value - - @field_validator("level_status") - @classmethod - def _validate_level_status(cls, value: str) -> str: - if value.lower() not in VALID_LEVEL_STATUSES: - raise ValueError( - f"Invalid level_status '{value}'. Expected one of: {sorted(VALID_LEVEL_STATUSES)}" - ) - return value - - @field_validator("data_quality") - @classmethod - def _validate_data_quality(cls, value: str) -> str: - if value.lower() not in VALID_DATA_QUALITIES: - raise ValueError( - f"Invalid data_quality '{value}'. Expected one of: {sorted(VALID_DATA_QUALITIES)}" - ) - return value - - @field_validator("sample_method") - @classmethod - def _normalize_sample_method(cls, value: str) -> str: - normalized = value.lower() - if normalized in SAMPLE_METHOD_ALIASES: - return SAMPLE_METHOD_ALIASES[normalized] - if normalized in SAMPLE_METHOD_CANONICAL: - return SAMPLE_METHOD_CANONICAL[normalized] - raise ValueError( - f"Invalid sample_method '{value}'. Expected one of: {sorted(SAMPLE_METHOD_ALIASES.keys())}" - ) - - @field_validator("water_level_notes", mode="before") - @classmethod - def _empty_to_none(cls, value: str | None) -> str | None: - if value is None: - return None - if isinstance(value, str) and value.strip() == "": - return None - return value - - def bulk_upload_water_levels( source_file: str | Path | bytes | BinaryIO, *, pretty_json: bool = False ) -> BulkUploadResult: @@ -180,7 +78,12 @@ def bulk_upload_water_levels( msg = f"File not found: {source_file}" payload = _build_payload([], [], 0, 0, 1, errors=[msg]) stdout = _serialize_payload(payload, pretty_json) - return BulkUploadResult(exit_code=1, stdout=stdout, stderr=msg, payload=payload) + return BulkUploadResult( + exit_code=1, + stdout=stdout, + stderr=msg, + payload=payload, + ) validation_errors: list[str] = [] created_rows: list[dict[str, Any]] = [] @@ -188,7 +91,8 @@ def bulk_upload_water_levels( 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. + # 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) @@ -198,7 +102,11 @@ def bulk_upload_water_levels( if not validation_errors: try: - created_rows = _create_records(session, parameter_id, valid_rows) + created_rows = _create_records( + session, + parameter_id, + valid_rows, + ) session.commit() except Exception as exc: # pragma: no cover - safety fallback session.rollback() @@ -209,7 +117,7 @@ def bulk_upload_water_levels( summary = { "total_rows_processed": len(csv_rows), - "total_rows_imported": len(created_rows) if not validation_errors else 0, + "total_rows_imported": (len(created_rows) if not validation_errors else 0), "validation_errors_or_warnings": _count_rows_with_issues(validation_errors), } payload = _build_payload( @@ -288,16 +196,22 @@ def _read_csv( for k, v in row.items(): if k is None: continue - key = HEADER_ALIASES.get(k.strip(), k.strip()) + stripped_key = k.strip() + if stripped_key in IGNORED_FIELDS: + continue + key = HEADER_ALIASES.get(stripped_key, stripped_key) value = v.strip() if isinstance(v, str) else v or "" - # If both alias and canonical header are present, preserve first non-empty value. + # If both alias and canonical header are present, + # preserve the first non-empty value. if key in normalized_row and normalized_row[key] and not value: continue normalized_row[key] = value rows.append(normalized_row) headers = [ - HEADER_ALIASES.get(h.strip(), h.strip()) for h in (reader.fieldnames or []) + HEADER_ALIASES.get(h.strip(), h.strip()) + for h in (reader.fieldnames or []) + if h is not None and h.strip() not in IGNORED_FIELDS ] return headers, rows @@ -350,10 +264,10 @@ def _validate_rows( raw={**normalized}, well=well, field_staff=model.field_staff, - sampler=model.sampler, + sampler=model.measuring_person, sample_method_term=model.sample_method, field_event_dt=model.field_event_date_time, - measurement_dt=model.measurement_date_time, + measurement_dt=model.water_level_date_time, mp_height=model.mp_height, depth_to_water_ft=model.depth_to_water_ft, level_status=model.level_status, @@ -413,7 +327,8 @@ def _create_records( "field_activity_id": field_activity.id, "sample_id": sample.id, "observation_id": observation.id, - "measurement_date_time": row.raw["measurement_date_time"], + "measurement_date_time": row.raw.get("water_level_date_time") + or row.raw.get("measurement_date_time"), "level_status": row.level_status, "data_quality": row.data_quality, } @@ -431,7 +346,11 @@ def _build_field_event_notes(row: _ValidatedRow) -> str | None: def _build_observation_notes(row: _ValidatedRow) -> str | None: - parts = [f"Level status: {row.level_status}", f"Data quality: {row.data_quality}"] + parts = [] + if row.level_status is not None: + parts.append(f"Level status: {row.level_status}") + if row.data_quality is not None: + parts.append(f"Data quality: {row.data_quality}") notes = " | ".join(parts) return notes or None diff --git a/tests/test_water_level_csv_schema.py b/tests/test_water_level_csv_schema.py new file mode 100644 index 00000000..d8344471 --- /dev/null +++ b/tests/test_water_level_csv_schema.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from schemas.water_level_csv import WaterLevelCsvRow + + +def test_water_level_csv_row_normalizes_source_headers_and_naive_datetimes(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + field_staff_2="", + field_staff_3="", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="electric tape", + mp_height="1.5", + level_status="Water level not affected", + depth_to_water_ft="45.2", + data_quality="Approved", + water_level_notes="Initial measurement", + ) + + assert row.field_staff_2 is None + assert row.field_staff_3 is None + assert row.sample_method == "Electric tape measurement (E-probe)" + assert row.field_event_date_time == datetime( + 2025, 2, 15, 15, 0, tzinfo=timezone.utc + ) + assert row.water_level_date_time == datetime( + 2025, 2, 15, 17, 30, tzinfo=timezone.utc + ) + + +def test_water_level_csv_row_accepts_legacy_alias_headers(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00-07:00", + field_staff="Tech 1", + measurement_date_time="2025-02-15T10:30:00-07:00", + sampler="Tech 1", + sample_method="Steel-tape measurement", + mp_height_ft="2.5", + ) + + assert row.measuring_person == "Tech 1" + assert row.sampler == "Tech 1" + assert row.mp_height == 2.5 + assert row.measurement_date_time == datetime( + 2025, 2, 15, 17, 30, tzinfo=timezone.utc + ) + + +def test_water_level_csv_row_normalizes_blank_optional_values_to_none(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + mp_height="", + level_status="", + depth_to_water_ft="", + data_quality="", + water_level_notes="", + ) + + assert row.mp_height is None + assert row.level_status is None + assert row.depth_to_water_ft is None + assert row.data_quality is None + assert row.water_level_notes is None From eccf31f9bf15735757ad8303e36dfb3c11e1a50f Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 26 Mar 2026 16:31:29 -0600 Subject: [PATCH 122/163] feat(water-level-import): enhance validation and resolve mp_height Tighten standalone water-level validation and add well-backed measuring point height resolution ahead of the persistence refactor. Changes: - enforce measuring_person, datetime ordering, and conditional level_status validation in WaterLevelCsvRow - reject negative depth_to_water_ft values while allowing negative mp_height values for surveyed reference points below land surface - validate data_quality against the DataQuality enum and normalize shorthand level_status values to canonical groundwater terms - resolve measuring_point_height from CSV input or the well's current MeasuringPointHistory-derived value - reject rows where depth_to_water_ft implies a water level below the bottom of the well - update schema, service, and API tests for the new rules --- schemas/water_level_csv.py | 79 +++++++++++++++++- services/water_level_csv.py | 77 +++++++++++++++++- tests/test_observation.py | 8 +- tests/test_water_level_csv_schema.py | 113 +++++++++++++++++++++++++- tests/test_water_level_csv_service.py | 86 ++++++++++++++++++++ 5 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 tests/test_water_level_csv_service.py diff --git a/schemas/water_level_csv.py b/schemas/water_level_csv.py index 572ad86c..32f33333 100644 --- a/schemas/water_level_csv.py +++ b/schemas/water_level_csv.py @@ -18,12 +18,14 @@ from datetime import datetime, timezone from typing import Annotated +from core.enums import DataQuality, GroundwaterLevelReason, SampleMethod from pydantic import ( AliasChoices, BaseModel, ConfigDict, Field, field_validator, + model_validator, ) from pydantic.functional_validators import BeforeValidator @@ -56,6 +58,20 @@ SAMPLE_METHOD_CANONICAL = { value.lower(): value for value in SAMPLE_METHOD_ALIASES.values() } +GROUNDWATER_LEVEL_REASON_ALIASES = { + "dry": "Site was dry", + "obstructed": ("Obstruction was encountered in the well (no level recorded)"), + "obstruction": ("Obstruction was encountered in the well (no level recorded)"), + "flowing": ( + "Site was flowing. Water level or head couldn't be measured " + "w/out additional equipment." + ), + "flowing recently": "Site was flowing recently.", + "pumped": "Site was being pumped", + "pumped recently": "Site was pumped recently", + "not affected": "Water level not affected", + "other": "Other conditions exist that would affect the level (remarks)", +} def empty_str_to_none(value): @@ -80,6 +96,20 @@ def _normalize_datetime_to_utc(value: datetime | str) -> datetime: return value.astimezone(timezone.utc) +def _canonicalize_enum_value( + value: str | None, enum_cls, field_name: str +) -> str | None: + if value is None: + return None + + normalized = value.strip().lower() + for item in enum_cls: + if item.value.lower() == normalized: + return item.value + + raise ValueError(f"Unknown {field_name}: {value}") + + class WaterLevelCsvRow(BaseModel): model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) @@ -139,7 +169,11 @@ def canonicalize_sample_method(value: str) -> str: @field_validator("sample_method") @classmethod def normalize_sample_method(cls, value: str) -> str: - return cls.canonicalize_sample_method(value) + return _canonicalize_enum_value( + cls.canonicalize_sample_method(value), + SampleMethod, + "sample_method", + ) @field_validator( "field_event_date_time", @@ -150,6 +184,49 @@ def normalize_sample_method(cls, value: str) -> str: def normalize_datetime_field(cls, value: datetime | str) -> datetime: return _normalize_datetime_to_utc(value) + @field_validator("depth_to_water_ft") + @classmethod + def validate_non_negative_depth_to_water(cls, value: float | None) -> float | None: + if value is not None and value < 0: + raise ValueError("depth_to_water_ft must be greater than or equal to 0") + return value + + @field_validator("level_status") + @classmethod + def normalize_level_status(cls, value: str | None) -> str | None: + if value is not None: + value = GROUNDWATER_LEVEL_REASON_ALIASES.get(value.strip().lower(), value) + return _canonicalize_enum_value(value, GroundwaterLevelReason, "level_status") + + @field_validator("data_quality") + @classmethod + def normalize_data_quality(cls, value: str | None) -> str | None: + return _canonicalize_enum_value(value, DataQuality, "data_quality") + + @model_validator(mode="after") + def validate_row_constraints(self) -> WaterLevelCsvRow: + field_staff = [ + staff + for staff in (self.field_staff, self.field_staff_2, self.field_staff_3) + if staff + ] + if self.measuring_person not in field_staff: + raise ValueError( + "measuring_person must match one of field_staff, " + "field_staff_2, or field_staff_3" + ) + + if self.water_level_date_time < self.field_event_date_time: + raise ValueError( + "water_level_date_time must be greater than or equal to " + "field_event_date_time" + ) + + if self.depth_to_water_ft is None and self.level_status is None: + raise ValueError("level_status is required when depth_to_water_ft is blank") + + return self + class WaterLevelBulkUploadSummary(BaseModel): total_rows_processed: int diff --git a/services/water_level_csv.py b/services/water_level_csv.py index e8a74317..d7cf4f2c 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -35,7 +35,7 @@ WATER_LEVEL_IGNORED_FIELDS, ) from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload REQUIRED_FIELDS: List[str] = list(WATER_LEVEL_REQUIRED_FIELDS) HEADER_ALIASES: dict[str, str] = dict(WATER_LEVEL_HEADER_ALIASES) @@ -61,6 +61,9 @@ class _ValidatedRow: field_event_dt: datetime measurement_dt: datetime mp_height: float | None + resolved_mp_height: float | int | None + existing_mp_height: float | int | None + mp_height_differs_from_history: bool depth_to_water_ft: float | None level_status: str | None data_quality: str | None @@ -251,13 +254,36 @@ def _validate_rows( 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) + sql = ( + select(Thing) + .options(selectinload(Thing.measuring_points)) + .where( + Thing.name == well_name, + Thing.thing_type == "water well", + ) + ) 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 + ( + resolved_mp_height, + existing_mp_height, + mp_height_differs_from_history, + ) = _resolve_measuring_point_height(well, model.mp_height) + + depth_error = _validate_depth_to_water_against_well( + idx, + well, + model.depth_to_water_ft, + resolved_mp_height, + ) + if depth_error: + errors.append(depth_error) + continue + valid_rows.append( _ValidatedRow( row_index=idx, @@ -269,6 +295,9 @@ def _validate_rows( field_event_dt=model.field_event_date_time, measurement_dt=model.water_level_date_time, mp_height=model.mp_height, + resolved_mp_height=resolved_mp_height, + existing_mp_height=existing_mp_height, + mp_height_differs_from_history=mp_height_differs_from_history, depth_to_water_ft=model.depth_to_water_ft, level_status=model.level_status, data_quality=model.data_quality, @@ -279,6 +308,44 @@ def _validate_rows( return valid_rows, errors +def _resolve_measuring_point_height( + well: Thing, csv_mp_height: float | None +) -> tuple[float | int | None, float | int | None, bool]: + existing_mp_height = well.measuring_point_height + if csv_mp_height is not None: + return ( + csv_mp_height, + existing_mp_height, + (existing_mp_height is not None and csv_mp_height != existing_mp_height), + ) + + return existing_mp_height, existing_mp_height, False + + +def _validate_depth_to_water_against_well( + row_index: int, + well: Thing, + depth_to_water_ft: float | None, + resolved_mp_height: float | int | None, +) -> str | None: + if ( + depth_to_water_ft is None + or resolved_mp_height is None + or well.well_depth is None + ): + return None + + corrected_depth_to_water = depth_to_water_ft - resolved_mp_height + if corrected_depth_to_water >= well.well_depth: + return ( + f"Row {row_index}: depth_to_water_ft minus measuring point height " + f"({corrected_depth_to_water}) must be less than well depth " + f"({well.well_depth})" + ) + + return None + + def _create_records( session: Session, parameter_id: int, rows: list[_ValidatedRow] ) -> list[dict[str, Any]]: @@ -327,8 +394,10 @@ def _create_records( "field_activity_id": field_activity.id, "sample_id": sample.id, "observation_id": observation.id, - "measurement_date_time": row.raw.get("water_level_date_time") - or row.raw.get("measurement_date_time"), + "measurement_date_time": ( + row.raw.get("water_level_date_time") + or row.raw.get("measurement_date_time") + ), "level_status": row.level_status, "data_quality": row.data_quality, } diff --git a/tests/test_observation.py b/tests/test_observation.py index 386c823c..34d2bc41 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -161,12 +161,12 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): water_well_thing.name, "2025-02-15T08:00:00-07:00", "2025-02-15T10:30:00-07:00", - "Groundwater Team", + "A Lopez", "electric tape", "1.5", - "stable", - "45.2", - "approved", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", "Initial measurement", ] ) diff --git a/tests/test_water_level_csv_schema.py b/tests/test_water_level_csv_schema.py index d8344471..09abed2d 100644 --- a/tests/test_water_level_csv_schema.py +++ b/tests/test_water_level_csv_schema.py @@ -1,7 +1,12 @@ from datetime import datetime, timezone +import pytest +from pydantic import ValidationError + from schemas.water_level_csv import WaterLevelCsvRow +DATA_QUALITY_VALUE = "Water level accurate to within two hundreths of a foot" + def test_water_level_csv_row_normalizes_source_headers_and_naive_datetimes(): row = WaterLevelCsvRow( @@ -16,7 +21,7 @@ def test_water_level_csv_row_normalizes_source_headers_and_naive_datetimes(): mp_height="1.5", level_status="Water level not affected", depth_to_water_ft="45.2", - data_quality="Approved", + data_quality=DATA_QUALITY_VALUE, water_level_notes="Initial measurement", ) @@ -40,6 +45,7 @@ def test_water_level_csv_row_accepts_legacy_alias_headers(): sampler="Tech 1", sample_method="Steel-tape measurement", mp_height_ft="2.5", + depth_to_water_ft="45.2", ) assert row.measuring_person == "Tech 1" @@ -59,14 +65,115 @@ def test_water_level_csv_row_normalizes_blank_optional_values_to_none(): measuring_person="Tech 1", sample_method="Steel-tape measurement", mp_height="", - level_status="", + level_status="Water level not affected", depth_to_water_ft="", data_quality="", water_level_notes="", ) assert row.mp_height is None - assert row.level_status is None + assert row.level_status == "Water level not affected" assert row.depth_to_water_ft is None assert row.data_quality is None assert row.water_level_notes is None + + +def test_water_level_csv_row_requires_measuring_person_to_match_field_staff(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + field_staff_2="Tech 2", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 3", + sample_method="Steel-tape measurement", + depth_to_water_ft="45.2", + ) + + assert ( + "measuring_person must match one of field_staff, field_staff_2, " + "or field_staff_3" + ) in str(exc.value) + + +def test_water_level_csv_row_requires_level_status_when_depth_is_blank(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="", + level_status="", + ) + + assert "level_status is required when depth_to_water_ft is blank" in str(exc.value) + + +def test_water_level_csv_row_rejects_water_level_before_field_event(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T10:30:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T08:00:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="45.2", + ) + + assert ( + "water_level_date_time must be greater than or equal to " + "field_event_date_time" + ) in str(exc.value) + + +def test_water_level_csv_row_canonicalizes_case_insensitive_lexicon_values(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="electric tape measurement (e-probe)", + depth_to_water_ft="", + level_status="dry", + data_quality=DATA_QUALITY_VALUE.lower(), + ) + + assert row.sample_method == "Electric tape measurement (E-probe)" + assert row.level_status == "Site was dry" + assert row.data_quality == DATA_QUALITY_VALUE + + +def test_water_level_csv_row_allows_negative_mp_height(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + mp_height="-0.1", + depth_to_water_ft="45.2", + ) + + assert row.mp_height == -0.1 + + +def test_water_level_csv_row_rejects_negative_depth_to_water(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="-0.1", + ) + + assert "depth_to_water_ft must be greater than or equal to 0" in str(exc.value) diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py new file mode 100644 index 00000000..a1c1e177 --- /dev/null +++ b/tests/test_water_level_csv_service.py @@ -0,0 +1,86 @@ +from datetime import date + +from db import Thing +from db.measuring_point_history import MeasuringPointHistory +from services.water_level_csv import ( + _resolve_measuring_point_height, + _validate_depth_to_water_against_well, +) + + +def _build_well( + *, + well_depth: float | None = None, + measuring_point_height: float | None = None, +) -> Thing: + well = Thing(name="AR0001", thing_type="water well", well_depth=well_depth) + well.measuring_points = [] + if measuring_point_height is not None: + well.measuring_points.append( + MeasuringPointHistory( + start_date=date(2025, 1, 1), + measuring_point_height=measuring_point_height, + ) + ) + return well + + +def test_resolve_measuring_point_height_prefers_csv_value(): + well = _build_well(measuring_point_height=3.5) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, 4.0) + + assert resolved_mp_height == 4.0 + assert existing_mp_height == 3.5 + assert differs is True + + +def test_resolve_measuring_point_height_falls_back_to_well_history(): + well = _build_well(measuring_point_height=3.5) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height == 3.5 + assert existing_mp_height == 3.5 + assert differs is False + + +def test_resolve_measuring_point_height_allows_missing_values(): + well = _build_well() + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height is None + assert existing_mp_height is None + assert differs is False + + +def test_validate_depth_to_water_against_well_rejects_depth_past_bottom(): + well = _build_well(well_depth=10.0) + + error = _validate_depth_to_water_against_well(4, well, 12.5, 1.0) + + assert ( + error == "Row 4: depth_to_water_ft minus measuring point height (11.5) " + "must be less than well depth (10.0)" + ) + + +def test_validate_depth_to_water_against_well_skips_when_height_unavailable(): + well = _build_well(well_depth=10.0) + + error = _validate_depth_to_water_against_well(4, well, 12.5, None) + + assert error is None From 6a09881afc9b0107caffe3915577c5cdd02bf5ba Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 26 Mar 2026 17:02:18 -0600 Subject: [PATCH 123/163] feat(water-level-import): add idempotent groundwater persistence Switch the standalone importer from placeholder inserts to deterministic, structured groundwater persistence. Changes: - replace random UUID sample names with deterministic well-inventory-style sample names based on well name and measurement timestamp - look up existing standalone groundwater-level samples by deterministic sample name and update them on rerun instead of inserting duplicates - persist samples with sample_matrix="groundwater" - resolve the groundwater-level parameter by name and groundwater matrix, creating it when missing - write structured groundwater observation fields instead of relying on note strings - persist resolved_mp_height to Observation.measuring_point_height - persist level_status to Observation.groundwater_level_reason - persist data_quality to Observation.nma_data_quality - persist water_level_notes directly to sample and observation notes - add tests for deterministic sample naming, idempotent reruns, and API-level structured groundwater persistence Impact: - rerunning the same standalone water-level CSV is now idempotent - imported groundwater observations are stored with the intended groundwater semantics instead of the earlier placeholder behavior - downstream queries can rely on structured observation fields rather than extracting meaning from generated note text --- services/water_level_csv.py | 154 ++++++++++++++++++-------- tests/test_observation.py | 11 +- tests/test_water_level_csv_service.py | 103 ++++++++++++++++- 3 files changed, 217 insertions(+), 51 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index d7cf4f2c..ba7654d8 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -19,7 +19,6 @@ import io import json import re -import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -352,39 +351,40 @@ def _create_records( 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) + sample_name = _build_sample_name(row) + sample = _find_existing_imported_sample(session, row, sample_name) + + if sample is None: + 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) + observation = Observation(sample=sample) + session.add(field_event) + session.add(field_activity) + session.add(sample) + session.add(observation) + else: + field_activity = sample.field_activity + field_event = field_activity.field_event + observation = _find_existing_observation(sample, parameter_id) + if observation is None: + observation = Observation(sample=sample) + session.add(observation) + + field_event.event_date = row.field_event_dt + field_event.notes = _build_field_event_notes(row) + field_activity.notes = f"Sampler: {row.sampler}" + + _apply_sample_values(sample, row, sample_name) + _apply_observation_values(observation, row, parameter_id) session.flush() created.append( @@ -406,6 +406,62 @@ def _create_records( return created +def _build_sample_name(row: _ValidatedRow) -> str: + return f"{row.well.name}-WL-{row.measurement_dt.strftime('%Y%m%d%H%M')}" + + +def _find_existing_imported_sample( + session: Session, row: _ValidatedRow, sample_name: str +) -> Sample | None: + sql = ( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .options( + selectinload(Sample.field_activity).selectinload(FieldActivity.field_event), + selectinload(Sample.observations), + ) + .where( + Thing.name == row.well.name, + Thing.thing_type == "water well", + FieldActivity.activity_type == "groundwater level", + Sample.sample_name == sample_name, + ) + .order_by(Sample.id.asc()) + ) + return session.scalars(sql).first() + + +def _find_existing_observation(sample: Sample, parameter_id: int) -> Observation | None: + for observation in sample.observations: + if observation.parameter_id == parameter_id: + return observation + return sample.observations[0] if sample.observations else None + + +def _apply_sample_values(sample: Sample, row: _ValidatedRow, sample_name: str) -> None: + sample.sample_date = row.measurement_dt + sample.sample_name = sample_name + sample.sample_matrix = "groundwater" + sample.sample_method = row.sample_method_term + sample.qc_type = "Normal" + sample.notes = row.water_level_notes + + +def _apply_observation_values( + observation: Observation, row: _ValidatedRow, parameter_id: int +) -> None: + observation.observation_datetime = row.measurement_dt + observation.parameter_id = parameter_id + observation.value = row.depth_to_water_ft + observation.unit = "ft" + observation.measuring_point_height = row.resolved_mp_height + observation.groundwater_level_reason = row.level_status + observation.nma_data_quality = row.data_quality + observation.notes = row.water_level_notes + + def _build_field_event_notes(row: _ValidatedRow) -> str | None: parts = [f"Field staff: {row.field_staff}"] if row.water_level_notes: @@ -414,22 +470,24 @@ def _build_field_event_notes(row: _ValidatedRow) -> str | None: return notes or None -def _build_observation_notes(row: _ValidatedRow) -> str | None: - parts = [] - if row.level_status is not None: - parts.append(f"Level status: {row.level_status}") - if row.data_quality is not None: - parts.append(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") + sql = select(Parameter.id).where( + Parameter.parameter_name == "groundwater level", + Parameter.matrix == "groundwater", + ) parameter_id = session.scalars(sql).one_or_none() - if parameter_id is None: - raise RuntimeError("Groundwater level parameter is not initialized") - return parameter_id + if parameter_id is not None: + return parameter_id + + parameter = Parameter( + parameter_name="groundwater level", + matrix="groundwater", + parameter_type="Field Parameter", + default_unit="ft", + ) + session.add(parameter) + session.flush() + return parameter.id # ============= EOF ============================================= diff --git a/tests/test_observation.py b/tests/test_observation.py index 34d2bc41..b68453b0 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -188,10 +188,19 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): with session_ctx() as session: observation = session.get(Observation, row["observation_id"]) assert observation is not None + sample = session.get(Sample, row["sample_id"]) + assert sample is not None + assert sample.sample_name == f"{water_well_thing.name}-WL-202502151730" + assert sample.sample_matrix == "groundwater" + assert observation.groundwater_level_reason == "Water level not affected" + assert ( + observation.nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) + assert observation.measuring_point_height == 1.5 # 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"]) diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index a1c1e177..7f619d7d 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -1,11 +1,16 @@ -from datetime import date +from datetime import date, datetime, timezone +from types import SimpleNamespace -from db import Thing +from db import FieldActivity, FieldEvent, Observation, Sample, Thing from db.measuring_point_history import MeasuringPointHistory +from db.engine import session_ctx from services.water_level_csv import ( + _build_sample_name, _resolve_measuring_point_height, _validate_depth_to_water_against_well, + bulk_upload_water_levels, ) +from sqlalchemy import select def _build_well( @@ -84,3 +89,97 @@ def test_validate_depth_to_water_against_well_skips_when_height_unavailable(): error = _validate_depth_to_water_against_well(4, well, 12.5, None) assert error is None + + +def test_build_sample_name_uses_deterministic_well_inventory_style_format(): + row = SimpleNamespace( + well=SimpleNamespace(name="AR0001"), + measurement_dt=datetime(2025, 2, 15, 10, 30, tzinfo=timezone.utc), + ) + + assert _build_sample_name(row) == "AR0001-WL-202502151030" + + +def test_bulk_upload_water_levels_is_idempotent(water_well_thing): + csv_content = "\n".join( + [ + ",".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", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ] + ) + + first = bulk_upload_water_levels(csv_content.encode("utf-8")) + second = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert first.exit_code == 0, first.payload + assert second.exit_code == 0, second.payload + assert ( + first.payload["water_levels"][0]["sample_id"] + == second.payload["water_levels"][0]["sample_id"] + ) + assert ( + first.payload["water_levels"][0]["observation_id"] + == second.payload["water_levels"][0]["observation_id"] + ) + + with session_ctx() as session: + samples = session.scalars( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + ) + ).all() + observations = session.scalars( + select(Observation) + .join(Sample, Observation.sample_id == Sample.id) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + ) + ).all() + + assert len(samples) == 1 + assert len(observations) == 1 + assert samples[0].sample_name == "Test Well-WL-202502151730" + assert samples[0].sample_matrix == "groundwater" + assert observations[0].groundwater_level_reason == "Water level not affected" + assert ( + observations[0].nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) + assert observations[0].measuring_point_height == 1.5 From 2f2f923daa61575f85d738156bb05d9d12d14780 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 27 Mar 2026 09:34:05 -0600 Subject: [PATCH 124/163] feat(water-level-import): add best-effort row savepoints Change the standalone water-level importer from all-or-nothing writes to best-effort row imports using per-row savepoints. Changes: - stop skipping all persistence when some rows have validation errors - wrap each standalone water-level row import in a nested transaction so one bad row does not roll back successful rows - continue processing remaining rows after row-level persistence errors - record row-specific persistence errors in the existing validation_errors output - report total_rows_imported as the number of successfully written rows even when the file contains failures - add mixed-row service coverage proving a valid row still imports when another row in the same file fails Impact: - standalone water-level CSV imports are now operationally resilient in the same way as well inventory imports - users can import good rows from partially bad files without manually splitting the source CSV first - later stages can refine API and CLI reporting without changing the core best-effort import behavior --- services/water_level_csv.py | 141 +++++++++++++------------- tests/test_water_level_csv_service.py | 66 ++++++++++++ 2 files changed, 137 insertions(+), 70 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index ba7654d8..d01c9ce0 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -91,8 +91,6 @@ def bulk_upload_water_levels( 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) @@ -102,24 +100,22 @@ def bulk_upload_water_levels( 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() + try: + parameter_id = _get_groundwater_level_parameter_id(session) + created_rows, persistence_errors = _create_records( + session, + parameter_id, + valid_rows, + ) + validation_errors.extend(persistence_errors) + session.commit() + except Exception as exc: # pragma: no cover - safety fallback + session.rollback() + validation_errors.append(str(exc)) summary = { "total_rows_processed": len(csv_rows), - "total_rows_imported": (len(created_rows) if not validation_errors else 0), + "total_rows_imported": len(created_rows), "validation_errors_or_warnings": _count_rows_with_issues(validation_errors), } payload = _build_payload( @@ -347,63 +343,68 @@ def _validate_depth_to_water_against_well( def _create_records( session: Session, parameter_id: int, rows: list[_ValidatedRow] -) -> list[dict[str, Any]]: +) -> tuple[list[dict[str, Any]], list[str]]: created: list[dict[str, Any]] = [] + errors: list[str] = [] for row in rows: - sample_name = _build_sample_name(row) - sample = _find_existing_imported_sample(session, row, sample_name) - - if sample is None: - 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) - observation = Observation(sample=sample) - session.add(field_event) - session.add(field_activity) - session.add(sample) - session.add(observation) - else: - field_activity = sample.field_activity - field_event = field_activity.field_event - observation = _find_existing_observation(sample, parameter_id) - if observation is None: - observation = Observation(sample=sample) - session.add(observation) - - field_event.event_date = row.field_event_dt - field_event.notes = _build_field_event_notes(row) - field_activity.notes = f"Sampler: {row.sampler}" - - _apply_sample_values(sample, row, sample_name) - _apply_observation_values(observation, row, parameter_id) - 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.get("water_level_date_time") - or row.raw.get("measurement_date_time") - ), - "level_status": row.level_status, - "data_quality": row.data_quality, - } - ) + try: + with session.begin_nested(): + sample_name = _build_sample_name(row) + sample = _find_existing_imported_sample(session, row, sample_name) + + if sample is None: + 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) + observation = Observation(sample=sample) + session.add(field_event) + session.add(field_activity) + session.add(sample) + session.add(observation) + else: + field_activity = sample.field_activity + field_event = field_activity.field_event + observation = _find_existing_observation(sample, parameter_id) + if observation is None: + observation = Observation(sample=sample) + session.add(observation) + + field_event.event_date = row.field_event_dt + field_event.notes = _build_field_event_notes(row) + field_activity.notes = f"Sampler: {row.sampler}" + + _apply_sample_values(sample, row, sample_name) + _apply_observation_values(observation, row, parameter_id) + 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.get("water_level_date_time") + or row.raw.get("measurement_date_time") + ), + "level_status": row.level_status, + "data_quality": row.data_quality, + } + ) + except Exception as exc: # pragma: no cover - exercised via DB tests + errors.append(f"Row {row.row_index}: {exc}") - return created + return created, errors def _build_sample_name(row: _ValidatedRow) -> str: diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 7f619d7d..4d882642 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -183,3 +183,69 @@ def test_bulk_upload_water_levels_is_idempotent(water_well_thing): == "Water level accurate to within two hundreths of a foot" ) assert observations[0].measuring_point_height == 1.5 + + +def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( + water_well_thing, +): + csv_content = "\n".join( + [ + ",".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", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ",".join( + [ + "A Lopez", + "Unknown Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Bad row", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 1 + assert result.payload["summary"]["total_rows_processed"] == 2 + assert result.payload["summary"]["total_rows_imported"] == 1 + assert result.payload["summary"]["validation_errors_or_warnings"] == 1 + assert len(result.payload["water_levels"]) == 1 + assert ( + "Unknown well_name_point_id 'Unknown Well'" + in result.payload["validation_errors"][0] + ) From 250f2c7a5781ea92b0fb8f5381bfa56dde8ab9b4 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 27 Mar 2026 09:59:42 -0600 Subject: [PATCH 125/163] feat: enhance GCS upload handling with async support and improved error logging --- api/asset.py | 4 +++- services/gcs_helper.py | 47 ++++++++++++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/api/asset.py b/api/asset.py index 90afdf9c..f52122bc 100644 --- a/api/asset.py +++ b/api/asset.py @@ -18,6 +18,7 @@ from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError +from starlette.concurrency import run_in_threadpool from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT, HTTP_204_NO_CONTENT from api.pagination import CustomPage @@ -84,7 +85,8 @@ async def upload_asset( ) -> dict: from services.gcs_helper import gcs_upload - uri, blob_name = gcs_upload(file, bucket) + # GCS client calls are synchronous and can block for large uploads. + uri, blob_name = await run_in_threadpool(gcs_upload, file, bucket) return { "uri": uri, "storage_path": blob_name, diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 237af5cb..55f2cdf2 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -16,7 +16,9 @@ import base64 import datetime import json +import logging import os +from functools import lru_cache from hashlib import md5 from fastapi import UploadFile @@ -27,8 +29,12 @@ GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME") GCS_BUCKET_BASE_URL = f"https://storage.cloud.google.com/{GCS_BUCKET_NAME}/uploads" +GCS_LOOKUP_TIMEOUT_SECS = float(os.environ.get("GCS_LOOKUP_TIMEOUT_SECS", "15")) +GCS_UPLOAD_TIMEOUT_SECS = float(os.environ.get("GCS_UPLOAD_TIMEOUT_SECS", "120")) +logger = logging.getLogger(__name__) +@lru_cache(maxsize=1) def get_storage_client(): from google.cloud import storage from google.oauth2 import service_account @@ -51,14 +57,16 @@ def get_storage_client(): return client -def get_storage_bucket(client=None, bucket: str = None): - if client is None: - client = get_storage_client() +@lru_cache(maxsize=8) +def _get_cached_bucket(bucket_name: str): + return get_storage_client().bucket(bucket_name) - if bucket is None: - bucket = GCS_BUCKET_NAME - return client.bucket(bucket) +def get_storage_bucket(client=None, bucket: str = None): + bucket_name = bucket or GCS_BUCKET_NAME + if client is not None: + return client.bucket(bucket_name) + return _get_cached_bucket(bucket_name) def make_blob_name_and_uri(file): @@ -78,12 +86,16 @@ def gcs_upload(file: UploadFile, bucket=None): file.file.seek(0) blob_name, uri = make_blob_name_and_uri(file) - eblob = bucket.get_blob(blob_name) + eblob = bucket.get_blob(blob_name, timeout=GCS_LOOKUP_TIMEOUT_SECS) if not eblob: blob = bucket.blob(blob_name) file.file.seek(0) - blob.upload_from_file(file.file, content_type=file.content_type) + blob.upload_from_file( + file.file, + content_type=file.content_type, + timeout=GCS_UPLOAD_TIMEOUT_SECS, + ) return uri, blob_name @@ -93,11 +105,20 @@ def gcs_remove(uri: str, bucket): def add_signed_url(asset: Asset, bucket): - asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( - version="v4", - expiration=datetime.timedelta(minutes=15), - method="GET", - ) + try: + asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( + version="v4", + expiration=datetime.timedelta(minutes=15), + method="GET", + ) + except Exception: + logger.warning( + "Failed to generate signed URL for asset_id=%s storage_path=%s", + getattr(asset, "id", None), + getattr(asset, "storage_path", None), + exc_info=True, + ) + asset.signed_url = None return asset From 4c3eea000a338e5031bb0296fc26ea94b35865a3 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 27 Mar 2026 10:14:51 -0600 Subject: [PATCH 126/163] feat(water-level-import): align partial-success API and CLI behavior Update the standalone water-level API and CLI contract to reflect the best-effort row import behavior. Changes: - treat mixed-result standalone water-level imports as successful when at least one row imports - keep total-failure imports as non-zero/400 outcomes when no rows are written - preserve row-level validation and persistence errors in the existing validation_errors payload - update the CLI status banner to distinguish clean success from completed-with-issues using summary data instead of exit code alone - add API coverage for partial-success bulk uploads - add CLI coverage for completed-with-issues reporting - update end-to-end CLI water-level expectations to current importer semantics from earlier stages Impact: - API callers no longer see a hard failure when valid rows were imported successfully from a mixed file - CLI users get more accurate status reporting for partial-success imports without losing warning visibility - the external contract now matches the row-level atomicity and best-effort behavior of the importer itself --- cli/cli.py | 10 +++- services/water_level_csv.py | 2 +- tests/test_cli_commands.py | 57 +++++++++++++++--- tests/test_observation.py | 83 +++++++++++++++++++++++++++ tests/test_water_level_csv_service.py | 2 +- 5 files changed, 143 insertions(+), 11 deletions(-) diff --git a/cli/cli.py b/cli/cli.py index 8a91f5f4..50b2b24a 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -666,9 +666,16 @@ def water_levels_bulk_upload( payload = result.payload if isinstance(result.payload, dict) else {} summary = payload.get("summary", {}) validation_errors = payload.get("validation_errors", []) + rows_with_issues = summary.get("validation_errors_or_warnings", 0) - if result.exit_code == 0: + if result.exit_code == 0 and not rows_with_issues: typer.secho("[WATER LEVEL IMPORT] SUCCESS", fg=colors["ok"], bold=True) + elif result.exit_code == 0: + typer.secho( + "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES", + fg=colors["issue"], + bold=True, + ) else: typer.secho( "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES", @@ -709,7 +716,6 @@ def water_levels_bulk_upload( if summary: processed = summary.get("total_rows_processed", 0) imported = summary.get("total_rows_imported", 0) - rows_with_issues = summary.get("validation_errors_or_warnings", 0) typer.secho("SUMMARY", fg=colors["accent"], bold=True) label_width = 16 value_width = 8 diff --git a/services/water_level_csv.py b/services/water_level_csv.py index d01c9ce0..99dd0848 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -123,7 +123,7 @@ 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 + exit_code = 0 if created_rows or not validation_errors else 1 return BulkUploadResult( exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index fb351fbd..656dd91e 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -643,6 +643,39 @@ def fake_upload(file_path, *, pretty_json=False): assert captured["pretty_json"] is True +def test_water_levels_bulk_upload_reports_partial_success(monkeypatch, tmp_path): + csv_file = tmp_path / "water_levels.csv" + csv_file.write_text("col\nvalue\n") + + def fake_upload(_file_path, *, pretty_json=False): + assert pretty_json is False + return SimpleNamespace( + exit_code=0, + stdout="", + stderr="Row 2: Unknown well_name_point_id 'Bad Well'", + payload={ + "summary": { + "total_rows_processed": 2, + "total_rows_imported": 1, + "validation_errors_or_warnings": 1, + }, + "validation_errors": ["Row 2: Unknown well_name_point_id 'Bad Well'"], + "water_levels": [{}], + }, + ) + + monkeypatch.setattr("cli.service_adapter.water_levels_csv", fake_upload) + + runner = CliRunner() + result = runner.invoke( + cli, ["water-levels", "bulk-upload", "--file", str(csv_file)] + ) + + assert result.exit_code == 0, result.output + assert "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES" in result.output + assert "rows_with_issues" in result.output + + def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ End-to-end CLI invocation should create FieldEvent, Sample, @@ -658,13 +691,17 @@ def _write_csv(path: Path, *, well_name: str, notes: str): ) row = ( f"CLI Tester,{well_name},2025-02-15T08:00:00-07:00," - "2025-02-15T10:30:00-07:00,Groundwater Team,electric tape," - f"1.5,stable,42.5,approved,{notes}" + "2025-02-15T10:30:00-07:00,CLI Tester,electric tape," + f"1.5,Water level not affected,7.0," + "Water level accurate to within two hundreths of a foot," + f"{notes}" ) - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ {header} {row} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" @@ -697,10 +734,16 @@ def _write_csv(path: Path, *, well_name: str, notes: str): assert field_event.thing_id == water_well_thing.id assert sample.sample_method == "Electric tape measurement (E-probe)" - assert sample.sample_matrix == "water" - assert observation.value == 42.5 + assert sample.sample_matrix == "groundwater" + assert sample.sample_name == f"{water_well_thing.name}-WL-202502151730" + assert observation.value == 7.0 assert observation.measuring_point_height == 1.5 - assert observation.notes == "Level status: stable | Data quality: approved" + assert observation.notes == unique_notes + assert observation.groundwater_level_reason == "Water level not affected" + assert ( + observation.nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) assert ( field_event.notes == f"Field staff: CLI Tester | {unique_notes}" ), "Field event notes should capture field staff and notes" diff --git a/tests/test_observation.py b/tests/test_observation.py index b68453b0..d1806788 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -212,6 +212,89 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): session.commit() +def test_bulk_upload_groundwater_levels_api_partial_success(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 += "\n".join( + [ + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ",".join( + [ + "A Lopez", + "Bad Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Bad row", + ] + ), + ] + ) + + 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"] == 2 + assert data["summary"]["validation_errors_or_warnings"] == 1 + assert len(data["validation_errors"]) == 1 + assert "Bad Well" in data["validation_errors"][0] + + row = data["water_levels"][0] + with session_ctx() as session: + observation = session.get(Observation, row["observation_id"]) + sample = session.get(Sample, row["sample_id"]) + field_activity = session.get(FieldActivity, row["field_activity_id"]) + field_event = session.get(FieldEvent, row["field_event_id"]) + + if observation: + session.delete(observation) + if sample: + session.delete(sample) + if field_activity: + session.delete(field_activity) + if field_event: + session.delete(field_event) + session.commit() + + # PATCH tests ================================================================== diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 4d882642..3e10de66 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -240,7 +240,7 @@ def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( result = bulk_upload_water_levels(csv_content.encode("utf-8")) - assert result.exit_code == 1 + assert result.exit_code == 0 assert result.payload["summary"]["total_rows_processed"] == 2 assert result.payload["summary"]["total_rows_imported"] == 1 assert result.payload["summary"]["validation_errors_or_warnings"] == 1 From 5cfbe805be87a1446a1762dad02185813310d468 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:17:23 +0000 Subject: [PATCH 127/163] build(deps): bump ecdsa from 0.19.1 to 0.19.2 (#620) Bumps [ecdsa](https://github.com/tlsfuzzer/python-ecdsa) from 0.19.1 to 0.19.2. - [Release notes](https://github.com/tlsfuzzer/python-ecdsa/releases) - [Changelog](https://github.com/tlsfuzzer/python-ecdsa/blob/master/NEWS) - [Commits](https://github.com/tlsfuzzer/python-ecdsa/compare/python-ecdsa-0.19.1...python-ecdsa-0.19.2) --- updated-dependencies: - dependency-name: ecdsa dependency-version: 0.19.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0dd0649d..26a13f59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -486,9 +486,9 @@ dnspython==2.8.0 \ dotenv==0.9.9 \ --hash=sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9 # via ocotilloapi -ecdsa==0.19.1 \ - --hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \ - --hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61 +ecdsa==0.19.2 \ + --hash=sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930 \ + --hash=sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399 # via python-jose email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ From 83bc1d94923593870b2bf28b8b7f03b769789b94 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 27 Mar 2026 12:25:03 -0600 Subject: [PATCH 128/163] fix(well-transfer): exclude monitoring_status from Thing creation Filtered well transfers failed while seeding dev data because CreateWell dumps included monitoring_status, but Thing exposes that as a computed status-history property rather than a writable ORM field. Exclude it from Thing creation so the transfer can persist wells and let monitoring status continue to be recorded through StatusHistory. --- tests/test_well_transfer.py | 51 +++++++++++++++++++++++++++++++++++++ transfers/well_transfer.py | 1 + 2 files changed, 52 insertions(+) create mode 100644 tests/test_well_transfer.py diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py new file mode 100644 index 00000000..bae3133a --- /dev/null +++ b/tests/test_well_transfer.py @@ -0,0 +1,51 @@ +from types import SimpleNamespace + +from schemas.thing import CreateWell +from transfers import well_transfer as wt + + +class _FakeSession: + def __init__(self): + self.added = [] + self.expunge_calls = [] + + def add(self, obj): + self.added.append(obj) + + def expunge(self, obj): + self.expunge_calls.append(obj) + + +def test_persist_well_excludes_monitoring_status_from_thing_kwargs( + monkeypatch, +): + captured_kwargs = {} + + class FakeThing: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + monkeypatch.setattr(wt, "Thing", FakeThing) + + transferer = wt.WellTransferer() + session = _FakeSession() + row = SimpleNamespace(PointID="AR0001", WellID=12, LocationId=34) + payload = { + "data": CreateWell( + name="AR0001", + monitoring_status="Not currently monitored", + ), + "well_purposes": [], + "well_casing_materials": [], + } + batch_errors = [] + + well = transferer._persist_well(session, row, payload, batch_errors) + + assert well is session.added[0] + assert "monitoring_status" not in captured_kwargs + assert captured_kwargs["thing_type"] == "water well" + assert captured_kwargs["nma_pk_welldata"] == 12 + assert captured_kwargs["nma_pk_location"] == 34 + assert batch_errors == [] + assert session.expunge_calls == [] diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index d8e1c200..c5b5e598 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -93,6 +93,7 @@ "is_suitable_for_datalogger", "is_open", "well_status", + "monitoring_status", ] From fa257cd0047a52aeb3f4e0fd9866eb0d6504ff04 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 27 Mar 2026 15:24:34 -0600 Subject: [PATCH 129/163] fix(water-level-import): harden real-file import cleanup Address the remaining issues found during validation against a real standalone water-level CSV. Changes: - coerce measuring point height and well depth values to float before depth-to-water validation so DB-backed Decimal values do not crash the importer - replace the nested savepoint context-manager flow with explicit savepoint handling to avoid the SQLAlchemy "nested transaction already deassociated from connection" warning - add regression coverage for measuring-point history values coming back as Decimal Impact: - real water-level CSV imports no longer fail on float-vs-Decimal depth checks - best-effort row imports complete without the savepoint warning seen in mixed-result runs - rerunning the same real file remains idempotent --- services/water_level_csv.py | 121 ++++++++++++++------------ tests/test_water_level_csv_service.py | 15 ++++ 2 files changed, 79 insertions(+), 57 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 99dd0848..f56cd805 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -307,6 +307,8 @@ def _resolve_measuring_point_height( well: Thing, csv_mp_height: float | None ) -> tuple[float | int | None, float | int | None, bool]: existing_mp_height = well.measuring_point_height + if existing_mp_height is not None: + existing_mp_height = float(existing_mp_height) if csv_mp_height is not None: return ( csv_mp_height, @@ -323,19 +325,19 @@ def _validate_depth_to_water_against_well( depth_to_water_ft: float | None, resolved_mp_height: float | int | None, ) -> str | None: - if ( - depth_to_water_ft is None - or resolved_mp_height is None - or well.well_depth is None - ): + well_depth = well.well_depth + if well_depth is not None: + well_depth = float(well_depth) + + if depth_to_water_ft is None or resolved_mp_height is None or well_depth is None: return None corrected_depth_to_water = depth_to_water_ft - resolved_mp_height - if corrected_depth_to_water >= well.well_depth: + if corrected_depth_to_water >= well_depth: return ( f"Row {row_index}: depth_to_water_ft minus measuring point height " f"({corrected_depth_to_water}) must be less than well depth " - f"({well.well_depth})" + f"({well_depth})" ) return None @@ -348,60 +350,65 @@ def _create_records( errors: list[str] = [] for row in rows: + savepoint = session.begin_nested() try: - with session.begin_nested(): - sample_name = _build_sample_name(row) - sample = _find_existing_imported_sample(session, row, sample_name) - - if sample is None: - 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_name = _build_sample_name(row) + sample = _find_existing_imported_sample(session, row, sample_name) + + if sample is None: + 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) + observation = Observation(sample=sample) + session.add(field_event) + session.add(field_activity) + session.add(sample) + session.add(observation) + else: + field_activity = sample.field_activity + field_event = field_activity.field_event + observation = _find_existing_observation(sample, parameter_id) + if observation is None: observation = Observation(sample=sample) - session.add(field_event) - session.add(field_activity) - session.add(sample) session.add(observation) - else: - field_activity = sample.field_activity - field_event = field_activity.field_event - observation = _find_existing_observation(sample, parameter_id) - if observation is None: - observation = Observation(sample=sample) - session.add(observation) - - field_event.event_date = row.field_event_dt - field_event.notes = _build_field_event_notes(row) - field_activity.notes = f"Sampler: {row.sampler}" - - _apply_sample_values(sample, row, sample_name) - _apply_observation_values(observation, row, parameter_id) - 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.get("water_level_date_time") - or row.raw.get("measurement_date_time") - ), - "level_status": row.level_status, - "data_quality": row.data_quality, - } - ) + + field_event.event_date = row.field_event_dt + field_event.notes = _build_field_event_notes(row) + field_activity.notes = f"Sampler: {row.sampler}" + + _apply_sample_values(sample, row, sample_name) + _apply_observation_values(observation, row, parameter_id) + session.flush() + savepoint.commit() + + 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.get("water_level_date_time") + or row.raw.get("measurement_date_time") + ), + "level_status": row.level_status, + "data_quality": row.data_quality, + } + ) except Exception as exc: # pragma: no cover - exercised via DB tests + if savepoint.is_active: + savepoint.rollback() + else: + session.expire_all() errors.append(f"Row {row.row_index}: {exc}") return created, errors diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 3e10de66..6b487ada 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -1,4 +1,5 @@ from datetime import date, datetime, timezone +from decimal import Decimal from types import SimpleNamespace from db import FieldActivity, FieldEvent, Observation, Sample, Thing @@ -58,6 +59,20 @@ def test_resolve_measuring_point_height_falls_back_to_well_history(): assert differs is False +def test_resolve_measuring_point_height_coerces_decimal_history_value(): + well = _build_well(measuring_point_height=Decimal("3.5")) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height == 3.5 + assert existing_mp_height == 3.5 + assert differs is False + + def test_resolve_measuring_point_height_allows_missing_values(): well = _build_well() From 44102db035fe9c02672a04feae38dcecd124b18c Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 27 Mar 2026 15:58:04 -0600 Subject: [PATCH 130/163] test(bdd): align water-level CSV feature coverage with current importer behavior - update standalone water-level BDD scenarios for partial-success imports - switch step data to current canonical headers and groundwater vocabularies - add coverage for legacy alias headers and measuring_person validation - fix shared CSV step compatibility by populating file_content in test context --- tests/features/steps/water-levels-csv.py | 147 ++++++++++++++++++----- tests/features/water-level-csv.feature | 93 +++++++------- 2 files changed, 161 insertions(+), 79 deletions(-) diff --git a/tests/features/steps/water-levels-csv.py b/tests/features/steps/water-levels-csv.py index 4a8d6b57..05257163 100644 --- a/tests/features/steps/water-levels-csv.py +++ b/tests/features/steps/water-levels-csv.py @@ -23,27 +23,50 @@ from db import Observation from db.engine import session_ctx from services.water_level_csv import bulk_upload_water_levels +from tests.features.environment import ( + add_location, + add_measuring_point_history, + add_well, +) REQUIRED_FIELDS: List[str] = [ "field_staff", "well_name_point_id", "field_event_date_time", - "measurement_date_time", - "sampler", + "water_level_date_time", + "measuring_person", "sample_method", +] +OPTIONAL_FIELDS = [ + "field_staff_2", + "field_staff_3", "mp_height", "level_status", "depth_to_water_ft", "data_quality", + "water_level_notes", +] +VALID_SAMPLE_METHODS = [ + "Electric tape measurement (E-probe)", + "Steel-tape measurement", +] +VALID_LEVEL_STATUSES = ["Water level not affected", "Site was dry"] +VALID_DATA_QUALITIES = [ + "Water level accurate to within two hundreths of a foot", + "None", ] -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 "wells" not in context.objects or not context.objects["wells"]: + with session_ctx() as session: + loc_1 = add_location(context, session) + loc_2 = add_location(context, session) + well_1 = add_well(context, session, loc_1, name_num=101) + well_2 = add_well(context, session, loc_2, name_num=102) + add_measuring_point_history(context, session, well_1) + add_measuring_point_history(context, session, well_2) + if not hasattr(context, "well_names"): context.well_names = [well.name for well in context.objects["wells"]] return context.well_names @@ -53,16 +76,19 @@ 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 + field_staff = "A Lopez" if index == 1 else "B Chen" return { - "field_staff": "A Lopez" if index == 1 else "B Chen", + "field_staff": field_staff, + "field_staff_2": "", + "field_staff_3": "", "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)], + "field_event_date_time": f"2025-02-{measurement_day:02d}T08:00:00", + "water_level_date_time": f"2025-02-{measurement_day:02d}T10:30:00", + "measuring_person": field_staff, "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", + "depth_to_water_ft": "7.0" if index == 1 else "", "data_quality": VALID_DATA_QUALITIES[(index - 1) % len(VALID_DATA_QUALITIES)], "water_level_notes": "Initial measurement" if index == 1 else "Follow-up", } @@ -89,6 +115,7 @@ def _write_csv_to_context(context: Context) -> None: temp_file.close() context.csv_file = str(Path(temp_file.name)) context.csv_raw_text = csv_text + context.file_content = csv_text def _set_rows( @@ -146,21 +173,53 @@ def step_given_each_well_name_point_id_value_matches_an_existing_well(context: C @given( - '"measurement_date_time" values are valid ISO 8601 timestamps with timezone offsets (e.g. "2025-02-15T10:30:00-08:00")' + '"field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00")' ) -def step_step_step(context: Context): +def step_given_field_event_date_time_values_are_valid_naive_iso_datetimes( + context: Context, +): for row in context.csv_rows: - assert row["measurement_date_time"].startswith("2025-02") - assert "T" in row["measurement_date_time"] + assert row["field_event_date_time"].startswith("2025-02") + assert "T" in row["field_event_date_time"] + assert "+" not in row["field_event_date_time"] + assert row["field_event_date_time"].count(":") == 2 -# @given("the water level CSV includes optional fields when available:") -# def step_impl(context: Context): -# field_name = context.table.headings[0] -# optional_fields = [row[field_name].strip() for row in context.table] -# headers = set(context.csv_headers) -# missing = [field for field in optional_fields if field not in headers] -# assert not missing, f"Missing optional headers: {missing}" +@given( + '"water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00")' +) +def step_given_water_level_date_time_values_are_valid_naive_iso_datetimes( + context: Context, +): + for row in context.csv_rows: + assert row["water_level_date_time"].startswith("2025-02") + assert "T" in row["water_level_date_time"] + assert "+" not in row["water_level_date_time"] + assert row["water_level_date_time"].count(":") == 2 + + +@given( + 'when provided, "sample_method", "level_status", and "data_quality" values are valid lexicon values' +) +def step_given_lexicon_values_are_valid(context: Context): + for row in context.csv_rows: + if row.get("sample_method"): + assert row["sample_method"] in VALID_SAMPLE_METHODS + if row.get("level_status"): + assert row["level_status"] in VALID_LEVEL_STATUSES + if row.get("data_quality"): + assert row["data_quality"] in VALID_DATA_QUALITIES + + +@given("the water level CSV includes optional fields when available:") +def step_given_the_water_level_csv_includes_optional_fields_when_available( + context: Context, +): + field_name = context.table.headings[0] + optional_fields = [row[field_name].strip() for row in context.table] + headers = set(context.csv_headers) + missing = [field for field in optional_fields if field not in headers] + assert not missing, f"Missing optional headers: {missing}" @when("I run the CLI command:") @@ -217,10 +276,26 @@ def step_then_stderr_should_be_empty(context: Context): # ============================================================================ # Scenario: Upload succeeds when required columns are present but reordered # ============================================================================ +@given( + "my water level CSV file uses legacy alias headers for measurement date, sampler, and measuring point height" +) +def step_given_my_water_level_csv_file_uses_legacy_alias_headers(context: Context): + rows = _build_valid_rows(context) + alias_rows = [] + for row in rows: + alias_row = dict(row) + alias_row["measurement_date_time"] = alias_row.pop("water_level_date_time") + alias_row["sampler"] = alias_row.pop("measuring_person") + alias_row["mp_height_ft"] = alias_row.pop("mp_height") + alias_rows.append(alias_row) + headers = list(alias_rows[0].keys()) + _set_rows(context, alias_rows, headers=headers) + + @given( "my water level CSV file contains all required headers but in a different column order" ) -def step_step_step_2(context: Context): +def step_given_my_water_level_csv_file_contains_reordered_headers(context: Context): rows = _build_valid_rows(context) headers = list(reversed(list(rows[0].keys()))) _set_rows(context, rows, headers=headers) @@ -299,13 +374,13 @@ def step_then_stderr_should_contain_a_validation_error_for_the_required_field( # Scenario: Upload fails due to invalid date formats # ============================================================================ @given( - 'my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field' + 'my CSV file contains invalid ISO 8601 date values in the "water_level_date_time" field' ) def step_step_step_6(context: Context): rows = _build_valid_rows(context, count=1) - rows[0]["measurement_date_time"] = "02/15/2025 10:30" + rows[0]["water_level_date_time"] = "02/15/2025 10:30" _set_rows(context, rows) - context.invalid_fields = ["measurement_date_time"] + context.invalid_fields = ["water_level_date_time"] @then("stderr should contain validation errors identifying the invalid field and row") @@ -323,7 +398,7 @@ def step_then_stderr_should_contain_validation_errors_identifying_the_invalid_fi # 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"' + 'my CSV file contains values that cannot be parsed as numeric in numeric fields such as "mp_height" or "depth_to_water_ft"' ) def step_step_step_7(context: Context): rows = _build_valid_rows(context, count=1) @@ -337,21 +412,31 @@ def step_step_step_7(context: Context): # 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"' + 'my CSV file contains invalid lexicon values for "sample_method", "level_status", or "data_quality"' ) def step_step_step_8(context: Context): rows = _build_valid_rows(context, count=1) - rows[0]["sampler"] = "Unknown Team" rows[0]["sample_method"] = "mystery" rows[0]["level_status"] = "supercharged" rows[0]["data_quality"] = "bad" _set_rows(context, rows) context.invalid_fields = [ - "sampler", "sample_method", "level_status", "data_quality", ] +@given( + "my water level CSV file contains a row where measuring_person is not one of the supplied field staff" +) +def step_given_measuring_person_is_not_one_of_the_supplied_field_staff( + context: Context, +): + rows = _build_valid_rows(context, count=1) + rows[0]["measuring_person"] = "Unexpected Person" + _set_rows(context, rows) + context.invalid_fields = ["measuring_person"] + + # ============= EOF ============================================= diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index 701718c3..1c3b7511 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -1,21 +1,10 @@ -# features/cli/bulk_upload_water_levels.feature - @cli @backend @BDMS-TBD Feature: Bulk upload water level entries from CSV via CLI As a hydrogeologist or data specialist I want to upload a CSV file containing water level entry data for multiple wells using a CLI command - So that water level records can be created efficiently and accurately in the system - -# Background: -# Given the CLI binary "bdms" is installed and available on the PATH -# And I have a valid CLI configuration for the target environment -# And valid lexicon values exist for: -# | lexicon category | -# | sample_method | -# | level_status | -# | data_quality | + So that groundwater-level records can be created efficiently and accurately in the system @positive @happy_path @BDMS-TBD @cleanup_samples Scenario: Uploading a valid water level entry CSV containing required and optional fields @@ -27,21 +16,21 @@ Feature: Bulk upload water level entries from CSV via CLI | field_staff | | well_name_point_id | | field_event_date_time | + | water_level_date_time | | measuring_person | + | sample_method | And each "well_name_point_id" value matches an existing well And "field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00") And "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") And when provided, "sample_method", "level_status", and "data_quality" values are valid lexicon values - And the CSV includes optional fields when available: + And the water level CSV includes optional fields when available: | optional field name | | field_staff_2 | | field_staff_3 | - | water_level_date_time | - | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | | water_level_notes | When I run the CLI command: """ @@ -57,57 +46,52 @@ Feature: Bulk upload water level entries from CSV via CLI And stdout includes an array of created water level entry objects And stderr should be empty - @positive @validation @column_order @BDMS-TBD @cleanup_samples - Scenario: Upload succeeds when required columns are present but in a different order - Given my water level CSV file contains all required headers but in a different column order - And the CSV includes required fields: - | required field name | - | field_staff | - | well_name_point_id | - | field_event_date_time | - | measuring_person | + @positive @validation @aliases @BDMS-TBD @cleanup_samples + Scenario: Upload succeeds when legacy alias headers are used + Given my water level CSV file uses legacy alias headers for measurement date, sampler, and measuring point height When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ - # assumes users are entering datetimes as Mountain Time because well location is restricted to New Mexico - Then all datetime objects are assigned the correct Mountain Time timezone offset based on the date value. - And the command exits with code 0 + Then the command exits with code 0 + And stdout should be valid JSON And all water level entries are imported And stderr should be empty @positive @validation @extra_columns @BDMS-TBD @cleanup_samples - Scenario: Upload succeeds when CSV contains extra, unknown columns + Scenario: Upload succeeds when CSV contains extra columns Given my water level CSV file contains extra columns but is otherwise valid When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with code 0 + And stdout should be valid JSON And all water level entries are imported And stderr should be empty - ########################################################################### - # NEGATIVE VALIDATION SCENARIOS - ########################################################################### - - @negative @validation @BDMS-TBD - Scenario: No water level entries are imported when any row fails validation + @positive @validation @partial_success @BDMS-TBD @cleanup_samples + Scenario: Valid rows are imported when another row fails validation Given my water level CSV contains 3 rows with 2 valid rows and 1 row missing the required "well_name_point_id" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ - Then the command exits with a non-zero exit code + 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 | 3 | + | total_rows_imported | 2 | + | validation_errors_or_warnings | 1 | 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 water level CSV file contains a row missing the required "" field When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain a validation error for the "" field @@ -118,14 +102,16 @@ Feature: Bulk upload water level entries from CSV via CLI | field_staff | | well_name_point_id | | field_event_date_time | + | water_level_date_time | | measuring_person | + | sample_method | @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 "water_level_date_time" field When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row @@ -133,10 +119,10 @@ Feature: Bulk upload water level entries from CSV via CLI @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" + Given my CSV file contains values that cannot be parsed as numeric in numeric fields such as "mp_height" or "depth_to_water_ft" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row @@ -147,7 +133,18 @@ Feature: Bulk upload water level entries from CSV via CLI Given my CSV file contains invalid lexicon values for "sample_method", "level_status", or "data_quality" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json + """ + 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 @measuring_person @BDMS-TBD + Scenario: Upload fails when measuring_person does not match supplied field staff + Given my water level CSV file contains a row where measuring_person is not one of the supplied field staff + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row From 0785cac7372e08a9693a42ce1a34e5add28fb5d2 Mon Sep 17 00:00:00 2001 From: ksmuczynski <20096455+ksmuczynski@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:07:05 +0000 Subject: [PATCH 131/163] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 656dd91e..a1d4515f 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -696,12 +696,10 @@ def _write_csv(path: Path, *, well_name: str, notes: str): "Water level accurate to within two hundreths of a foot," f"{notes}" ) - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ {header} {row} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 0e9b5af5dcd8ee9750fe3ddec48594435b5e1621 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 28 Mar 2026 17:46:48 -0600 Subject: [PATCH 132/163] feat: enhance GCS upload handling with async support and improved error logging --- api/thing.py | 24 +++++++ schemas/well_details.py | 22 +++++++ services/well_details_helper.py | 110 ++++++++++++++++++++++++++++++++ tests/test_thing.py | 108 +++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 schemas/well_details.py create mode 100644 services/well_details_helper.py diff --git a/api/thing.py b/api/thing.py index 367237f5..4a8be334 100644 --- a/api/thing.py +++ b/api/thing.py @@ -51,6 +51,7 @@ UpdateThingIdLink, UpdateWellScreen, ) +from schemas.well_details import WellDetailsResponse from services.crud_helper import model_patcher, model_adder, model_deleter from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category @@ -68,6 +69,7 @@ modify_well_descriptor_tables, WELL_DESCRIPTOR_MODEL_MAP, ) +from services.well_details_helper import get_well_details_payload router = APIRouter(prefix="/thing", tags=["thing"]) @@ -177,6 +179,28 @@ async def get_well_by_id( return get_thing_of_a_thing_type_by_id(session, request, thing_id) +@router.get( + "/water-well/{thing_id}/details", + summary="Get water well details payload", + status_code=HTTP_200_OK, +) +async def get_well_details( + user: viewer_dependency, + thing_id: int, + session: session_dependency, + request: Request, +) -> WellDetailsResponse: + """ + Retrieve the consolidated payload needed to render the well details page. + Hydrograph series and map layer loading are intentionally handled separately. + """ + return get_well_details_payload( + session=session, + request=request, + thing_id=thing_id, + ) + + @router.get( "/water-well/{thing_id}/well-screen", summary="Get well screens by water well ID", diff --git a/schemas/well_details.py b/schemas/well_details.py new file mode 100644 index 00000000..fa94f154 --- /dev/null +++ b/schemas/well_details.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict, Field + +from schemas.contact import ContactResponse +from schemas.deployment import DeploymentResponse +from schemas.observation import GroundwaterLevelObservationResponse +from schemas.sample import SampleResponse +from schemas.sensor import SensorResponse +from schemas.thing import WellResponse, WellScreenResponse + + +class WellDetailsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + well: WellResponse + contacts: list[ContactResponse] = Field(default_factory=list) + sensors: list[SensorResponse] = Field(default_factory=list) + deployments: list[DeploymentResponse] = Field(default_factory=list) + well_screens: list[WellScreenResponse] = Field(default_factory=list) + recent_groundwater_level_observations: list[GroundwaterLevelObservationResponse] = ( + Field(default_factory=list) + ) + latest_field_event_sample: SampleResponse | None = None diff --git a/services/well_details_helper.py b/services/well_details_helper.py new file mode 100644 index 00000000..25f10088 --- /dev/null +++ b/services/well_details_helper.py @@ -0,0 +1,110 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload, selectinload + +from db import ( + Contact, + Deployment, + FieldActivity, + FieldEvent, + FieldEventParticipant, + Observation, + Parameter, + Sample, + Sensor, + ThingContactAssociation, + WellScreen, +) +from services.thing_helper import get_thing_of_a_thing_type_by_id + + +def get_well_details_payload( + session: Session, + request, + thing_id: int, + recent_observation_limit: int = 100, +): + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + + contacts = session.scalars( + select(Contact) + .join(ThingContactAssociation) + .where(ThingContactAssociation.thing_id == well.id) + .options( + selectinload(Contact.emails), + selectinload(Contact.phones), + selectinload(Contact.addresses), + selectinload(Contact.incomplete_nma_phones), + selectinload(Contact.thing_associations).selectinload( + ThingContactAssociation.thing + ), + ) + .order_by(Contact.id) + ).all() + + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + + deployments = session.scalars( + select(Deployment) + .where(Deployment.thing_id == well.id) + .options(selectinload(Deployment.sensor)) + .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) + ).all() + + well_screens = session.scalars( + select(WellScreen) + .where(WellScreen.thing_id == well.id) + .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) + ).all() + + groundwater_parameter_id = ( + session.query(Parameter) + .filter(Parameter.parameter_name == "groundwater level") + .one() + .id + ) + + recent_groundwater_level_observations = session.scalars( + select(Observation) + .join(Sample) + .join(FieldActivity) + .join(FieldEvent) + .where( + FieldEvent.thing_id == well.id, + Observation.parameter_id == groundwater_parameter_id, + ) + .options(selectinload(Observation.parameter)) + .order_by(Observation.observation_datetime.desc(), Observation.id.desc()) + .limit(recent_observation_limit) + ).all() + + latest_field_event_sample = None + if recent_groundwater_level_observations: + latest_sample_id = recent_groundwater_level_observations[0].sample_id + latest_field_event_sample = session.scalar( + select(Sample) + .where(Sample.id == latest_sample_id) + .options( + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing), + joinedload(Sample.field_event_participant).joinedload( + FieldEventParticipant.participant + ), + ) + ) + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + "well_screens": well_screens, + "recent_groundwater_level_observations": recent_groundwater_level_observations, + "latest_field_event_sample": latest_field_event_sample, + } diff --git a/tests/test_thing.py b/tests/test_thing.py index c9dd7d16..10f8e05f 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -560,6 +560,114 @@ def test_get_water_well_by_id(water_well_thing, location): assert data["current_location"] == expected_location +def test_get_water_well_details_payload( + water_well_thing, + field_event, + contact, + email, + phone, + address, + sensor, + sensor_to_water_well_thing_deployment, + thing_id_link, + well_screen, + groundwater_level_sample, + groundwater_level_observation, +): + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + + assert data["well"]["id"] == water_well_thing.id + assert data["well"]["alternate_ids"][0]["id"] == thing_id_link.id + assert data["contacts"][0]["id"] == contact.id + assert data["contacts"][0]["emails"][0]["id"] == email.id + assert data["contacts"][0]["phones"][0]["id"] == phone.id + assert data["contacts"][0]["addresses"][0]["id"] == address.id + assert data["sensors"][0]["id"] == sensor.id + assert data["deployments"][0]["id"] == sensor_to_water_well_thing_deployment.id + assert data["deployments"][0]["sensor"]["id"] == sensor.id + assert data["well_screens"][0]["id"] == well_screen.id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == groundwater_level_observation.id + ) + assert data["latest_field_event_sample"]["id"] == groundwater_level_sample.id + assert data["latest_field_event_sample"]["field_event"]["id"] == field_event.id + assert data["latest_field_event_sample"]["contact"]["id"] == contact.id + + +def test_get_water_well_details_payload_uses_latest_observation_sample( + water_well_thing, + groundwater_level_sample, + groundwater_level_observation, + field_event_participant, + sensor, +): + from db import Observation, Sample + + with session_ctx() as session: + later_sample = Sample( + field_activity_id=groundwater_level_sample.field_activity_id, + field_event_participant_id=field_event_participant.id, + sample_date="2025-01-02T12:00:00Z", + sample_name="later groundwater level sample", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + notes="later sample", + release_status="draft", + ) + session.add(later_sample) + session.commit() + session.refresh(later_sample) + + later_observation = Observation( + observation_datetime="2025-01-02T00:04:00Z", + sample_id=later_sample.id, + sensor_id=sensor.id, + parameter_id=groundwater_level_observation.parameter_id, + release_status="draft", + value=9.0, + unit="ft", + measuring_point_height=5.0, + groundwater_level_reason="Water level not affected", + ) + session.add(later_observation) + session.commit() + session.refresh(later_observation) + later_sample_id = later_sample.id + later_observation_id = later_observation.id + + try: + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + assert data["latest_field_event_sample"]["id"] == later_sample_id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == later_observation_id + ) + finally: + with session_ctx() as session: + later_observation = session.get(Observation, later_observation_id) + if later_observation is not None: + session.delete(later_observation) + later_sample = session.get(Sample, later_sample_id) + if later_sample is not None: + session.delete(later_sample) + session.commit() + + +def test_get_water_well_details_payload_404_not_found(): + response = client.get("/thing/water-well/999999/details") + + assert response.status_code == 404 + assert response.json()["detail"] == "Thing with ID 999999 not found." + + def test_get_water_well_by_id_404_not_found(water_well_thing): bad_id = 99999 response = client.get(f"/thing/water-well/{bad_id}") From cc296ee255005acce859f32664c0ce4d29efe70d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:38:34 +0000 Subject: [PATCH 133/163] build(deps): bump cryptography from 45.0.6 to 46.0.6 Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.6 to 46.0.6. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.6...46.0.6) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 76 +++++++++++++++++++++++++++++++----------------- uv.lock | 74 ++++++++++++++++++++++++++++------------------ 3 files changed, 97 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3aca006a..6aead1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "charset-normalizer==3.4.4", "click==8.3.1", "cloud-sql-python-connector==1.20.0", - "cryptography==45.0.6", + "cryptography==46.0.6", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", diff --git a/requirements.txt b/requirements.txt index 26a13f59..29881b06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -437,32 +437,56 @@ coverage==7.10.2 \ --hash=sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe \ --hash=sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb # via pytest-cov -cryptography==45.0.6 \ - --hash=sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5 \ - --hash=sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74 \ - --hash=sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394 \ - --hash=sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301 \ - --hash=sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08 \ - --hash=sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3 \ - --hash=sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b \ - --hash=sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402 \ - --hash=sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3 \ - --hash=sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0 \ - --hash=sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f \ - --hash=sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3 \ - --hash=sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9 \ - --hash=sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5 \ - --hash=sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719 \ - --hash=sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02 \ - --hash=sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2 \ - --hash=sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec \ - --hash=sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159 \ - --hash=sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453 \ - --hash=sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf \ - --hash=sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9 \ - --hash=sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016 \ - --hash=sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05 \ - --hash=sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42 +cryptography==46.0.6 \ + --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \ + --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \ + --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \ + --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \ + --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \ + --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \ + --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \ + --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \ + --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \ + --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \ + --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \ + --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \ + --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \ + --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \ + --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \ + --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \ + --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \ + --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \ + --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \ + --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \ + --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \ + --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \ + --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \ + --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \ + --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \ + --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \ + --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \ + --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \ + --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \ + --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \ + --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \ + --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \ + --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \ + --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \ + --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \ + --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \ + --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \ + --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \ + --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \ + --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \ + --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \ + --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \ + --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \ + --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \ + --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \ + --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \ + --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \ + --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \ + --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4 # via # authlib # cloud-sql-python-connector diff --git a/uv.lock b/uv.lock index 03b2dc95..7e98c6a5 100644 --- a/uv.lock +++ b/uv.lock @@ -593,37 +593,55 @@ wheels = [ [[package]] name = "cryptography" -version = "45.0.6" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, - { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, - { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, - { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, - { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, - { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, - { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, ] [[package]] @@ -1545,7 +1563,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.4.4" }, { name = "click", specifier = "==8.3.1" }, { name = "cloud-sql-python-connector", specifier = "==1.20.0" }, - { name = "cryptography", specifier = "==45.0.6" }, + { name = "cryptography", specifier = "==46.0.6" }, { name = "dnspython", specifier = "==2.8.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "email-validator", specifier = "==2.3.0" }, From a7cc3c987213190ea440343fb073f40a726afa5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:46:12 +0000 Subject: [PATCH 134/163] build(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 8.0.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v7.6...v8.0.0) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 5e793928..96f10356 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.6.0 + uses: astral-sh/setup-uv@v8.0.0 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 7700065d..ac800253 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.6.0 + uses: astral-sh/setup-uv@v8.0.0 with: version: "latest" diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 825c77ce..191c6b37 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -59,7 +59,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv (with cache) - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v4 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 79702dee..36fdf523 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.6.0 + uses: astral-sh/setup-uv@v8.0.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -155,7 +155,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.6.0 + uses: astral-sh/setup-uv@v8.0.0 with: enable-cache: true cache-dependency-glob: uv.lock From 47f12d6c6f63255a416703c83302ea4705025af7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:46:17 +0000 Subject: [PATCH 135/163] build(deps): bump codecov/codecov-action from 5 to 6 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .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 79702dee..f54b8bd6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -97,7 +97,7 @@ jobs: run: uv run pytest -vv --durations=20 --cov --cov-report=xml --junitxml=junit.xml --ignore=tests/transfers - name: Upload results to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} From 40fda2f27b312e0f0bfed402107e7d98b901ce20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:58:30 +0000 Subject: [PATCH 136/163] build(deps): bump the uv-non-major group with 42 updates (#626) --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.13.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: anyio dependency-version: 4.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: apitally[fastapi] dependency-version: 0.24.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: charset-normalizer dependency-version: 3.4.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: cloud-sql-python-connector dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: fastapi dependency-version: 0.135.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: fastapi-pagination dependency-version: 0.15.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: geoalchemy2 dependency-version: 0.18.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: google-api-core dependency-version: 2.30.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: google-auth dependency-version: 2.49.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: google-cloud-storage dependency-version: 3.10.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: googleapis-common-protos dependency-version: 1.73.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: greenlet dependency-version: 3.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: numpy dependency-version: 2.4.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: phonenumbers dependency-version: 9.0.26 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: proto-plus dependency-version: 1.27.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: pygments dependency-version: 2.20.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: pyjwt dependency-version: 2.12.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: requests dependency-version: 2.33.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: sentry-sdk[fastapi] dependency-version: 2.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: sqlalchemy dependency-version: 2.0.48 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: typer dependency-version: 0.24.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: uvicorn dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: yarl dependency-version: 1.23.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: apitally dependency-version: 0.24.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: babel dependency-version: 2.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: cfgv dependency-version: 3.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: coverage dependency-version: 7.13.5 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: dateparser dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: filelock dependency-version: 3.25.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: identify dependency-version: 2.6.18 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: nodeenv dependency-version: 1.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: opentelemetry-api dependency-version: 1.40.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: opentelemetry-sdk dependency-version: 1.40.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: opentelemetry-semantic-conventions dependency-version: 0.61b0 dependency-type: direct:production dependency-group: uv-non-major - dependency-name: platformdirs dependency-version: 4.9.4 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: pyyaml dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: regex dependency-version: 2026.3.32 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: rich dependency-version: 14.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major - dependency-name: sentry-sdk dependency-version: 2.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-non-major - dependency-name: werkzeug dependency-version: 3.1.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-non-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 48 +- requirements.txt | 1429 +++++++++++++++++++++++++++++++--------------- uv.lock | 713 ++++++++++++----------- 3 files changed, 1354 insertions(+), 836 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6aead1be..085a7cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,13 @@ requires-python = ">=3.13" dependencies = [ "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.3", + "aiohttp==3.13.4", "aiosignal==1.4.0", "aiosqlite==0.22.1", "alembic==1.18.4", "annotated-types==0.7.0", - "anyio==4.12.1", - "apitally[fastapi]==0.24.1", + "anyio==4.13.0", + "apitally[fastapi]==0.24.4", "asgiref==3.11.1", "asn1crypto==1.5.1", "asyncpg==0.31.0", @@ -23,25 +23,25 @@ dependencies = [ "cachetools==5.5.2", "certifi==2025.8.3", "cffi==2.0.0", - "charset-normalizer==3.4.4", + "charset-normalizer==3.4.6", "click==8.3.1", - "cloud-sql-python-connector==1.20.0", + "cloud-sql-python-connector==1.20.1", "cryptography==46.0.6", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", - "fastapi==0.129.0", - "fastapi-pagination==0.15.10", + "fastapi==0.135.2", + "fastapi-pagination==0.15.12", "frozenlist==1.8.0", - "geoalchemy2==0.18.1", - "google-api-core==2.29.0", - "google-auth==2.48.0", + "geoalchemy2==0.18.4", + "google-api-core==2.30.0", + "google-auth==2.49.1", "google-cloud-core==2.5.0", - "google-cloud-storage==3.9.0", + "google-cloud-storage==3.10.1", "google-crc32c==1.8.0", "google-resumable-media==2.8.0", - "googleapis-common-protos==1.72.0", - "greenlet==3.3.1", + "googleapis-common-protos==1.73.1", + "greenlet==3.3.2", "gunicorn==23.0.0", "h11==0.16.0", "httpcore==1.0.9", @@ -53,17 +53,17 @@ dependencies = [ "mako==1.3.10", "markupsafe==3.0.3", "multidict==6.7.1", - "numpy==2.4.2", + "numpy==2.4.4", "packaging==26.0", "pandas==2.3.2", "pandas-stubs~=2.3.2", "pg8000==1.31.5", - "phonenumbers==9.0.24", + "phonenumbers==9.0.26", "pillow==12.1.1", "pluggy==1.6.0", "pre-commit==4.5.1", "propcache==0.4.1", - "proto-plus==1.27.1", + "proto-plus==1.27.2", "protobuf==6.33.5", "psycopg2-binary>=2.9.10", "pyasn1==0.6.3", @@ -71,8 +71,8 @@ dependencies = [ "pycparser==3.0", "pydantic==2.12.5", "pydantic-core==2.41.5", - "pygments==2.19.2", - "pyjwt==2.12.0", + "pygments==2.20.0", + "pyjwt==2.12.1", "pygeoapi==0.22.0", "pyproj==3.7.2", "pyshp==2.3.1", @@ -82,27 +82,27 @@ dependencies = [ "python-jose>=3.5.0", "python-multipart==0.0.22", "pytz==2025.2", - "requests==2.33.0", + "requests==2.33.1", "rsa==4.9.1", "scramp==1.4.8", - "sentry-sdk[fastapi]==2.53.0", + "sentry-sdk[fastapi]==2.56.0", "shapely==2.1.2", "six==1.17.0", "sniffio==1.3.1", - "sqlalchemy==2.0.46", + "sqlalchemy==2.0.48", "sqlalchemy-continuum==1.6.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", "starlette==0.52.1", "starlette-admin[i18n]==0.16.0", - "typer==0.23.1", + "typer==0.24.1", "typing-extensions==4.15.0", "typing-inspection==0.4.2", "tzdata==2025.3", "urllib3==2.6.3", "utm==0.8.1", - "uvicorn==0.40.0", - "yarl==1.22.0", + "uvicorn==0.42.0", + "yarl==1.23.0", ] [tool.uv] diff --git a/requirements.txt b/requirements.txt index 29881b06..4090f32f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,59 +16,127 @@ aiohappyeyeballs==2.6.1 \ # via # aiohttp # ocotilloapi -aiohttp==3.13.3 \ - --hash=sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c \ - --hash=sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c \ - --hash=sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f \ - --hash=sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2 \ - --hash=sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf \ - --hash=sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998 \ - --hash=sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767 \ - --hash=sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43 \ - --hash=sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592 \ - --hash=sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a \ - --hash=sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687 \ - --hash=sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8 \ - --hash=sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261 \ - --hash=sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4 \ - --hash=sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587 \ - --hash=sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91 \ - --hash=sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3 \ - --hash=sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344 \ - --hash=sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6 \ - --hash=sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3 \ - --hash=sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29 \ - --hash=sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c \ - --hash=sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926 \ - --hash=sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64 \ - --hash=sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e \ - --hash=sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6 \ - --hash=sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d \ - --hash=sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415 \ - --hash=sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603 \ - --hash=sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0 \ - --hash=sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf \ - --hash=sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591 \ - --hash=sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26 \ - --hash=sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a \ - --hash=sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9 \ - --hash=sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba \ - --hash=sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df \ - --hash=sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984 \ - --hash=sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632 \ - --hash=sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56 \ - --hash=sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88 \ - --hash=sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc \ - --hash=sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0 \ - --hash=sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1 \ - --hash=sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25 \ - --hash=sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1 \ - --hash=sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f \ - --hash=sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72 \ - --hash=sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808 \ - --hash=sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0 \ - --hash=sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730 \ - --hash=sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa +aiohttp==3.13.4 \ + --hash=sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144 \ + --hash=sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9 \ + --hash=sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed \ + --hash=sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182 \ + --hash=sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576 \ + --hash=sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118 \ + --hash=sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965 \ + --hash=sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e \ + --hash=sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c \ + --hash=sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97 \ + --hash=sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e \ + --hash=sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933 \ + --hash=sha256:1746338dc2a33cf706cd7446575d13d451f28f9860bebc908c7632b22e71ae3f \ + --hash=sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726 \ + --hash=sha256:19f60011ad60e40a01d242238bb335399e3a4d8df958c63cbb835add8d5c3b5a \ + --hash=sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5 \ + --hash=sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871 \ + --hash=sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758 \ + --hash=sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0 \ + --hash=sha256:26ed03f7d3d6453634729e2c7600d7255d65e879559c5a48fe1bb78355cde74b \ + --hash=sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7 \ + --hash=sha256:2d15e7e4f1099d9e4d863eaf77a8eee5dcb002b7d7188061b0fbee37f845899e \ + --hash=sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e \ + --hash=sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f \ + --hash=sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d \ + --hash=sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927 \ + --hash=sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7 \ + --hash=sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3 \ + --hash=sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2 \ + --hash=sha256:3b4e07d8803a70dd886b5f38588e5b49f894995ca8e132b06c31a2583ae2ef6e \ + --hash=sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7 \ + --hash=sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9 \ + --hash=sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329 \ + --hash=sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1 \ + --hash=sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819 \ + --hash=sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42 \ + --hash=sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70 \ + --hash=sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7 \ + --hash=sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9 \ + --hash=sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2 \ + --hash=sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c \ + --hash=sha256:4c3f733916e85506b8000dddc071c6b82f8c68f56c99adb328d6550017db062d \ + --hash=sha256:4e2e68085730a03704beb2cff035fa8648f62c9f93758d7e6d70add7f7bb5b3b \ + --hash=sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a \ + --hash=sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3 \ + --hash=sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e \ + --hash=sha256:5539ec0d6a3a5c6799b661b7e79166ad1b7ae71ccb59a92fcb6b4ef89295bc94 \ + --hash=sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2 \ + --hash=sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d \ + --hash=sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be \ + --hash=sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77 \ + --hash=sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023 \ + --hash=sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722 \ + --hash=sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538 \ + --hash=sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453 \ + --hash=sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee \ + --hash=sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f \ + --hash=sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b \ + --hash=sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7 \ + --hash=sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123 \ + --hash=sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e \ + --hash=sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3 \ + --hash=sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c \ + --hash=sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845 \ + --hash=sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a \ + --hash=sha256:797613182ffaaca0b9ad5f3b3d3ce5d21242c768f75e66c750b8292bd97c9de3 \ + --hash=sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763 \ + --hash=sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb \ + --hash=sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c \ + --hash=sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83 \ + --hash=sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8 \ + --hash=sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942 \ + --hash=sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab \ + --hash=sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1 \ + --hash=sha256:907ad36b6a65cff7d88d7aca0f77c650546ba850a4f92c92ecb83590d4613249 \ + --hash=sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073 \ + --hash=sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde \ + --hash=sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27 \ + --hash=sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c \ + --hash=sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500 \ + --hash=sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069 \ + --hash=sha256:a5444dce2e6fba0a1dc2d58d026e674f25f21de178c6f844342629bcef019f2f \ + --hash=sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9 \ + --hash=sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab \ + --hash=sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d \ + --hash=sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21 \ + --hash=sha256:b3d525648fe7c8b4977e460c18098f9f81d7991d72edfdc2f13cf96068f279bc \ + --hash=sha256:b3f00bb9403728b08eb3951e982ca0a409c7a871d709684623daeab79465b181 \ + --hash=sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8 \ + --hash=sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb \ + --hash=sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3 \ + --hash=sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145 \ + --hash=sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d \ + --hash=sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165 \ + --hash=sha256:c344c47e85678e410b064fc2ace14db86bb69db7ed5520c234bf13aed603ec30 \ + --hash=sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8 \ + --hash=sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30 \ + --hash=sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954 \ + --hash=sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7 \ + --hash=sha256:cb15595eb52870f84248d7cc97013a76f52ab02ff74d394be093b1d9b8b82bc0 \ + --hash=sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba \ + --hash=sha256:ce7320a945aac4bf0bb8901600e4f9409eb602f25ce3ef4d275b48f6d704a862 \ + --hash=sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9 \ + --hash=sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349 \ + --hash=sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393 \ + --hash=sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97 \ + --hash=sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12 \ + --hash=sha256:d904084985ca66459e93797e5e05985c048a9c0633655331144c089943e53d12 \ + --hash=sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38 \ + --hash=sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b \ + --hash=sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551 \ + --hash=sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57 \ + --hash=sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c \ + --hash=sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb \ + --hash=sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d \ + --hash=sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532 \ + --hash=sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360 \ + --hash=sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f \ + --hash=sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c \ + --hash=sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791 # via # cloud-sql-python-connector # ocotilloapi @@ -98,16 +166,16 @@ annotated-types==0.7.0 \ # via # ocotilloapi # pydantic -anyio==4.12.1 \ - --hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \ - --hash=sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc # via # httpx # ocotilloapi # starlette -apitally==0.24.1 \ - --hash=sha256:18d476871e081ff8f42fd0b631b33ccaf631be404abe9a54e30621117389a70e \ - --hash=sha256:90adc1ad7698e83833622f4673e72c46e39c9474385a891dd3ce4e413c1f0863 +apitally==0.24.4 \ + --hash=sha256:764f3c9dc907ec2014f8f420d66db091826106eb7b306ce871238c647029a019 \ + --hash=sha256:78447204cb1b0e6b409129ae8b13ddcdfe03bab648af8662cd73fc24a8e30ec2 # via ocotilloapi asgiref==3.11.1 \ --hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \ @@ -159,9 +227,9 @@ authlib==1.6.9 \ --hash=sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04 \ --hash=sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3 # via ocotilloapi -babel==2.17.0 \ - --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ - --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 # via # pygeoapi # starlette-admin @@ -321,45 +389,140 @@ cffi==2.0.0 \ # via # cryptography # ocotilloapi -cfgv==3.4.0 \ - --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ - --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 +cfgv==3.5.0 \ + --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ + --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 # via pre-commit -charset-normalizer==3.4.4 \ - --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ - --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ - --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ - --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ - --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ - --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ - --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ - --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ - --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ - --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ - --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ - --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ - --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ - --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ - --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ - --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ - --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ - --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ - --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ - --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ - --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ - --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ - --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ - --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ - --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ - --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ - --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ - --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ - --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ - --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ - --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ - --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ - --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ - --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 +charset-normalizer==3.4.6 \ + --hash=sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e \ + --hash=sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c \ + --hash=sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5 \ + --hash=sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815 \ + --hash=sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f \ + --hash=sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0 \ + --hash=sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484 \ + --hash=sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407 \ + --hash=sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6 \ + --hash=sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8 \ + --hash=sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264 \ + --hash=sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815 \ + --hash=sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2 \ + --hash=sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4 \ + --hash=sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579 \ + --hash=sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f \ + --hash=sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa \ + --hash=sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95 \ + --hash=sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab \ + --hash=sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297 \ + --hash=sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a \ + --hash=sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e \ + --hash=sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84 \ + --hash=sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8 \ + --hash=sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0 \ + --hash=sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9 \ + --hash=sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f \ + --hash=sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1 \ + --hash=sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843 \ + --hash=sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565 \ + --hash=sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7 \ + --hash=sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c \ + --hash=sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b \ + --hash=sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7 \ + --hash=sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687 \ + --hash=sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9 \ + --hash=sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14 \ + --hash=sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89 \ + --hash=sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f \ + --hash=sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0 \ + --hash=sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9 \ + --hash=sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a \ + --hash=sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389 \ + --hash=sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0 \ + --hash=sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30 \ + --hash=sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd \ + --hash=sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e \ + --hash=sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9 \ + --hash=sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc \ + --hash=sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532 \ + --hash=sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d \ + --hash=sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae \ + --hash=sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2 \ + --hash=sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64 \ + --hash=sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f \ + --hash=sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557 \ + --hash=sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e \ + --hash=sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff \ + --hash=sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398 \ + --hash=sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db \ + --hash=sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a \ + --hash=sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43 \ + --hash=sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597 \ + --hash=sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c \ + --hash=sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e \ + --hash=sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2 \ + --hash=sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54 \ + --hash=sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e \ + --hash=sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4 \ + --hash=sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4 \ + --hash=sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7 \ + --hash=sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6 \ + --hash=sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5 \ + --hash=sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194 \ + --hash=sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69 \ + --hash=sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f \ + --hash=sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316 \ + --hash=sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e \ + --hash=sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73 \ + --hash=sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8 \ + --hash=sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923 \ + --hash=sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88 \ + --hash=sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f \ + --hash=sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21 \ + --hash=sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4 \ + --hash=sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6 \ + --hash=sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc \ + --hash=sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2 \ + --hash=sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866 \ + --hash=sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021 \ + --hash=sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2 \ + --hash=sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d \ + --hash=sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8 \ + --hash=sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de \ + --hash=sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237 \ + --hash=sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4 \ + --hash=sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778 \ + --hash=sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb \ + --hash=sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc \ + --hash=sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602 \ + --hash=sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4 \ + --hash=sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f \ + --hash=sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5 \ + --hash=sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611 \ + --hash=sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8 \ + --hash=sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf \ + --hash=sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d \ + --hash=sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b \ + --hash=sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db \ + --hash=sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e \ + --hash=sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077 \ + --hash=sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd \ + --hash=sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef \ + --hash=sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e \ + --hash=sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8 \ + --hash=sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe \ + --hash=sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058 \ + --hash=sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17 \ + --hash=sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833 \ + --hash=sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421 \ + --hash=sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550 \ + --hash=sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff \ + --hash=sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2 \ + --hash=sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc \ + --hash=sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982 \ + --hash=sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d \ + --hash=sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed \ + --hash=sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104 \ + --hash=sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659 # via # ocotilloapi # requests @@ -379,9 +542,9 @@ cligj==0.7.2 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ --hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df # via rasterio -cloud-sql-python-connector==1.20.0 \ - --hash=sha256:aa7c30631c5f455d14d561d7b0b414a97652a1b582a301f5570ba2cea2aa9105 \ - --hash=sha256:fdd96153b950040b0252453115604c142922b72cf3636146165a648ac5f6fc30 +cloud-sql-python-connector==1.20.1 \ + --hash=sha256:7e826875c5c284e1dfd872ab81d8c75eb82dd67ad1bbf43b9e74489342765255 \ + --hash=sha256:c00f9d81205eb658fe06f9f353e00646eb3f55d2d86de01dc1222eec1f5f2fc9 # via ocotilloapi colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ @@ -389,53 +552,113 @@ colorama==0.4.6 ; sys_platform == 'win32' \ # via # click # pytest -coverage==7.10.2 \ - --hash=sha256:0100b19f230df72c90fdb36db59d3f39232391e8d89616a7de30f677da4f532b \ - --hash=sha256:04c74f9ef1f925456a9fd23a7eef1103126186d0500ef9a0acb0bd2514bdc7cc \ - --hash=sha256:11333094c1bff621aa811b67ed794865cbcaa99984dedea4bd9cf780ad64ecba \ - --hash=sha256:12e52b5aa00aa720097d6947d2eb9e404e7c1101ad775f9661ba165ed0a28303 \ - --hash=sha256:14fb5b6641ab5b3c4161572579f0f2ea8834f9d3af2f7dd8fbaecd58ef9175cc \ - --hash=sha256:1a2e934e9da26341d342d30bfe91422bbfdb3f1f069ec87f19b2909d10d8dcc4 \ - --hash=sha256:228946da741558904e2c03ce870ba5efd9cd6e48cbc004d9a27abee08100a15a \ - --hash=sha256:248b5394718e10d067354448dc406d651709c6765669679311170da18e0e9af8 \ - --hash=sha256:2d358f259d8019d4ef25d8c5b78aca4c7af25e28bd4231312911c22a0e824a57 \ - --hash=sha256:2e980e4179f33d9b65ac4acb86c9c0dde904098853f27f289766657ed16e07b3 \ - --hash=sha256:5250bda76e30382e0a2dcd68d961afcab92c3a7613606e6269855c6979a1b0bb \ - --hash=sha256:52d708b5fd65589461381fa442d9905f5903d76c086c6a4108e8e9efdca7a7ed \ - --hash=sha256:5b9d538e8e04916a5df63052d698b30c74eb0174f2ca9cd942c981f274a18eaf \ - --hash=sha256:5c61675a922b569137cf943770d7ad3edd0202d992ce53ac328c5ff68213ccf4 \ - --hash=sha256:5d6e6d84e6dd31a8ded64759626627247d676a23c1b892e1326f7c55c8d61055 \ - --hash=sha256:651015dcd5fd9b5a51ca79ece60d353cacc5beaf304db750407b29c89f72fe2b \ - --hash=sha256:65b451949cb789c346f9f9002441fc934d8ccedcc9ec09daabc2139ad13853f7 \ - --hash=sha256:6eb586fa7d2aee8d65d5ae1dd71414020b2f447435c57ee8de8abea0a77d5074 \ - --hash=sha256:718044729bf1fe3e9eb9f31b52e44ddae07e434ec050c8c628bf5adc56fe4bdd \ - --hash=sha256:71d40b3ac0f26fa9ffa6ee16219a714fed5c6ec197cdcd2018904ab5e75bcfa3 \ - --hash=sha256:75cc1a3f8c88c69bf16a871dab1fe5a7303fdb1e9f285f204b60f1ee539b8fc0 \ - --hash=sha256:81bf6a32212f9f66da03d63ecb9cd9bd48e662050a937db7199dbf47d19831de \ - --hash=sha256:835f39e618099325e7612b3406f57af30ab0a0af350490eff6421e2e5f608e46 \ - --hash=sha256:8f34b09f68bdadec122ffad312154eda965ade433559cc1eadd96cca3de5c824 \ - --hash=sha256:916369b3b914186b2c5e5ad2f7264b02cff5df96cdd7cdad65dccd39aa5fd9f0 \ - --hash=sha256:95db3750dd2e6e93d99fa2498f3a1580581e49c494bddccc6f85c5c21604921f \ - --hash=sha256:95e23987b52d02e7c413bf2d6dc6288bd5721beb518052109a13bfdc62c8033b \ - --hash=sha256:96e5921342574a14303dfdb73de0019e1ac041c863743c8fe1aa6c2b4a257226 \ - --hash=sha256:9c1cd71483ea78331bdfadb8dcec4f4edfb73c7002c1206d8e0af6797853f5be \ - --hash=sha256:9f75dbf4899e29a37d74f48342f29279391668ef625fdac6d2f67363518056a1 \ - --hash=sha256:a3e853cc04987c85ec410905667eed4bf08b1d84d80dfab2684bb250ac8da4f6 \ - --hash=sha256:a7df481e7508de1c38b9b8043da48d94931aefa3e32b47dd20277e4978ed5b95 \ - --hash=sha256:a91e027d66eff214d88d9afbe528e21c9ef1ecdf4956c46e366c50f3094696d0 \ - --hash=sha256:abb57fdd38bf6f7dcc66b38dafb7af7c5fdc31ac6029ce373a6f7f5331d6f60f \ - --hash=sha256:aca7b5645afa688de6d4f8e89d30c577f62956fefb1bad021490d63173874186 \ - --hash=sha256:c2e117e64c26300032755d4520cd769f2623cde1a1d1c3515b05a3b8add0ade1 \ - --hash=sha256:ca07fa78cc9d26bc8c4740de1abd3489cf9c47cc06d9a8ab3d552ff5101af4c0 \ - --hash=sha256:d800705f6951f75a905ea6feb03fff8f3ea3468b81e7563373ddc29aa3e5d1ca \ - --hash=sha256:daaf98009977f577b71f8800208f4d40d4dcf5c2db53d4d822787cdc198d76e1 \ - --hash=sha256:e8415918856a3e7d57a4e0ad94651b761317de459eb74d34cc1bb51aad80f07e \ - --hash=sha256:e96649ac34a3d0e6491e82a2af71098e43be2874b619547c3282fc11d3840a4b \ - --hash=sha256:ea8d8fe546c528535c761ba424410bbeb36ba8a0f24be653e94b70c93fd8a8ca \ - --hash=sha256:f256173b48cc68486299d510a3e729a96e62c889703807482dbf56946befb5c8 \ - --hash=sha256:f287a25a8ca53901c613498e4a40885b19361a2fe8fbfdbb7f8ef2cad2a23f03 \ - --hash=sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe \ - --hash=sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb +coverage==7.13.5 \ + --hash=sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256 \ + --hash=sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b \ + --hash=sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5 \ + --hash=sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d \ + --hash=sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a \ + --hash=sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969 \ + --hash=sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642 \ + --hash=sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87 \ + --hash=sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740 \ + --hash=sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215 \ + --hash=sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d \ + --hash=sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422 \ + --hash=sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8 \ + --hash=sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911 \ + --hash=sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b \ + --hash=sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587 \ + --hash=sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8 \ + --hash=sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606 \ + --hash=sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9 \ + --hash=sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf \ + --hash=sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633 \ + --hash=sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6 \ + --hash=sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43 \ + --hash=sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2 \ + --hash=sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61 \ + --hash=sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930 \ + --hash=sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc \ + --hash=sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247 \ + --hash=sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75 \ + --hash=sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e \ + --hash=sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376 \ + --hash=sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01 \ + --hash=sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1 \ + --hash=sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3 \ + --hash=sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743 \ + --hash=sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9 \ + --hash=sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf \ + --hash=sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e \ + --hash=sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1 \ + --hash=sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd \ + --hash=sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b \ + --hash=sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab \ + --hash=sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d \ + --hash=sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a \ + --hash=sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0 \ + --hash=sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510 \ + --hash=sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f \ + --hash=sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0 \ + --hash=sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8 \ + --hash=sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf \ + --hash=sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209 \ + --hash=sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9 \ + --hash=sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3 \ + --hash=sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3 \ + --hash=sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d \ + --hash=sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd \ + --hash=sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2 \ + --hash=sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882 \ + --hash=sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09 \ + --hash=sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea \ + --hash=sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c \ + --hash=sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562 \ + --hash=sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3 \ + --hash=sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806 \ + --hash=sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e \ + --hash=sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878 \ + --hash=sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e \ + --hash=sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9 \ + --hash=sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45 \ + --hash=sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29 \ + --hash=sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4 \ + --hash=sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c \ + --hash=sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479 \ + --hash=sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400 \ + --hash=sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c \ + --hash=sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a \ + --hash=sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf \ + --hash=sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686 \ + --hash=sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de \ + --hash=sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028 \ + --hash=sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0 \ + --hash=sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179 \ + --hash=sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16 \ + --hash=sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85 \ + --hash=sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a \ + --hash=sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0 \ + --hash=sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810 \ + --hash=sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161 \ + --hash=sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607 \ + --hash=sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26 \ + --hash=sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819 \ + --hash=sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40 \ + --hash=sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5 \ + --hash=sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15 \ + --hash=sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0 \ + --hash=sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90 \ + --hash=sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0 \ + --hash=sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6 \ + --hash=sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a \ + --hash=sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58 \ + --hash=sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b \ + --hash=sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17 \ + --hash=sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5 \ + --hash=sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664 \ + --hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \ + --hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f # via pytest-cov cryptography==46.0.6 \ --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \ @@ -492,9 +715,9 @@ cryptography==46.0.6 \ # cloud-sql-python-connector # google-auth # ocotilloapi -dateparser==1.3.0 \ - --hash=sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5 \ - --hash=sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a +dateparser==1.4.0 \ + --hash=sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378 \ + --hash=sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4 # via pygeofilter distlib==0.4.0 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ @@ -518,21 +741,21 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.129.0 \ - --hash=sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af \ - --hash=sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec +fastapi==0.135.2 \ + --hash=sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5 \ + --hash=sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56 # via # apitally # fastapi-pagination # ocotilloapi # sentry-sdk -fastapi-pagination==0.15.10 \ - --hash=sha256:0ba7d4f795059a91a9e89358af129f2114876452c1defaf198ea8e3419e9a3cd \ - --hash=sha256:d50071ebc93b519391f16ff6c3ba9e3603bd659963fe6774ba2f4d5037e17fd8 +fastapi-pagination==0.15.12 \ + --hash=sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a \ + --hash=sha256:914b41e07b8556de34c12d3568c9b7137eb62a3558420061a4acbebf7e729a08 # via ocotilloapi -filelock==3.18.0 \ - --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ - --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de +filelock==3.25.2 \ + --hash=sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694 \ + --hash=sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70 # via # pygeoapi # virtualenv @@ -611,20 +834,20 @@ frozenlist==1.8.0 \ # aiohttp # aiosignal # ocotilloapi -geoalchemy2==0.18.1 \ - --hash=sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124 \ - --hash=sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb +geoalchemy2==0.18.4 \ + --hash=sha256:5719e2bb040d5c406d5d03425fec87997ce9351843b053ca11373a0f5a31971b \ + --hash=sha256:89e6680dcbb6b8d8c784dcaa889e48ab2783aa42487ee5730fdbd7a7c7ddf6ec # via ocotilloapi -google-api-core==2.29.0 \ - --hash=sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7 \ - --hash=sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9 +google-api-core==2.30.0 \ + --hash=sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b \ + --hash=sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5 # via # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.48.0 \ - --hash=sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f \ - --hash=sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce +google-auth==2.49.1 \ + --hash=sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64 \ + --hash=sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7 # via # cloud-sql-python-connector # google-api-core @@ -637,9 +860,9 @@ google-cloud-core==2.5.0 \ # via # google-cloud-storage # ocotilloapi -google-cloud-storage==3.9.0 \ - --hash=sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066 \ - --hash=sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc +google-cloud-storage==3.10.1 \ + --hash=sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286 \ + --hash=sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f # via ocotilloapi google-crc32c==1.8.0 \ --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ @@ -663,40 +886,66 @@ google-resumable-media==2.8.0 \ # via # google-cloud-storage # ocotilloapi -googleapis-common-protos==1.72.0 \ - --hash=sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038 \ - --hash=sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5 +googleapis-common-protos==1.73.1 \ + --hash=sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6 \ + --hash=sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8 # via # google-api-core # ocotilloapi -greenlet==3.3.1 \ - --hash=sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e \ - --hash=sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e \ - --hash=sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946 \ - --hash=sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d \ - --hash=sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451 \ - --hash=sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951 \ - --hash=sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f \ - --hash=sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d \ - --hash=sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242 \ - --hash=sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98 \ - --hash=sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2 \ - --hash=sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab \ - --hash=sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249 \ - --hash=sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3 \ - --hash=sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac \ - --hash=sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1 \ - --hash=sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774 \ - --hash=sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd \ - --hash=sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3 \ - --hash=sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2 \ - --hash=sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a \ - --hash=sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683 \ - --hash=sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79 \ - --hash=sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b \ - --hash=sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5 \ - --hash=sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97 \ - --hash=sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53 +greenlet==3.3.2 \ + --hash=sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd \ + --hash=sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082 \ + --hash=sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b \ + --hash=sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5 \ + --hash=sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f \ + --hash=sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727 \ + --hash=sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e \ + --hash=sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2 \ + --hash=sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f \ + --hash=sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327 \ + --hash=sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd \ + --hash=sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2 \ + --hash=sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070 \ + --hash=sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99 \ + --hash=sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be \ + --hash=sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79 \ + --hash=sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7 \ + --hash=sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e \ + --hash=sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf \ + --hash=sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f \ + --hash=sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506 \ + --hash=sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a \ + --hash=sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395 \ + --hash=sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4 \ + --hash=sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca \ + --hash=sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492 \ + --hash=sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab \ + --hash=sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358 \ + --hash=sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce \ + --hash=sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5 \ + --hash=sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef \ + --hash=sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d \ + --hash=sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac \ + --hash=sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55 \ + --hash=sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124 \ + --hash=sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4 \ + --hash=sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986 \ + --hash=sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd \ + --hash=sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f \ + --hash=sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb \ + --hash=sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4 \ + --hash=sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13 \ + --hash=sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab \ + --hash=sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff \ + --hash=sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a \ + --hash=sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9 \ + --hash=sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86 \ + --hash=sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd \ + --hash=sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71 \ + --hash=sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92 \ + --hash=sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643 \ + --hash=sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54 \ + --hash=sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9 # via # ocotilloapi # sqlalchemy @@ -723,9 +972,9 @@ httpx==0.28.1 \ # via # apitally # ocotilloapi -identify==2.6.12 \ - --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ - --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 +identify==2.6.18 \ + --hash=sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd \ + --hash=sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737 # via pre-commit idna==3.11 \ --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ @@ -918,73 +1167,102 @@ multidict==6.7.1 \ # aiohttp # ocotilloapi # yarl -nodeenv==1.9.1 \ - --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ - --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 +nodeenv==1.10.0 \ + --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ + --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb # via pre-commit -numpy==2.4.2 \ - --hash=sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82 \ - --hash=sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75 \ - --hash=sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257 \ - --hash=sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71 \ - --hash=sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a \ - --hash=sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181 \ - --hash=sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef \ - --hash=sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c \ - --hash=sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e \ - --hash=sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f \ - --hash=sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b \ - --hash=sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657 \ - --hash=sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262 \ - --hash=sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a \ - --hash=sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b \ - --hash=sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae \ - --hash=sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554 \ - --hash=sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05 \ - --hash=sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1 \ - --hash=sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622 \ - --hash=sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a \ - --hash=sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443 \ - --hash=sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98 \ - --hash=sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110 \ - --hash=sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308 \ - --hash=sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5 \ - --hash=sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef \ - --hash=sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab \ - --hash=sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909 \ - --hash=sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325 \ - --hash=sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979 \ - --hash=sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7 \ - --hash=sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7 \ - --hash=sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74 \ - --hash=sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499 \ - --hash=sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000 \ - --hash=sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a \ - --hash=sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913 \ - --hash=sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8 \ - --hash=sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d \ - --hash=sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb \ - --hash=sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236 \ - --hash=sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1 +numpy==2.4.4 \ + --hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \ + --hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \ + --hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \ + --hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \ + --hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \ + --hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \ + --hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \ + --hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \ + --hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \ + --hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \ + --hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \ + --hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \ + --hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \ + --hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \ + --hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \ + --hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \ + --hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \ + --hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \ + --hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \ + --hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \ + --hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \ + --hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \ + --hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \ + --hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \ + --hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \ + --hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \ + --hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \ + --hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \ + --hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \ + --hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \ + --hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \ + --hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \ + --hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \ + --hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \ + --hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \ + --hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \ + --hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \ + --hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \ + --hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \ + --hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \ + --hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \ + --hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \ + --hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \ + --hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \ + --hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \ + --hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \ + --hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \ + --hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \ + --hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \ + --hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \ + --hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \ + --hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \ + --hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \ + --hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \ + --hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \ + --hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \ + --hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \ + --hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \ + --hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \ + --hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \ + --hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \ + --hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \ + --hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \ + --hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \ + --hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \ + --hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \ + --hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \ + --hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \ + --hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \ + --hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \ + --hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \ + --hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e # via # ocotilloapi # pandas # pandas-stubs # rasterio # shapely -opentelemetry-api==1.39.1 \ - --hash=sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950 \ - --hash=sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c +opentelemetry-api==1.40.0 \ + --hash=sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f \ + --hash=sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9 # via # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.39.1 \ - --hash=sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c \ - --hash=sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6 +opentelemetry-sdk==1.40.0 \ + --hash=sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2 \ + --hash=sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1 # via apitally -opentelemetry-semantic-conventions==0.60b1 \ - --hash=sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953 \ - --hash=sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb +opentelemetry-semantic-conventions==0.61b0 \ + --hash=sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a \ + --hash=sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2 # via opentelemetry-sdk packaging==26.0 \ --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ @@ -1018,9 +1296,9 @@ pg8000==1.31.5 \ --hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \ --hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78 # via ocotilloapi -phonenumbers==9.0.24 \ - --hash=sha256:97c38e4b5b8af992c75de01bd9c0f84e61701a9c900fd84f49744714910a4dc3 \ - --hash=sha256:fa86ab7112ef8b286a811392311bd76bbbae7d1d271c2ed26cf73f2e9fa4d3c6 +phonenumbers==9.0.26 \ + --hash=sha256:9e582c827f0f5503cddeebef80099475a52ffa761551d8384099c7ec71298cbf \ + --hash=sha256:ff473da5712965b6c7f7a31cbff8255864df694eb48243771133ecb761e807c1 # via ocotilloapi pillow==12.1.1 \ --hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \ @@ -1115,9 +1393,9 @@ pillow==12.1.1 \ --hash=sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090 \ --hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289 # via ocotilloapi -platformdirs==4.3.8 \ - --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ - --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 +platformdirs==4.9.4 \ + --hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \ + --hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868 # via virtualenv pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -1197,9 +1475,9 @@ propcache==0.4.1 \ # aiohttp # ocotilloapi # yarl -proto-plus==1.27.1 \ - --hash=sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147 \ - --hash=sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc +proto-plus==1.27.2 \ + --hash=sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718 \ + --hash=sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24 # via # google-api-core # ocotilloapi @@ -1354,16 +1632,16 @@ pygeoif==1.6.0 \ # via # pygeoapi # pygeofilter -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # ocotilloapi # pytest # rich -pyjwt==2.12.0 \ - --hash=sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02 \ - --hash=sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e +pyjwt==2.12.1 \ + --hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ + --hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b # via ocotilloapi pyparsing==3.3.2 \ --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ @@ -1433,9 +1711,9 @@ python-dateutil==2.9.0.post0 \ # pandas # pg8000 # pygeoapi -python-dotenv==1.2.1 \ - --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ - --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 # via dotenv python-jose==3.5.0 \ --hash=sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771 \ @@ -1455,17 +1733,80 @@ pytz==2025.2 \ # ocotilloapi # pandas # pygeoapi -pyyaml==6.0.2 \ - --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ - --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ - --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ - --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ - --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ - --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ - --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ - --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ - --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ - --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via # pre-commit # pygeoapi @@ -1502,85 +1843,134 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.2.19 \ - --hash=sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876 \ - --hash=sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e \ - --hash=sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919 \ - --hash=sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13 \ - --hash=sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83 \ - --hash=sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47 \ - --hash=sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265 \ - --hash=sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619 \ - --hash=sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01 \ - --hash=sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f \ - --hash=sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768 \ - --hash=sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a \ - --hash=sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799 \ - --hash=sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a \ - --hash=sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64 \ - --hash=sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868 \ - --hash=sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b \ - --hash=sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02 \ - --hash=sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04 \ - --hash=sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73 \ - --hash=sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e \ - --hash=sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d \ - --hash=sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1 \ - --hash=sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007 \ - --hash=sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e \ - --hash=sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175 \ - --hash=sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe \ - --hash=sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3 \ - --hash=sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5 \ - --hash=sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969 \ - --hash=sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a \ - --hash=sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310 \ - --hash=sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b \ - --hash=sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3 \ - --hash=sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd \ - --hash=sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a \ - --hash=sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7 \ - --hash=sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411 \ - --hash=sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e \ - --hash=sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555 \ - --hash=sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879 \ - --hash=sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4 \ - --hash=sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161 \ - --hash=sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9 \ - --hash=sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5 \ - --hash=sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7 \ - --hash=sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854 \ - --hash=sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e \ - --hash=sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0 \ - --hash=sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db \ - --hash=sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f \ - --hash=sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f \ - --hash=sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c \ - --hash=sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1 \ - --hash=sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7 \ - --hash=sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed \ - --hash=sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968 \ - --hash=sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743 \ - --hash=sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c \ - --hash=sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b \ - --hash=sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867 \ - --hash=sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60 \ - --hash=sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3 \ - --hash=sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904 \ - --hash=sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c +regex==2026.3.32 \ + --hash=sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1 \ + --hash=sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c \ + --hash=sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1 \ + --hash=sha256:0d7855f5e59fcf91d0c9f4a51dc5d8847813832a2230c3e8e35912ccf20baaa2 \ + --hash=sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb \ + --hash=sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298 \ + --hash=sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61 \ + --hash=sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad \ + --hash=sha256:18eb45f711e942c27dbed4109830bd070d8d618e008d0db39705f3f57070a4c6 \ + --hash=sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9 \ + --hash=sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51 \ + --hash=sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9 \ + --hash=sha256:1e0f6648fd48f4c73d801c55ab976cd602e2da87de99c07bff005b131f269c6a \ + --hash=sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d \ + --hash=sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897 \ + --hash=sha256:2c8d402ea3dfe674288fe3962016affd33b5b27213d2b5db1823ffa4de524c57 \ + --hash=sha256:2dcca2bceb823c9cc610e57b86a265d7ffc30e9fe98548c609eba8bd3c0c2488 \ + --hash=sha256:2ffbadc647325dd4e3118269bda93ded1eb5f5b0c3b7ba79a3da9fbd04f248e9 \ + --hash=sha256:34c905a721ddee0f84c99e3e3b59dd4a5564a6fe338222bc89dd4d4df166115c \ + --hash=sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb \ + --hash=sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b \ + --hash=sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4 \ + --hash=sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5 \ + --hash=sha256:3f5747501b69299c6b0b047853771e4ed390510bada68cb16da9c9c2078343f7 \ + --hash=sha256:462a041d2160090553572f6bb0be417ab9bb912a08de54cb692829c871ee88c1 \ + --hash=sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810 \ + --hash=sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae \ + --hash=sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041 \ + --hash=sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0 \ + --hash=sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc \ + --hash=sha256:5336b1506142eb0f23c96fb4a34b37c4fefd4fed2a7042069f3c8058efe17855 \ + --hash=sha256:567b57eb987547a23306444e4f6f85d4314f83e65c71d320d898aa7550550443 \ + --hash=sha256:5aa78c857c1731bdd9863923ffadc816d823edf475c7db6d230c28b53b7bdb5e \ + --hash=sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88 \ + --hash=sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9 \ + --hash=sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e \ + --hash=sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27 \ + --hash=sha256:6128dd0793a87287ea1d8bf16b4250dd96316c464ee15953d5b98875a284d41e \ + --hash=sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc \ + --hash=sha256:66a5083c3ffe5a5a95f8281ea47a88072d4f24001d562d1d9d28d4cdc005fec5 \ + --hash=sha256:66d3126afe7eac41759cd5f0b3b246598086e88e70527c0d68c9e615b81771c4 \ + --hash=sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6 \ + --hash=sha256:6980ceb5c1049d4878632f08ba0bf7234c30e741b0dc9081da0f86eca13189d3 \ + --hash=sha256:69a847a6ffaa86e8af7b9e7037606e05a6f663deec516ad851e8e05d9908d16a \ + --hash=sha256:6ada7bd5bb6511d12177a7b00416ce55caee49fbf8c268f26b909497b534cacb \ + --hash=sha256:70c634e39c5cda0da05c93d6747fdc957599f7743543662b6dbabdd8d3ba8a96 \ + --hash=sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98 \ + --hash=sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b \ + --hash=sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4 \ + --hash=sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5 \ + --hash=sha256:879ae91f2928a13f01a55cfa168acedd2b02b11b4cd8b5bb9223e8cde777ca52 \ + --hash=sha256:887a9fa74418d74d645281ee0edcf60694053bd1bc2ebc49eb5e66bfffc6d107 \ + --hash=sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac \ + --hash=sha256:89e50667e7e8c0e7903e4d644a2764fffe9a3a5d6578f72ab7a7b4205bf204b7 \ + --hash=sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3 \ + --hash=sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327 \ + --hash=sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745 \ + --hash=sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a \ + --hash=sha256:8fe14e24124ef41220e5992a0f09432f890037df6f93fd3d6b7a0feff2db16b2 \ + --hash=sha256:918db4e34a7ef3d0beee913fa54b34231cc3424676f1c19bdb85f01828d3cd37 \ + --hash=sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e \ + --hash=sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249 \ + --hash=sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85 \ + --hash=sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929 \ + --hash=sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166 \ + --hash=sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173 \ + --hash=sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4 \ + --hash=sha256:ad5c53f2e8fcae9144009435ebe3d9832003508cf8935c04542a1b3b8deefa15 \ + --hash=sha256:ad8d372587e659940568afd009afeb72be939c769c552c9b28773d0337251391 \ + --hash=sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c \ + --hash=sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14 \ + --hash=sha256:b3aa21bad31db904e0b9055e12c8282df62d43169c4a9d2929407060066ebc74 \ + --hash=sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc \ + --hash=sha256:b56993a7aeb4140c4770f4f7965c9e5af4f024457d06e23c01b0d47501cb18ed \ + --hash=sha256:b6acb765e7c1f2fa08ac9057a33595e26104d7d67046becae184a8f100932dd9 \ + --hash=sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0 \ + --hash=sha256:b7836aa13721dbdef658aebd11f60d00de633a95726521860fe1f6be75fa225a \ + --hash=sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c \ + --hash=sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d \ + --hash=sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4 \ + --hash=sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff \ + --hash=sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55 \ + --hash=sha256:c1ed17104d1be7f807fdec35ec99777168dd793a09510d753f8710590ba54cdd \ + --hash=sha256:c3c6f6b027d10f84bfe65049028892b5740878edd9eae5fea0d1710b09b1d257 \ + --hash=sha256:c5e0fdb5744caf1036dec5510f543164f2144cb64932251f6dfd42fa872b7f9c \ + --hash=sha256:c60f1de066eb5a0fd8ee5974de4194bb1c2e7692941458807162ffbc39887303 \ + --hash=sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b \ + --hash=sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865 \ + --hash=sha256:c9f261ad3cd97257dc1d9355bfbaa7dd703e06574bffa0fa8fe1e31da915ee38 \ + --hash=sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b \ + --hash=sha256:d363660f9ef8c734495598d2f3e527fb41f745c73159dc0d743402f049fb6836 \ + --hash=sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e \ + --hash=sha256:d571f0b2eec3513734ea31a16ce0f7840c0b85a98e7edfa0e328ed144f9ef78f \ + --hash=sha256:d6b39a2cc5625bbc4fda18919a891eab9aab934eecf83660a90ce20c53621a9a \ + --hash=sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c \ + --hash=sha256:dab4178a0bc1ef13178832b12db7bc7f562e8f028b2b5be186e370090dc50652 \ + --hash=sha256:db976be51375bca900e008941639448d148c655c9545071965d0571ecc04f5d0 \ + --hash=sha256:ded4fc0edf3de792850cb8b04bbf3c5bd725eeaf9df4c27aad510f6eed9c4e19 \ + --hash=sha256:e006ea703d5c0f3d112b51ba18af73b58209b954acfe3d8da42eacc9a00e4be6 \ + --hash=sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b \ + --hash=sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba \ + --hash=sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf \ + --hash=sha256:e83ce8008b48762be296f1401f19afd9ea29f3d035d1974e0cecb74e9afbd1df \ + --hash=sha256:ed3b8281c5d0944d939c82db4ec2300409dd69ee087f7a75a94f2e301e855fb4 \ + --hash=sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60 \ + --hash=sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16 \ + --hash=sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194 \ + --hash=sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f \ + --hash=sha256:f54840bea73541652f1170dc63402a5b776fc851ad36a842da9e5163c1f504a0 \ + --hash=sha256:f785f44a44702dea89b28bce5bc82552490694ce4e144e21a4f0545e364d2150 \ + --hash=sha256:f7cc00089b4c21847852c0ad76fb3680f9833b855a0d30bcec94211c435bff6b \ + --hash=sha256:f95bd07f301135771559101c060f558e2cf896c7df00bec050ca7f93bf11585a \ + --hash=sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f \ + --hash=sha256:fd03e38068faeef937cc6761a250a4aaa015564bd0d61481fefcf15586d31825 # via dateparser -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b \ - --hash=sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652 +requests==2.33.1 \ + --hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \ + --hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a # via # cloud-sql-python-connector # google-api-core # google-cloud-storage # ocotilloapi # pygeoapi -rich==14.3.2 \ - --hash=sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69 \ - --hash=sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8 +rich==14.3.3 \ + --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ + --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b # via typer rpds-py==0.30.0 \ --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ @@ -1658,9 +2048,9 @@ scramp==1.4.8 \ # via # ocotilloapi # pg8000 -sentry-sdk==2.53.0 \ - --hash=sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899 \ - --hash=sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77 +sentry-sdk==2.56.0 \ + --hash=sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02 \ + --hash=sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168 # via ocotilloapi shapely==2.1.2 \ --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ @@ -1714,31 +2104,70 @@ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc # via ocotilloapi -sqlalchemy==2.0.46 \ - --hash=sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366 \ - --hash=sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b \ - --hash=sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863 \ - --hash=sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa \ - --hash=sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf \ - --hash=sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada \ - --hash=sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad \ - --hash=sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908 \ - --hash=sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef \ - --hash=sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330 \ - --hash=sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f \ - --hash=sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee \ - --hash=sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e \ - --hash=sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00 \ - --hash=sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764 \ - --hash=sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d \ - --hash=sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10 \ - --hash=sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2 \ - --hash=sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b \ - --hash=sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7 \ - --hash=sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447 \ - --hash=sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e \ - --hash=sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e \ - --hash=sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede +sqlalchemy==2.0.48 \ + --hash=sha256:01f6bbd4308b23240cf7d3ef117557c8fd097ec9549d5d8a52977544e35b40ad \ + --hash=sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e \ + --hash=sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd \ + --hash=sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6 \ + --hash=sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0 \ + --hash=sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0 \ + --hash=sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc \ + --hash=sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b \ + --hash=sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f \ + --hash=sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0 \ + --hash=sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894 \ + --hash=sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b \ + --hash=sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8 \ + --hash=sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0 \ + --hash=sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131 \ + --hash=sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b \ + --hash=sha256:4599a95f9430ae0de82b52ff0d27304fe898c17cb5f4099f7438a51b9998ac77 \ + --hash=sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f \ + --hash=sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb \ + --hash=sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9 \ + --hash=sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c \ + --hash=sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241 \ + --hash=sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658 \ + --hash=sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7 \ + --hash=sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a \ + --hash=sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae \ + --hash=sha256:6bb85c546591569558571aa1b06aba711b26ae62f111e15e56136d69920e1616 \ + --hash=sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7 \ + --hash=sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89 \ + --hash=sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3 \ + --hash=sha256:7c998f2ace8bf76b453b75dbcca500d4f4b9dd3908c13e89b86289b37784848b \ + --hash=sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0 \ + --hash=sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2 \ + --hash=sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d \ + --hash=sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76 \ + --hash=sha256:858e433f12b0e5b3ed2f8da917433b634f4937d0e8793e5cb33c54a1a01df565 \ + --hash=sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99 \ + --hash=sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485 \ + --hash=sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617 \ + --hash=sha256:a5b429eb84339f9f05e06083f119ad814e6d85e27ecbdf9c551dfdbb128eaf8a \ + --hash=sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096 \ + --hash=sha256:a6b764fb312bd35e47797ad2e63f0d323792837a6ac785a4ca967019357d2bc7 \ + --hash=sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed \ + --hash=sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f \ + --hash=sha256:b8fc3454b4f3bd0a368001d0e968852dad45a873f8b4babd41bc302ec851a099 \ + --hash=sha256:bcb8ebbf2e2c36cfe01a94f2438012c6a9d494cf80f129d9753bcdf33bfc35a6 \ + --hash=sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018 \ + --hash=sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2 \ + --hash=sha256:d64177f443594c8697369c10e4bbcac70ef558e0f7921a1de7e4a3d1734bcf67 \ + --hash=sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933 \ + --hash=sha256:d8fcccbbc0c13c13702c471da398b8cd72ba740dca5859f148ae8e0e8e0d3e7e \ + --hash=sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b \ + --hash=sha256:e214d546c8ecb5fc22d6e6011746082abf13a9cf46eefb45769c7b31407c97b5 \ + --hash=sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd \ + --hash=sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79 \ + --hash=sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4 \ + --hash=sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571 \ + --hash=sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c \ + --hash=sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121 \ + --hash=sha256:f27f9da0a7d22b9f981108fd4b62f8b5743423388915a563e651c20d06c1f457 \ + --hash=sha256:f8649a14caa5f8a243628b1d61cf530ad9ae4578814ba726816adb1121fc493e \ + --hash=sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29 \ + --hash=sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb # via # alembic # geoalchemy2 @@ -1777,9 +2206,9 @@ tinydb==4.8.2 \ --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 # via pygeoapi -typer==0.23.1 \ - --hash=sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134 \ - --hash=sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e +typer==0.24.1 \ + --hash=sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e \ + --hash=sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45 # via ocotilloapi types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ @@ -1830,85 +2259,147 @@ utm==0.8.1 \ --hash=sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa \ --hash=sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a # via ocotilloapi -uvicorn==0.40.0 \ - --hash=sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea \ - --hash=sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee +uvicorn==0.42.0 \ + --hash=sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359 \ + --hash=sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775 # via ocotilloapi virtualenv==20.32.0 \ --hash=sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56 \ --hash=sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0 # via pre-commit -werkzeug==3.1.6 \ - --hash=sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25 \ - --hash=sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131 +werkzeug==3.1.7 \ + --hash=sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f \ + --hash=sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351 # via flask -yarl==1.22.0 \ - --hash=sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a \ - --hash=sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da \ - --hash=sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093 \ - --hash=sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79 \ - --hash=sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683 \ - --hash=sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2 \ - --hash=sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff \ - --hash=sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02 \ - --hash=sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03 \ - --hash=sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c \ - --hash=sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c \ - --hash=sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da \ - --hash=sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2 \ - --hash=sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0 \ - --hash=sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53 \ - --hash=sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138 \ - --hash=sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4 \ - --hash=sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d \ - --hash=sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f \ - --hash=sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1 \ - --hash=sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d \ - --hash=sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694 \ - --hash=sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3 \ - --hash=sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a \ - --hash=sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b \ - --hash=sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5 \ - --hash=sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f \ - --hash=sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df \ - --hash=sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b \ - --hash=sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b \ - --hash=sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2 \ - --hash=sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708 \ - --hash=sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10 \ - --hash=sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b \ - --hash=sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e \ - --hash=sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33 \ - --hash=sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590 \ - --hash=sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53 \ - --hash=sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f \ - --hash=sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1 \ - --hash=sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27 \ - --hash=sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273 \ - --hash=sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601 \ - --hash=sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784 \ - --hash=sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71 \ - --hash=sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b \ - --hash=sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a \ - --hash=sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c \ - --hash=sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face \ - --hash=sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d \ - --hash=sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e \ - --hash=sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9 \ - --hash=sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95 \ - --hash=sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf \ - --hash=sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca \ - --hash=sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62 \ - --hash=sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67 \ - --hash=sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529 \ - --hash=sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486 \ - --hash=sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a \ - --hash=sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d \ - --hash=sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b \ - --hash=sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e \ - --hash=sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8 \ - --hash=sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd \ - --hash=sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249 +yarl==1.23.0 \ + --hash=sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc \ + --hash=sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4 \ + --hash=sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85 \ + --hash=sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993 \ + --hash=sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222 \ + --hash=sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de \ + --hash=sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25 \ + --hash=sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e \ + --hash=sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2 \ + --hash=sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e \ + --hash=sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860 \ + --hash=sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957 \ + --hash=sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760 \ + --hash=sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52 \ + --hash=sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788 \ + --hash=sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912 \ + --hash=sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719 \ + --hash=sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035 \ + --hash=sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220 \ + --hash=sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412 \ + --hash=sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05 \ + --hash=sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41 \ + --hash=sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4 \ + --hash=sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4 \ + --hash=sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd \ + --hash=sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748 \ + --hash=sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a \ + --hash=sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4 \ + --hash=sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34 \ + --hash=sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069 \ + --hash=sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25 \ + --hash=sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2 \ + --hash=sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb \ + --hash=sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f \ + --hash=sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5 \ + --hash=sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8 \ + --hash=sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c \ + --hash=sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512 \ + --hash=sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6 \ + --hash=sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5 \ + --hash=sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9 \ + --hash=sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072 \ + --hash=sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5 \ + --hash=sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277 \ + --hash=sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a \ + --hash=sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6 \ + --hash=sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae \ + --hash=sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26 \ + --hash=sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2 \ + --hash=sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4 \ + --hash=sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70 \ + --hash=sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723 \ + --hash=sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c \ + --hash=sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9 \ + --hash=sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5 \ + --hash=sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e \ + --hash=sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c \ + --hash=sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4 \ + --hash=sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0 \ + --hash=sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2 \ + --hash=sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b \ + --hash=sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7 \ + --hash=sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750 \ + --hash=sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2 \ + --hash=sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474 \ + --hash=sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716 \ + --hash=sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7 \ + --hash=sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123 \ + --hash=sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007 \ + --hash=sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595 \ + --hash=sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe \ + --hash=sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea \ + --hash=sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598 \ + --hash=sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679 \ + --hash=sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8 \ + --hash=sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83 \ + --hash=sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6 \ + --hash=sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f \ + --hash=sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94 \ + --hash=sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51 \ + --hash=sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120 \ + --hash=sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039 \ + --hash=sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1 \ + --hash=sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05 \ + --hash=sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb \ + --hash=sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144 \ + --hash=sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa \ + --hash=sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a \ + --hash=sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99 \ + --hash=sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928 \ + --hash=sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d \ + --hash=sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3 \ + --hash=sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434 \ + --hash=sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86 \ + --hash=sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46 \ + --hash=sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319 \ + --hash=sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67 \ + --hash=sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c \ + --hash=sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169 \ + --hash=sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c \ + --hash=sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59 \ + --hash=sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107 \ + --hash=sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4 \ + --hash=sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a \ + --hash=sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb \ + --hash=sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f \ + --hash=sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769 \ + --hash=sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432 \ + --hash=sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090 \ + --hash=sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764 \ + --hash=sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d \ + --hash=sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4 \ + --hash=sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b \ + --hash=sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d \ + --hash=sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543 \ + --hash=sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24 \ + --hash=sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5 \ + --hash=sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b \ + --hash=sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d \ + --hash=sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b \ + --hash=sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6 \ + --hash=sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735 \ + --hash=sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e \ + --hash=sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28 \ + --hash=sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3 \ + --hash=sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401 \ + --hash=sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6 \ + --hash=sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d # via # aiohttp # ocotilloapi diff --git a/uv.lock b/uv.lock index 7e98c6a5..69907f75 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -42,59 +42,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -152,28 +152,28 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "apitally" -version = "0.24.1" +version = "0.24.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "opentelemetry-sdk" }, { name = "psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/a0/f3d66fc04d5cc6de2b4c45534329c70fe506f63f0ffc2603ed485584c456/apitally-0.24.1.tar.gz", hash = "sha256:18d476871e081ff8f42fd0b631b33ccaf631be404abe9a54e30621117389a70e", size = 220724, upload-time = "2026-02-16T12:44:06.635Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/1c/949fb90a4d0475028480433116a481208be05ce06e40afcae050e6053a29/apitally-0.24.4.tar.gz", hash = "sha256:78447204cb1b0e6b409129ae8b13ddcdfe03bab648af8662cd73fc24a8e30ec2", size = 225388, upload-time = "2026-03-30T03:29:09.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/c8/2b2d566edf46b5a50bd3178770089269d1dcf17f4398157b35c9f54c02c3/apitally-0.24.1-py3-none-any.whl", hash = "sha256:90adc1ad7698e83833622f4673e72c46e39c9474385a891dd3ce4e413c1f0863", size = 47829, upload-time = "2026-02-16T12:44:08.833Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/49690a550caae96dcd6b4e376d1d0e094c180eb1ee1224fe26b8956aecb8/apitally-0.24.4-py3-none-any.whl", hash = "sha256:764f3c9dc907ec2014f8f420d66db091826106eb7b306ce871238c647029a019", size = 48067, upload-time = "2026-03-30T03:29:10.538Z" }, ] [package.optional-dependencies] @@ -449,43 +449,59 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] @@ -514,7 +530,7 @@ wheels = [ [[package]] name = "cloud-sql-python-connector" -version = "1.20.0" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -524,9 +540,9 @@ dependencies = [ { name = "google-auth" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/9a/b349d7fe9d4dd5f7b72d58b1b3c422d4e3e62854c5871355b7f4faf66281/cloud_sql_python_connector-1.20.0.tar.gz", hash = "sha256:fdd96153b950040b0252453115604c142922b72cf3636146165a648ac5f6fc30", size = 44208, upload-time = "2026-01-13T01:09:11.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/cd/e54ca42ea4f386571ab99cb363e15d45b2c1228dc6f8120101e6cd6db9e1/cloud_sql_python_connector-1.20.1.tar.gz", hash = "sha256:7e826875c5c284e1dfd872ab81d8c75eb82dd67ad1bbf43b9e74489342765255", size = 44235, upload-time = "2026-03-16T16:53:04.927Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/1a/5d5015c7c1175d9abf985c07b0665151394c497649ba8026985ba7aba26b/cloud_sql_python_connector-1.20.0-py3-none-any.whl", hash = "sha256:aa7c30631c5f455d14d561d7b0b414a97652a1b582a301f5570ba2cea2aa9105", size = 50101, upload-time = "2026-01-13T01:09:09.748Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4eaf81d926b65247b3ff9376f71cf3aac287b3d8af1b2a12fd2605fd8534/cloud_sql_python_connector-1.20.1-py3-none-any.whl", hash = "sha256:c00f9d81205eb658fe06f9f353e00646eb3f55d2d86de01dc1222eec1f5f2fc9", size = 50100, upload-time = "2026-03-16T16:53:03.475Z" }, ] [[package]] @@ -745,7 +761,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -754,23 +770,23 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] name = "fastapi-pagination" -version = "0.15.10" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/36/4314836683bec1b33195bbaf2d74e1515cfcbb7e7ef5431ef515b864a5d0/fastapi_pagination-0.15.10.tar.gz", hash = "sha256:0ba7d4f795059a91a9e89358af129f2114876452c1defaf198ea8e3419e9a3cd", size = 575160, upload-time = "2026-02-08T13:13:40.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/71/7381bf08f9fb6a890ec41a7ee5191ca564e0af94b899c2006fddaf07d78f/fastapi_pagination-0.15.12.tar.gz", hash = "sha256:914b41e07b8556de34c12d3568c9b7137eb62a3558420061a4acbebf7e729a08", size = 595227, upload-time = "2026-03-28T12:51:03.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/95/cce73569317fdba138c315b980c39c6a035baa0ea5867d12276f1d312cff/fastapi_pagination-0.15.10-py3-none-any.whl", hash = "sha256:d50071ebc93b519391f16ff6c3ba9e3603bd659963fe6774ba2f4d5037e17fd8", size = 60798, upload-time = "2026-02-08T13:13:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2f/644fd77ecac100da965221751ae4f7604e149c58c46c1d96c37e828bb5f7/fastapi_pagination-0.15.12-py3-none-any.whl", hash = "sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a", size = 60921, upload-time = "2026-03-28T12:51:04.288Z" }, ] [[package]] @@ -888,20 +904,20 @@ wheels = [ [[package]] name = "geoalchemy2" -version = "0.18.1" +version = "0.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/df/f6d689120a15a2287794e16696c3bdb4cf2e53038255d288b61a4d59e1fa/geoalchemy2-0.18.1.tar.gz", hash = "sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124", size = 239302, upload-time = "2025-11-18T15:12:05.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/9d/02d54b0d61a2f331044ea7b4216ee1c98a8ad3a69906dfc3967c51501623/geoalchemy2-0.18.4.tar.gz", hash = "sha256:5719e2bb040d5c406d5d03425fec87997ce9351843b053ca11373a0f5a31971b", size = 239766, upload-time = "2026-03-02T14:48:48.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/25/b3d6fc757d8d909e0e666ec6fbf1b7914e9ad18d6e1b08994cd9d2e63330/geoalchemy2-0.18.1-py3-none-any.whl", hash = "sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb", size = 81261, upload-time = "2025-11-18T15:12:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/56/92/60ee150773376aa9b542dcd44660e4ae9f5f14e690c8795be476156de598/geoalchemy2-0.18.4-py3-none-any.whl", hash = "sha256:89e6680dcbb6b8d8c784dcaa889e48ab2783aa42487ee5730fdbd7a7c7ddf6ec", size = 81097, upload-time = "2026-03-02T14:48:47.456Z" }, ] [[package]] name = "google-api-core" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -910,23 +926,22 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, ] [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [[package]] @@ -944,7 +959,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.9.0" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -954,9 +969,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, ] [[package]] @@ -991,48 +1006,48 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] [[package]] name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -1375,52 +1390,52 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] [[package]] @@ -1544,13 +1559,13 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = "==24.1.0" }, { name = "aiohappyeyeballs", specifier = "==2.6.1" }, - { name = "aiohttp", specifier = "==3.13.3" }, + { name = "aiohttp", specifier = "==3.13.4" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "alembic", specifier = "==1.18.4" }, { name = "annotated-types", specifier = "==0.7.0" }, - { name = "anyio", specifier = "==4.12.1" }, - { name = "apitally", extras = ["fastapi"], specifier = "==0.24.1" }, + { name = "anyio", specifier = "==4.13.0" }, + { name = "apitally", extras = ["fastapi"], specifier = "==0.24.4" }, { name = "asgiref", specifier = "==3.11.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, @@ -1560,25 +1575,25 @@ requires-dist = [ { name = "cachetools", specifier = "==5.5.2" }, { name = "certifi", specifier = "==2025.8.3" }, { name = "cffi", specifier = "==2.0.0" }, - { name = "charset-normalizer", specifier = "==3.4.4" }, + { name = "charset-normalizer", specifier = "==3.4.6" }, { name = "click", specifier = "==8.3.1" }, - { name = "cloud-sql-python-connector", specifier = "==1.20.0" }, + { name = "cloud-sql-python-connector", specifier = "==1.20.1" }, { name = "cryptography", specifier = "==46.0.6" }, { name = "dnspython", specifier = "==2.8.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "email-validator", specifier = "==2.3.0" }, - { name = "fastapi", specifier = "==0.129.0" }, - { name = "fastapi-pagination", specifier = "==0.15.10" }, + { name = "fastapi", specifier = "==0.135.2" }, + { name = "fastapi-pagination", specifier = "==0.15.12" }, { name = "frozenlist", specifier = "==1.8.0" }, - { name = "geoalchemy2", specifier = "==0.18.1" }, - { name = "google-api-core", specifier = "==2.29.0" }, - { name = "google-auth", specifier = "==2.48.0" }, + { name = "geoalchemy2", specifier = "==0.18.4" }, + { name = "google-api-core", specifier = "==2.30.0" }, + { name = "google-auth", specifier = "==2.49.1" }, { name = "google-cloud-core", specifier = "==2.5.0" }, - { name = "google-cloud-storage", specifier = "==3.9.0" }, + { name = "google-cloud-storage", specifier = "==3.10.1" }, { name = "google-crc32c", specifier = "==1.8.0" }, { name = "google-resumable-media", specifier = "==2.8.0" }, - { name = "googleapis-common-protos", specifier = "==1.72.0" }, - { name = "greenlet", specifier = "==3.3.1" }, + { name = "googleapis-common-protos", specifier = "==1.73.1" }, + { name = "greenlet", specifier = "==3.3.2" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "httpcore", specifier = "==1.0.9" }, @@ -1590,17 +1605,17 @@ requires-dist = [ { name = "mako", specifier = "==1.3.10" }, { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, - { name = "numpy", specifier = "==2.4.2" }, + { name = "numpy", specifier = "==2.4.4" }, { name = "packaging", specifier = "==26.0" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, - { name = "phonenumbers", specifier = "==9.0.24" }, + { name = "phonenumbers", specifier = "==9.0.26" }, { name = "pillow", specifier = "==12.1.1" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "pre-commit", specifier = "==4.5.1" }, { name = "propcache", specifier = "==0.4.1" }, - { name = "proto-plus", specifier = "==1.27.1" }, + { name = "proto-plus", specifier = "==1.27.2" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyasn1", specifier = "==0.6.3" }, @@ -1609,8 +1624,8 @@ requires-dist = [ { name = "pydantic", specifier = "==2.12.5" }, { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygeoapi", specifier = "==0.22.0" }, - { name = "pygments", specifier = "==2.19.2" }, - { name = "pyjwt", specifier = "==2.12.0" }, + { name = "pygments", specifier = "==2.20.0" }, + { name = "pyjwt", specifier = "==2.12.1" }, { name = "pyproj", specifier = "==3.7.2" }, { name = "pyshp", specifier = "==2.3.1" }, { name = "pytest", specifier = "==9.0.2" }, @@ -1619,27 +1634,27 @@ requires-dist = [ { name = "python-jose", specifier = ">=3.5.0" }, { name = "python-multipart", specifier = "==0.0.22" }, { name = "pytz", specifier = "==2025.2" }, - { name = "requests", specifier = "==2.33.0" }, + { name = "requests", specifier = "==2.33.1" }, { name = "rsa", specifier = "==4.9.1" }, { name = "scramp", specifier = "==1.4.8" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.53.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.56.0" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, { name = "sniffio", specifier = "==1.3.1" }, - { name = "sqlalchemy", specifier = "==2.0.46" }, + { name = "sqlalchemy", specifier = "==2.0.48" }, { name = "sqlalchemy-continuum", specifier = "==1.6.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, { name = "starlette", specifier = "==0.52.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.0" }, - { name = "typer", specifier = "==0.23.1" }, + { name = "typer", specifier = "==0.24.1" }, { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "tzdata", specifier = "==2025.3" }, { name = "urllib3", specifier = "==2.6.3" }, { name = "utm", specifier = "==0.8.1" }, - { name = "uvicorn", specifier = "==0.40.0" }, - { name = "yarl", specifier = "==1.22.0" }, + { name = "uvicorn", specifier = "==0.42.0" }, + { name = "yarl", specifier = "==1.23.0" }, ] [package.metadata.requires-dev] @@ -1789,11 +1804,11 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.24" +version = "9.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/277ae37edb6f5189937223cc3b2a21b8de9d70ac2d0eb684cf33ba055fdd/phonenumbers-9.0.24.tar.gz", hash = "sha256:97c38e4b5b8af992c75de01bd9c0f84e61701a9c900fd84f49744714910a4dc3", size = 2298138, upload-time = "2026-02-13T11:28:57.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/a3/3720326431a23c8e8944a07cdf51520608f1fded87e32e991116fdb801bd/phonenumbers-9.0.26.tar.gz", hash = "sha256:9e582c827f0f5503cddeebef80099475a52ffa761551d8384099c7ec71298cbf", size = 2298587, upload-time = "2026-03-13T11:34:19.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/c7/b01beac6077df7261d92c6b52408617690147144d8946f6f6ecb7d9766ab/phonenumbers-9.0.24-py2.py3-none-any.whl", hash = "sha256:fa86ab7112ef8b286a811392311bd76bbbae7d1d271c2ed26cf73f2e9fa4d3c6", size = 2584198, upload-time = "2026-02-13T11:28:55.334Z" }, + { url = "https://files.pythonhosted.org/packages/dd/93/8825b3c9c23e595f34aa11735b29550c27a0f57fe4fc8c9ee737390566ca/phonenumbers-9.0.26-py2.py3-none-any.whl", hash = "sha256:ff473da5712965b6c7f7a31cbff8255864df694eb48243771133ecb761e807c1", size = 2584969, upload-time = "2026-03-13T11:34:16.671Z" }, ] [[package]] @@ -1959,14 +1974,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.27.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, ] [[package]] @@ -2216,11 +2231,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2234,11 +2249,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -2350,11 +2365,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -2558,7 +2573,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2566,9 +2581,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -2676,15 +2691,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, ] [package.optional-dependencies] @@ -2764,37 +2779,41 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.46" +version = "2.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, - { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, - { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, - { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, - { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, - { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] @@ -2876,7 +2895,7 @@ wheels = [ [[package]] name = "typer" -version = "0.23.1" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2884,9 +2903,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -2960,15 +2979,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] @@ -2999,80 +3018,88 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] From 65e51c66783fd4fc7a10a8adb291666d3b94b315 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 30 Mar 2026 14:33:44 -0600 Subject: [PATCH 137/163] feat: enhance logging with debug timing for various operations and add well export endpoint --- api/asset.py | 77 +++++++++++++++--- api/sample.py | 71 +++++++++++----- api/thing.py | 27 +++++- core/app.py | 61 +++++--------- db/engine.py | 60 ++++++++++++-- docker-compose.yml | 13 +-- entrypoint.sh | 7 +- schemas/well_export.py | 15 ++++ services/gcs_helper.py | 77 +++++++++++++++++- services/observation_helper.py | 31 ++++++- services/sample_helper.py | 118 ++++++++++++++++++++++++--- services/thing_helper.py | 27 ++++++ services/well_details_helper.py | 140 ++++++++++++++++++++++++++++++++ tests/test_asset.py | 121 ++++++++++++++++++++++++--- tests/test_request_timing.py | 33 ++++---- 15 files changed, 749 insertions(+), 129 deletions(-) create mode 100644 schemas/well_export.py diff --git a/api/asset.py b/api/asset.py index f52122bc..b6555ab4 100644 --- a/api/asset.py +++ b/api/asset.py @@ -14,12 +14,19 @@ # limitations under the License. # =============================================================================== +import logging +import time + from fastapi import APIRouter, Depends, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError from starlette.concurrency import run_in_threadpool -from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT, HTTP_204_NO_CONTENT +from starlette.status import ( + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_409_CONFLICT, +) from api.pagination import CustomPage from core.dependencies import ( @@ -33,16 +40,38 @@ from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter +from services.env import get_bool_env from services.exceptions_helper import PydanticStyleException from services.query_helper import simple_get_by_id router = APIRouter(prefix="/asset", tags=["asset"]) +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) def get_storage_bucket(): - from services.gcs_helper import get_storage_bucket as get_gcs_storage_bucket + from services.gcs_helper import ( + get_storage_bucket as get_gcs_storage_bucket, + ) - return get_gcs_storage_bucket() + started_at = time.perf_counter() + try: + return get_gcs_storage_bucket() + finally: + if is_debug_timing_enabled(): + logger.info( + "asset storage bucket resolved", + extra={ + "event": "asset_storage_bucket_resolved", + "bucket_resolution_ms": round( + (time.perf_counter() - started_at) * 1000, + 2, + ), + }, + ) def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> None: @@ -53,8 +82,8 @@ def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> Non error_message = error.orig.args[0]["M"] if ( - error_message - == 'null value in column "thing_id" of relation "asset_thing_association" violates not-null constraint' + error_message == 'null value in column "thing_id" of relation ' + '"asset_thing_association" violates not-null constraint' ): """ Developer's notes @@ -70,10 +99,13 @@ def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> Non "input": {"thing_id": payload.thing_id}, } - raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[detail], + ) -# POST ========================================================================= +# POST ======================================================================= @router.post( "/upload", status_code=HTTP_201_CREATED, @@ -86,7 +118,21 @@ async def upload_asset( from services.gcs_helper import gcs_upload # GCS client calls are synchronous and can block for large uploads. + request_started_at = time.perf_counter() uri, blob_name = await run_in_threadpool(gcs_upload, file, bucket) + if is_debug_timing_enabled(): + logger.info( + "asset upload request completed", + extra={ + "event": "asset_upload_request_completed", + "filename": file.filename, + "content_type": file.content_type, + "upload_request_ms": round( + (time.perf_counter() - request_started_at) * 1000, + 2, + ), + }, + ) return { "uri": uri, "storage_path": blob_name, @@ -110,7 +156,11 @@ async def add_asset( # this storage path and thing_id from services.gcs_helper import check_asset_exists - existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id) + existing_asset = check_asset_exists( + session, + storage_path, + thing_id=thing_id, + ) if existing_asset: # If an asset already exists, return it return existing_asset @@ -136,7 +186,7 @@ async def add_asset( database_error_handler(asset_data, e) -# GET ========================================================================== +# GET ======================================================================== """ Developer's notes @@ -189,11 +239,11 @@ async def get_asset( asset = simple_get_by_id(session, Asset, asset_id) - add_signed_url(asset, bucket) + asset = await run_in_threadpool(add_signed_url, asset, bucket) return asset -# PATCH ======================================================================== +# PATCH ====================================================================== @router.patch("/{asset_id}") async def update_asset( asset_id: int, @@ -207,7 +257,7 @@ async def update_asset( return model_patcher(session, Asset, asset_id, asset_data, user=user) -# DELETE ======================================================================= +# DELETE ===================================================================== @router.delete("/{asset_id}", status_code=HTTP_204_NO_CONTENT) @@ -215,7 +265,8 @@ async def delete_asset( asset_id: int, session: session_dependency, user: admin_dependency ): - # TODO: Interesting issue here. we don't have a way of tracking who deleted a record + # TODO: Interesting issue here. We don't have a way of tracking + # who deleted a record. return model_deleter(session, Asset, asset_id) diff --git a/api/sample.py b/api/sample.py index 22003a12..fdd471cb 100644 --- a/api/sample.py +++ b/api/sample.py @@ -27,11 +27,13 @@ ) from db.sample import Sample from schemas import ResourceNotFoundResponse -from schemas.sample import SampleResponse, CreateSample, UpdateSample -from services.query_helper import simple_get_by_id +from schemas.sample import CreateSample, SampleResponse, UpdateSample from services.crud_helper import model_patcher, model_deleter, model_adder from services.exceptions_helper import PydanticStyleException -from services.sample_helper import get_db_samples +from services.sample_helper import ( + get_db_samples, + get_sample_by_id_with_relationships, +) router = APIRouter( prefix="/sample", @@ -42,44 +44,56 @@ # TODO: add the following database validation handlers # invalid sample_id # invalid lexicon terms -# sample_date of the Sample model cannot be before the event_date of the FieldEvent model +# sample_date of the Sample model cannot be before the +# event_date of the FieldEvent model def database_error_handler( - payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError + payload: CreateSample | UpdateSample, + error: IntegrityError | ProgrammingError, ) -> None: """ Handle errors raised by the database when adding or updating a sample. """ error_message = error.orig.args[0]["M"] if ( - error_message - == 'duplicate key value violates unique constraint "sample_sample_name_key"' + error_message == "duplicate key value violates unique " + 'constraint "sample_sample_name_key"' ): detail = { "loc": ["body", "sample_name"], - "msg": f"Sample with sample_name {payload.sample_name} already exists.", + "msg": ( + f"Sample with sample_name {payload.sample_name} " "already exists." + ), "type": "value_error", "input": {"sample_name": payload.sample_name}, } elif ( error_message - == 'insert or update on table "sample" violates foreign key constraint "sample_field_activity_id_fkey"' + == 'insert or update on table "sample" violates foreign key constraint ' + '"sample_field_activity_id_fkey"' ): detail = { "loc": ["body", "field_activity_id"], - "msg": f"FieldActivity with ID {payload.field_activity_id} does not exist.", + "msg": ( + f"FieldActivity with ID {payload.field_activity_id} " "does not exist." + ), "type": "value_error", "input": {"field_activity_id": payload.field_activity_id}, } - raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[detail], + ) # ============= Post ============================================= @router.post("", status_code=HTTP_201_CREATED) async def add_sample( - sample_data: CreateSample, session: session_dependency, user: admin_dependency + sample_data: CreateSample, + session: session_dependency, + user: admin_dependency, ) -> SampleResponse: """ Endpoint to add a sample. @@ -106,7 +120,13 @@ async def update_sample( try: # since this is only one instance N+1 is not a concern for # FieldActivity, FieldEvent, and Thing - return model_patcher(session, Sample, sample_id, sample_data, user=user) + return model_patcher( + session, + Sample, + sample_id, + sample_data, + user=user, + ) except (IntegrityError, ProgrammingError) as e: database_error_handler(sample_data, e) @@ -124,27 +144,38 @@ async def get_samples( """ Endpoint to retrieve samples. """ - return get_db_samples(session, thing_id, sort=sort, order=order, filter_=filter_) + return get_db_samples( + session, + thing_id, + sort=sort, + order=order, + filter_=filter_, + ) @router.get("/{sample_id}", summary="Get Sample by ID") async def get_sample_by_id( - sample_id: int, session: session_dependency, user: viewer_dependency + sample_id: int, + session: session_dependency, + user: viewer_dependency, ) -> SampleResponse | ResourceNotFoundResponse: """ Endpoint to retrieve a sample by its ID. """ - # since this is only one instance N+1 is not a concern - # FieldActivity, FieldEvent, and Thing - return simple_get_by_id(session, Sample, sample_id) + return get_sample_by_id_with_relationships(session, sample_id) # ======= DELETE =============================================================== -@router.delete("/{sample_id}", summary="Delete Sample by ID") +@router.delete( + "/{sample_id}", + summary="Delete Sample by ID", +) async def delete_sample_by_id( - sample_id: int, session: session_dependency, user: admin_dependency + sample_id: int, + session: session_dependency, + user: admin_dependency, ) -> Response: return model_deleter(session, Sample, sample_id) diff --git a/api/thing.py b/api/thing.py index 4a8be334..0babb871 100644 --- a/api/thing.py +++ b/api/thing.py @@ -52,6 +52,7 @@ UpdateWellScreen, ) from schemas.well_details import WellDetailsResponse +from schemas.well_export import WellExportResponse from services.crud_helper import model_patcher, model_adder, model_deleter from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category @@ -69,7 +70,10 @@ modify_well_descriptor_tables, WELL_DESCRIPTOR_MODEL_MAP, ) -from services.well_details_helper import get_well_details_payload +from services.well_details_helper import ( + get_well_details_payload, + get_well_export_payload, +) router = APIRouter(prefix="/thing", tags=["thing"]) @@ -201,6 +205,27 @@ async def get_well_details( ) +@router.get( + "/water-well/{thing_id}/export", + summary="Get water well export payload", + status_code=HTTP_200_OK, +) +async def get_well_export( + user: viewer_dependency, + thing_id: int, + session: session_dependency, + request: Request, +) -> WellExportResponse: + """ + Retrieve the minimal payload needed for field sheet export generation. + """ + return get_well_export_payload( + session=session, + request=request, + thing_id=thing_id, + ) + + @router.get( "/water-well/{thing_id}/well-screen", summary="Get well screens by water well ID", diff --git a/core/app.py b/core/app.py index 43fd705a..02408ff6 100644 --- a/core/app.py +++ b/core/app.py @@ -13,10 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import logging import os -import asyncio import time -import logging +from uuid import uuid4 from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -44,7 +44,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: seed_all(10, skip_if_exists=True) app.state.instance_ready_at = time.perf_counter() - app.state.first_request_pending = True logger.info( "instance startup complete", extra={ @@ -69,55 +68,39 @@ def create_base_app() -> FastAPI: ) app.state.process_boot_started_at = time.perf_counter() app.state.instance_ready_at = None - app.state.first_request_pending = True - app.state.request_timing_lock = asyncio.Lock() @app.middleware("http") - async def log_request_timing(request: Request, call_next): - request_started_at = time.perf_counter() - async with app.state.request_timing_lock: - is_first_request = app.state.first_request_pending - app.state.first_request_pending = False + async def log_request_lifecycle(request: Request, call_next): + request_id = uuid4().hex + request.state.request_id = request_id + logger.info( + "request started %s %s", + request.method, + request.url.path, + extra={ + "event": "request_started", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + }, + ) status_code = 500 try: response = await call_next(request) status_code = response.status_code return response finally: - request_duration_ms = round( - (time.perf_counter() - request_started_at) * 1000, 2 - ) - startup_ms = round( - ( - (app.state.instance_ready_at or request_started_at) - - app.state.process_boot_started_at - ) - * 1000, - 2, - ) - uptime_before_request_ms = round( - ( - ( - request_started_at - - (app.state.instance_ready_at or request_started_at) - ) - ) - * 1000, - 2, - ) - request_kind = "cold" if is_first_request else "warm" - logger.info( - "request timing", + "request completed %s %s status=%s", + request.method, + request.url.path, + status_code, extra={ - "event": "request_timing", - "request_kind": request_kind, + "event": "request_completed", + "request_id": request_id, "method": request.method, "path": request.url.path, "status_code": status_code, - "request_duration_ms": request_duration_ms, - "startup_ms": startup_ms, - "uptime_before_request_ms": uptime_before_request_ms, }, ) diff --git a/db/engine.py b/db/engine.py index eb841306..c7a7721d 100644 --- a/db/engine.py +++ b/db/engine.py @@ -16,13 +16,13 @@ import copy import getpass +import logging import os +import time from contextlib import contextmanager from dotenv import load_dotenv -from sqlalchemy import ( - create_engine, -) +from sqlalchemy import create_engine, event from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import ( sessionmaker, @@ -31,9 +31,49 @@ from services.env import get_bool_env -# Load .env file - don't override env vars already set (e.g., by test framework) +# Load .env file. Do not override env vars already set by the runtime. load_dotenv(override=False) driver = os.environ.get("DB_DRIVER", "") +logger = logging.getLogger(__name__) + + +def _install_pool_logging(engine): + @event.listens_for(engine, "checkout") + def log_checkout(dbapi_connection, connection_record, connection_proxy): + connection_record.info["checked_out_at"] = time.perf_counter() + logger.info( + "db pool checkout", + extra={ + "event": "db_pool_checkout", + "pool_status": engine.pool.status(), + }, + ) + + @event.listens_for(engine, "checkin") + def log_checkin(dbapi_connection, connection_record): + checked_out_at = connection_record.info.pop("checked_out_at", None) + hold_ms = None + if checked_out_at is not None: + hold_ms = round((time.perf_counter() - checked_out_at) * 1000, 2) + logger.info( + "db pool checkin", + extra={ + "event": "db_pool_checkin", + "connection_hold_ms": hold_ms, + "pool_status": engine.pool.status(), + }, + ) + + @event.listens_for(engine, "invalidate") + def log_invalidate(dbapi_connection, connection_record, exception): + logger.warning( + "db pool invalidate", + extra={ + "event": "db_pool_invalidate", + "pool_status": engine.pool.status(), + "exception_type": (type(exception).__name__ if exception else None), + }, + ) def get_iam_login_token() -> str: @@ -85,7 +125,11 @@ def asyncify_connection(): else: connect_kwargs["password"] = password - connection = connector.connect_async(instance_name, "asyncpg", **connect_kwargs) + connection = connector.connect_async( + instance_name, + "asyncpg", + **connect_kwargs, + ) return AsyncAdapt_asyncpg_connection( engine.dialect.dbapi, @@ -133,6 +177,7 @@ def getconn(): # Configure connection pool for parallel transfers pool_size = int(os.environ.get("DB_POOL_SIZE", "10")) max_overflow = int(os.environ.get("DB_MAX_OVERFLOW", "20")) + pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( "postgresql+pg8000://", @@ -140,8 +185,10 @@ def getconn(): echo=False, pool_size=pool_size, max_overflow=max_overflow, + pool_timeout=pool_timeout, pool_pre_ping=True, ) + _install_pool_logging(engine) return engine connector = Connector() @@ -172,6 +219,7 @@ def getconn(): # max_overflow: additional connections during peak usage pool_size = int(os.environ.get("DB_POOL_SIZE", "10")) max_overflow = int(os.environ.get("DB_MAX_OVERFLOW", "20")) + pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( url, @@ -179,8 +227,10 @@ def getconn(): plugins=["geoalchemy2"], pool_size=pool_size, max_overflow=max_overflow, + pool_timeout=pool_timeout, pool_pre_ping=True, # Verify connections before use ) + _install_pool_logging(engine) async_engine = create_async_engine( url.replace("postgresql+pg8000", "postgresql+asyncpg"), diff --git a/docker-compose.yml b/docker-compose.yml index fcbd09f1..78120d76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,16 +2,17 @@ services: db: - build: - context: . - dockerfile: ./docker/db/Dockerfile - platform: linux/amd64 + image: postgis/postgis:17-3.5 + # build: +# context: . +# dockerfile: ./docker/db/Dockerfile +# platform: linux/amd64 environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=ocotilloapi_dev ports: - - 5432:5432 + - "5432:5432" volumes: - postgres_data_dev:/var/lib/postgresql/data - ./docker/db/init:/docker-entrypoint-initdb.d:ro @@ -41,7 +42,7 @@ services: - PYGEOAPI_POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - GOOGLE_APPLICATION_CREDENTIALS=/app/gcs_credentials.json ports: - - 8000:8000 + - "8000:8000" depends_on: db: condition: service_healthy # <-- wait for DB to be ready diff --git a/entrypoint.sh b/entrypoint.sh index 18e0badc..c89c621c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,6 +7,7 @@ DB_NAME="${POSTGRES_DB:-postgres}" APP_MODULE="${APP_MODULE:-main:app}" APP_PORT="${APP_PORT:-8000}" RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}" +UVICORN_RELOAD="${UVICORN_RELOAD:-false}" # Wait for PostgreSQL to be ready until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -d "$DB_NAME"; do @@ -21,4 +22,8 @@ if [ "$RUN_MIGRATIONS" = "true" ]; then fi echo "Starting the application..." -uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" --reload +if [ "$UVICORN_RELOAD" = "true" ]; then + uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" --reload +else + uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" +fi diff --git a/schemas/well_export.py b/schemas/well_export.py new file mode 100644 index 00000000..9259e3de --- /dev/null +++ b/schemas/well_export.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict, Field + +from schemas.contact import ContactResponse +from schemas.deployment import DeploymentResponse +from schemas.sensor import SensorResponse +from schemas.thing import WellResponse + + +class WellExportResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + well: WellResponse + contacts: list[ContactResponse] = Field(default_factory=list) + sensors: list[SensorResponse] = Field(default_factory=list) + deployments: list[DeploymentResponse] = Field(default_factory=list) diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 55f2cdf2..b524f211 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -18,6 +18,7 @@ import json import logging import os +import time from functools import lru_cache from hashlib import md5 @@ -26,12 +27,18 @@ from core.settings import settings from db import Asset, AssetThingAssociation +from services.env import get_bool_env GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME") GCS_BUCKET_BASE_URL = f"https://storage.cloud.google.com/{GCS_BUCKET_NAME}/uploads" GCS_LOOKUP_TIMEOUT_SECS = float(os.environ.get("GCS_LOOKUP_TIMEOUT_SECS", "15")) GCS_UPLOAD_TIMEOUT_SECS = float(os.environ.get("GCS_UPLOAD_TIMEOUT_SECS", "120")) logger = logging.getLogger(__name__) +HASH_CHUNK_SIZE = 1024 * 1024 + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) @lru_cache(maxsize=1) @@ -51,8 +58,8 @@ def get_storage_client(): # Create storage client client = storage.Client(credentials=creds) else: - # Use application default credentials (from ~/.config/gcloud/application_default_credentials.json) - # This will automatically use GOOGLE_APPLICATION_CREDENTIALS if set, or the default location + # Use application default credentials from gcloud or + # GOOGLE_APPLICATION_CREDENTIALS when present. client = storage.Client() return client @@ -69,33 +76,90 @@ def get_storage_bucket(client=None, bucket: str = None): return _get_cached_bucket(bucket_name) +def _log_stage(stage: str, started_at: float, **extra): + if not is_debug_timing_enabled(): + return + logger.info( + "gcs stage timing", + extra={ + "event": "gcs_stage_timing", + "stage": stage, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + **extra, + }, + ) + + +def _hash_file(file_obj) -> str: + hasher = md5() + while True: + chunk = file_obj.read(HASH_CHUNK_SIZE) + if not chunk: + break + hasher.update(chunk) + return hasher.hexdigest() + + def make_blob_name_and_uri(file): + started_at = time.perf_counter() head, extension = os.path.splitext(file.filename) - file_id = md5(file.file.read()).hexdigest() + file.file.seek(0) + file_id = _hash_file(file.file) + file.file.seek(0) blob_name = f"{head}_{file_id}{extension}" uri = f"{GCS_BUCKET_BASE_URL}/{blob_name}" + _log_stage( + "hash_file", + started_at, + filename=file.filename, + blob_name=blob_name, + ) return blob_name, uri def gcs_upload(file: UploadFile, bucket=None): + upload_started_at = time.perf_counter() if bucket is None: + bucket_started_at = time.perf_counter() bucket = get_storage_bucket() + _log_stage("resolve_bucket", bucket_started_at, filename=file.filename) # make file id from hash of file contents file.file.seek(0) blob_name, uri = make_blob_name_and_uri(file) + lookup_started_at = time.perf_counter() eblob = bucket.get_blob(blob_name, timeout=GCS_LOOKUP_TIMEOUT_SECS) + _log_stage( + "lookup_blob", + lookup_started_at, + filename=file.filename, + blob_name=blob_name, + blob_exists=eblob is not None, + ) if not eblob: blob = bucket.blob(blob_name) file.file.seek(0) + upload_blob_started_at = time.perf_counter() blob.upload_from_file( file.file, content_type=file.content_type, timeout=GCS_UPLOAD_TIMEOUT_SECS, ) + _log_stage( + "upload_blob", + upload_blob_started_at, + filename=file.filename, + blob_name=blob_name, + ) + _log_stage( + "upload_request_total", + upload_started_at, + filename=file.filename, + blob_name=blob_name, + ) return uri, blob_name @@ -105,12 +169,19 @@ def gcs_remove(uri: str, bucket): def add_signed_url(asset: Asset, bucket): + started_at = time.perf_counter() try: asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( version="v4", expiration=datetime.timedelta(minutes=15), method="GET", ) + _log_stage( + "generate_signed_url", + started_at, + asset_id=getattr(asset, "id", None), + storage_path=getattr(asset, "storage_path", None), + ) except Exception: logger.warning( "Failed to generate signed URL for asset_id=%s storage_path=%s", diff --git a/services/observation_helper.py b/services/observation_helper.py index c049d5b1..f99241db 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -1,4 +1,6 @@ from datetime import datetime +import logging +import time from typing import List from fastapi import Request, Query @@ -24,8 +26,15 @@ GroundwaterLevelObservationResponse, ) from services.exceptions_helper import PydanticStyleException +from services.env import get_bool_env from services.query_helper import simple_get_by_id, order_sort_filter +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + def get_activity_type_from_request(request: Request) -> str: path = request.url.path @@ -235,7 +244,27 @@ def get_observations( if not order: sql = sql.order_by(Observation.observation_datetime.desc()) - return paginate(query=sql, conn=session) + started_at = time.perf_counter() + page = paginate(query=sql, conn=session) + if is_debug_timing_enabled(): + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "observation query completed path=%s thing_id=%s sensor_id=%s sample_id=%s duration_ms=%s", + request.url.path, + thing_id, + sensor_id, + sample_id, + duration_ms, + extra={ + "event": "observation_query_completed", + "path": request.url.path, + "thing_id": thing_id, + "sensor_id": sensor_id, + "sample_id": sample_id, + "duration_ms": duration_ms, + }, + ) + return page def verify_observed_property_corresponds_with_activity_type( diff --git a/services/sample_helper.py b/services/sample_helper.py index 20423de8..e6591ca2 100644 --- a/services/sample_helper.py +++ b/services/sample_helper.py @@ -1,9 +1,88 @@ -from sqlalchemy.orm import Session, joinedload +import logging +import time + +from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload, selectinload +from starlette.status import HTTP_404_NOT_FOUND -from db import FieldEvent, FieldActivity, FieldEventParticipant, Sample +from db import ( + Contact, + FieldActivity, + FieldEvent, + FieldEventParticipant, + GroupThingAssociation, + LocationThingAssociation, + Sample, + Thing, + ThingAquiferAssociation, + ThingContactAssociation, +) from services.query_helper import order_sort_filter +logger = logging.getLogger(__name__) + + +THING_RESPONSE_BASE = ( + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing) +) + + +THING_RESPONSE_LOADER_OPTIONS = ( + THING_RESPONSE_BASE.selectinload(Thing.location_associations).selectinload( + LocationThingAssociation.location + ), + THING_RESPONSE_BASE.selectinload(Thing.well_purposes), + THING_RESPONSE_BASE.selectinload(Thing.well_casing_materials), + THING_RESPONSE_BASE.selectinload(Thing.links), + THING_RESPONSE_BASE.selectinload(Thing.measuring_points), + THING_RESPONSE_BASE.selectinload(Thing.monitoring_frequencies), + THING_RESPONSE_BASE.selectinload(Thing.aquifer_associations).selectinload( + ThingAquiferAssociation.aquifer_system + ), + THING_RESPONSE_BASE.selectinload(Thing.group_associations).selectinload( + GroupThingAssociation.group + ), + THING_RESPONSE_BASE.selectinload(Thing.notes), + THING_RESPONSE_BASE.selectinload(Thing.permission_history), + THING_RESPONSE_BASE.selectinload(Thing.data_provenance), + THING_RESPONSE_BASE.selectinload(Thing.status_history), +) + + +CONTACT_RESPONSE_BASE = selectinload(Sample.field_event_participant) +CONTACT_RESPONSE_PARTICIPANT = CONTACT_RESPONSE_BASE.selectinload( + FieldEventParticipant.participant +) +CONTACT_RESPONSE_THING_ASSOCIATIONS = CONTACT_RESPONSE_PARTICIPANT.selectinload( + Contact.thing_associations +) + + +CONTACT_RESPONSE_LOADER_OPTIONS = ( + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.emails), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.phones), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.addresses), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.incomplete_nma_phones), + CONTACT_RESPONSE_THING_ASSOCIATIONS.selectinload(ThingContactAssociation.thing), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.notes), +) + + +SAMPLE_ACTIVITY_LOADER = joinedload(Sample.field_activity).joinedload( + FieldActivity.field_event +) + + +SAMPLE_LOADER_OPTIONS = ( + SAMPLE_ACTIVITY_LOADER, + *THING_RESPONSE_LOADER_OPTIONS, + *CONTACT_RESPONSE_LOADER_OPTIONS, +) + def get_db_samples( session: Session, @@ -12,15 +91,7 @@ def get_db_samples( sort: str | None = None, filter_: str | None = None, ): - query = session.query(Sample).options( - # Eagerly load related FieldActivity and FieldEvent to avoid N+1 problem - joinedload(Sample.field_activity) - .joinedload(FieldActivity.field_event) - .joinedload(FieldEvent.thing), - joinedload(Sample.field_event_participant).joinedload( - FieldEventParticipant.participant - ), # Eagerly load related Contact - ) + query = session.query(Sample).options(*SAMPLE_LOADER_OPTIONS) if thing_id: query = query.join(FieldActivity) @@ -30,3 +101,28 @@ def get_db_samples( query = order_sort_filter(query, Sample, sort, order, filter_) return paginate(query) + + +def get_sample_by_id_with_relationships(session: Session, sample_id: int) -> Sample: + started_at = time.perf_counter() + sql = select(Sample).where(Sample.id == sample_id) + sql = sql.options(*SAMPLE_LOADER_OPTIONS) + sample = session.execute(sql).unique().scalar_one_or_none() + if sample is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Sample with ID {sample_id} not found.", + ) + + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "sample lookup completed sample_id=%s duration_ms=%s", + sample_id, + duration_ms, + extra={ + "event": "sample_lookup_completed", + "sample_id": sample_id, + "duration_ms": duration_ms, + }, + ) + return sample diff --git a/services/thing_helper.py b/services/thing_helper.py index 221cb121..5b32f54c 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -14,6 +14,8 @@ # limitations under the License. # =============================================================================== from datetime import datetime +import logging +import time from zoneinfo import ZoneInfo from fastapi import Request, HTTPException @@ -43,9 +45,17 @@ from services.audit_helper import audit_add from services.crud_helper import model_patcher from services.exceptions_helper import PydanticStyleException +from services.env import get_bool_env from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter, simple_get_by_id +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + WELL_DESCRIPTOR_MODEL_MAP = { "well_purposes": (WellPurpose, "purpose"), "well_casing_materials": (WellCasingMaterial, "material"), @@ -160,6 +170,7 @@ def verify_thing_type_correspondence(thing: Thing, thing_type: str): def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id: int): + started_at = time.perf_counter() thing_type = get_thing_type_from_request(request) sql = select(Thing).where(Thing.id == thing_id) @@ -175,6 +186,22 @@ def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id ) verify_thing_type_correspondence(thing, thing_type) + if is_debug_timing_enabled(): + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "thing lookup completed path=%s thing_id=%s thing_type=%s duration_ms=%s", + request.url.path, + thing_id, + thing_type, + duration_ms, + extra={ + "event": "thing_lookup_completed", + "path": request.url.path, + "thing_id": thing_id, + "thing_type": thing_type, + "duration_ms": duration_ms, + }, + ) return thing diff --git a/services/well_details_helper.py b/services/well_details_helper.py index 25f10088..7408d15a 100644 --- a/services/well_details_helper.py +++ b/services/well_details_helper.py @@ -1,3 +1,6 @@ +import logging +import time + from sqlalchemy import select from sqlalchemy.orm import Session, joinedload, selectinload @@ -14,8 +17,35 @@ ThingContactAssociation, WellScreen, ) +from services.env import get_bool_env from services.thing_helper import get_thing_of_a_thing_type_by_id +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + +def _log_payload_stage(payload_name: str, stage: str, thing_id: int, started_at: float): + if not is_debug_timing_enabled(): + return + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "%s stage=%s thing_id=%s duration_ms=%s", + payload_name, + stage, + thing_id, + duration_ms, + extra={ + "event": "well_payload_stage_timing", + "payload_name": payload_name, + "stage": stage, + "thing_id": thing_id, + "duration_ms": duration_ms, + }, + ) + def get_well_details_payload( session: Session, @@ -23,8 +53,12 @@ def get_well_details_payload( thing_id: int, recent_observation_limit: int = 100, ): + payload_started_at = time.perf_counter() + stage_started_at = time.perf_counter() well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + _log_payload_stage("well_details", "load_well", thing_id, stage_started_at) + stage_started_at = time.perf_counter() contacts = session.scalars( select(Contact) .join(ThingContactAssociation) @@ -40,7 +74,9 @@ def get_well_details_payload( ) .order_by(Contact.id) ).all() + _log_payload_stage("well_details", "load_contacts", thing_id, stage_started_at) + stage_started_at = time.perf_counter() sensors = session.scalars( select(Sensor) .join(Deployment) @@ -48,27 +84,50 @@ def get_well_details_payload( .distinct() .order_by(Sensor.id) ).all() + _log_payload_stage("well_details", "load_sensors", thing_id, stage_started_at) + stage_started_at = time.perf_counter() deployments = session.scalars( select(Deployment) .where(Deployment.thing_id == well.id) .options(selectinload(Deployment.sensor)) .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) ).all() + _log_payload_stage( + "well_details", + "load_deployments", + thing_id, + stage_started_at, + ) + stage_started_at = time.perf_counter() well_screens = session.scalars( select(WellScreen) .where(WellScreen.thing_id == well.id) .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) ).all() + _log_payload_stage( + "well_details", + "load_well_screens", + thing_id, + stage_started_at, + ) + stage_started_at = time.perf_counter() groundwater_parameter_id = ( session.query(Parameter) .filter(Parameter.parameter_name == "groundwater level") .one() .id ) + _log_payload_stage( + "well_details", + "resolve_groundwater_parameter", + thing_id, + stage_started_at, + ) + stage_started_at = time.perf_counter() recent_groundwater_level_observations = session.scalars( select(Observation) .join(Sample) @@ -82,10 +141,17 @@ def get_well_details_payload( .order_by(Observation.observation_datetime.desc(), Observation.id.desc()) .limit(recent_observation_limit) ).all() + _log_payload_stage( + "well_details", + "load_recent_groundwater_level_observations", + thing_id, + stage_started_at, + ) latest_field_event_sample = None if recent_groundwater_level_observations: latest_sample_id = recent_groundwater_level_observations[0].sample_id + stage_started_at = time.perf_counter() latest_field_event_sample = session.scalar( select(Sample) .where(Sample.id == latest_sample_id) @@ -98,6 +164,19 @@ def get_well_details_payload( ), ) ) + _log_payload_stage( + "well_details", + "load_latest_field_event_sample", + thing_id, + stage_started_at, + ) + + _log_payload_stage( + "well_details", + "payload_total", + thing_id, + payload_started_at, + ) return { "well": well, @@ -108,3 +187,64 @@ def get_well_details_payload( "recent_groundwater_level_observations": recent_groundwater_level_observations, "latest_field_event_sample": latest_field_event_sample, } + + +def get_well_export_payload( + session: Session, + request, + thing_id: int, +): + payload_started_at = time.perf_counter() + stage_started_at = time.perf_counter() + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + _log_payload_stage("well_export", "load_well", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + contacts = session.scalars( + select(Contact) + .join(ThingContactAssociation) + .where(ThingContactAssociation.thing_id == well.id) + .options( + selectinload(Contact.emails), + selectinload(Contact.phones), + selectinload(Contact.addresses), + selectinload(Contact.incomplete_nma_phones), + selectinload(Contact.thing_associations).selectinload( + ThingContactAssociation.thing + ), + ) + .order_by(Contact.id) + ).all() + _log_payload_stage("well_export", "load_contacts", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + _log_payload_stage("well_export", "load_sensors", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + deployments = session.scalars( + select(Deployment) + .where(Deployment.thing_id == well.id) + .options(selectinload(Deployment.sensor)) + .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) + ).all() + _log_payload_stage( + "well_export", + "load_deployments", + thing_id, + stage_started_at, + ) + _log_payload_stage("well_export", "payload_total", thing_id, payload_started_at) + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + } diff --git a/tests/test_asset.py b/tests/test_asset.py index 008cade9..081fe580 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import io +import logging +import os from datetime import timezone from unittest.mock import patch @@ -23,6 +26,7 @@ from core.dependencies import viewer_function, admin_function, editor_function from db import Asset from schemas import DT_FMT +from services import gcs_helper from tests import ( client, cleanup_post_test, @@ -30,12 +34,18 @@ cleanup_patch_test, ) -# CLASSES, FIXTURES, AND FUNCTIONS ============================================= +# CLASSES, FIXTURES, AND FUNCTIONS =========================================== class MockBlob: + def __init__(self): + self.upload_calls = 0 + self.last_file_position = None + def upload_from_file(self, *args, **kwargs): - pass + self.upload_calls += 1 + if args: + self.last_file_position = args[0].tell() def generate_signed_url(self, *args, **kwargs): return "https://storage.googleapis.com/mock-bucket/mock-asset" @@ -47,11 +57,15 @@ def delete(self, *args, **kwargs): class MockStorageBucket: name = "mock-bucket" + def __init__(self, existing_blob=None): + self._blob = MockBlob() + self._existing_blob = existing_blob + def blob(self, *args, **kwargs): - return MockBlob() + return self._blob def get_blob(self, *args, **kwargs): - return None + return self._existing_blob def mock_storage_bucket(): @@ -77,7 +91,7 @@ def override_dependency_fixture(): app.dependency_overrides = {} -# POST & UPLOAD tests ========================================================== +# POST & UPLOAD tests ======================================================== def test_upload_asset(): @@ -94,6 +108,86 @@ def test_upload_asset(): assert "storage_path" in data +def test_gcs_upload_logs_stage_timings(caplog): + bucket = MockStorageBucket() + upload = type( + "UploadStub", + (), + { + "filename": "field-compilation.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"pdf-bytes" * 2048), + }, + )() + + with patch.dict(os.environ, {"API_DEBUG_TIMING": "true"}): + with caplog.at_level(logging.INFO, logger="services.gcs_helper"): + uri, blob_name = gcs_helper.gcs_upload(upload, bucket) + + stage_logs = [ + record for record in caplog.records if record.msg == "gcs stage timing" + ] + + assert uri.endswith(blob_name) + assert {record.stage for record in stage_logs} >= { + "hash_file", + "lookup_blob", + "upload_blob", + "upload_request_total", + } + + +def test_gcs_upload_skips_existing_blob(): + existing_blob = object() + bucket = MockStorageBucket(existing_blob=existing_blob) + upload = type( + "UploadStub", + (), + { + "filename": "existing.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"existing-pdf"), + }, + )() + + gcs_helper.gcs_upload(upload, bucket) + + assert bucket._blob.upload_calls == 0 + + +def test_make_blob_name_and_uri_rewinds_file_after_hashing(): + upload = type( + "UploadStub", + (), + { + "filename": "rewind.pdf", + "file": io.BytesIO(b"a" * (gcs_helper.HASH_CHUNK_SIZE + 5)), + }, + )() + + blob_name, uri = gcs_helper.make_blob_name_and_uri(upload) + + assert blob_name in uri + assert upload.file.tell() == 0 + + +def test_gcs_upload_rewinds_before_upload(): + bucket = MockStorageBucket() + upload = type( + "UploadStub", + (), + { + "filename": "rewind-before-upload.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"b" * (gcs_helper.HASH_CHUNK_SIZE + 7)), + }, + )() + + gcs_helper.gcs_upload(upload, bucket) + + assert bucket._blob.last_file_position == 0 + + def test_add_asset(water_well_thing): payload = { "release_status": "draft", @@ -119,7 +213,7 @@ def test_add_asset(water_well_thing): assert data["storage_path"] == payload["storage_path"] assert data["mime_type"] == payload["mime_type"] assert data["size"] == payload["size"] - assert data["signed_url"] == None + assert data["signed_url"] is None cleanup_post_test(Asset, data["id"]) @@ -146,7 +240,7 @@ def test_add_asset_409_bad_thing_id(water_well_thing): assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} -# GET tests ==================================================================== +# GET tests ================================================================== def test_get_assets(asset, asset_with_associated_thing): @@ -166,7 +260,7 @@ def test_get_assets(asset, asset_with_associated_thing): assert data["items"][0]["size"] == asset.size assert data["items"][0]["uri"] == asset.uri assert data["items"][0]["storage_service"] == asset.storage_service - assert data["items"][0]["signed_url"] == None + assert data["items"][0]["signed_url"] is None assert data["items"][1]["id"] == asset_with_associated_thing.id assert data["items"][1][ @@ -187,11 +281,14 @@ def test_get_assets(asset, asset_with_associated_thing): data["items"][1]["storage_service"] == asset_with_associated_thing.storage_service ) - assert data["items"][1]["signed_url"] == None + assert data["items"][1]["signed_url"] is None def test_get_assets_thing_id(asset_with_associated_thing, water_well_thing): - with patch("api.asset.get_storage_bucket", return_value=MockStorageBucket()): + with patch( + "api.asset.get_storage_bucket", + return_value=MockStorageBucket(), + ): query_parameters = {"thing_id": water_well_thing.id} response = client.get("/asset", params=query_parameters) assert response.status_code == 200 @@ -231,7 +328,7 @@ def test_get_asset_by_id_404_not_found(asset): assert data["detail"] == f"Asset with ID {bad_id} not found." -# PATCH tests ================================================================== +# PATCH tests ================================================================ def test_patch_asset(asset): @@ -260,7 +357,7 @@ def test_patch_asset_404_not_found(asset): assert data["detail"] == f"Asset with ID {bad_id} not found." -# DELETE tests ================================================================= +# DELETE tests =============================================================== def test_delete_asset(second_asset): diff --git a/tests/test_request_timing.py b/tests/test_request_timing.py index ae6b255e..78e4a9f4 100644 --- a/tests/test_request_timing.py +++ b/tests/test_request_timing.py @@ -5,7 +5,7 @@ from core.app import create_base_app -def test_request_timing_logs_cold_then_warm(caplog): +def test_request_lifecycle_logs_start_and_completion(caplog): app = create_base_app() @app.get("/ping") @@ -20,24 +20,23 @@ async def ping(): startup_logs = [ record for record in caplog.records if record.msg == "instance startup complete" ] - request_logs = [ - record for record in caplog.records if record.msg == "request timing" + request_started_logs = [ + record for record in caplog.records if record.msg == "request started" + ] + request_completed_logs = [ + record for record in caplog.records if record.msg == "request completed" ] - assert len(startup_logs) == 1 - assert len(request_logs) == 2 + assert len(request_started_logs) == 2 + assert len(request_completed_logs) == 2 assert startup_logs[0].event == "instance_startup_complete" assert startup_logs[0].startup_ms >= 0 - - assert request_logs[0].request_kind == "cold" - assert request_logs[0].path == "/ping" - assert request_logs[0].status_code == 200 - assert request_logs[0].request_duration_ms >= 0 - assert request_logs[0].startup_ms >= 0 - - assert request_logs[1].request_kind == "warm" - assert request_logs[1].path == "/ping" - assert request_logs[1].status_code == 200 - assert request_logs[1].request_duration_ms >= 0 - assert request_logs[1].uptime_before_request_ms >= 0 + assert request_started_logs[0].event == "request_started" + assert request_started_logs[0].request_id + assert request_started_logs[0].path == "/ping" + assert request_completed_logs[0].event == "request_completed" + assert request_completed_logs[0].request_id == request_started_logs[0].request_id + assert request_completed_logs[0].status_code == 200 + assert request_completed_logs[1].request_id == request_started_logs[1].request_id + assert request_completed_logs[1].status_code == 200 From 8d0be60b90592c36735b5824137738bfe4b6d5dc Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 30 Mar 2026 14:41:09 -0600 Subject: [PATCH 138/163] feat: add location properties to water well response and enhance test coverage --- schemas/location.py | 6 ++++++ schemas/thing.py | 8 ++++++++ services/thing_helper.py | 4 ++++ tests/conftest.py | 3 +++ tests/test_thing.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+) diff --git a/schemas/location.py b/schemas/location.py index 59654528..50fe28dd 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -101,6 +101,9 @@ class GeoJSONProperties(BaseModel): elevation_unit: str = "ft" vertical_datum: str = "NAVD88" elevation_method: ElevationMethod | None + county: str | None = None + state: str | None = None + quad_name: str | None = None utm_coordinates: GeoJSONUTMCoordinates = Field( default_factory=GeoJSONUTMCoordinates ) @@ -154,6 +157,9 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["notes"] = data_dict.get("notes") data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") + data_dict["properties"]["county"] = data_dict.get("county") + data_dict["properties"]["state"] = data_dict.get("state") + data_dict["properties"]["quad_name"] = data_dict.get("quad_name") data_dict["properties"]["nma_location_notes"] = data_dict.get( "nma_location_notes" ) diff --git a/schemas/thing.py b/schemas/thing.py index cd3483fd..b3f19287 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -229,6 +229,13 @@ def remove_records_with_end_date(cls, monitoring_frequencies): return active_frequencies +class WellContactSummaryResponse(BaseResponseModel): + name: str | None = None + organization: str | None = None + role: str + contact_type: str + + class WellResponse(BaseThingResponse): """ Response schema for well details. @@ -262,6 +269,7 @@ class WellResponse(BaseThingResponse): aquifers: list[dict] = [] water_notes: list[NoteResponse] = [] construction_notes: list[NoteResponse] = [] + contacts: list[WellContactSummaryResponse] = [] permissions: list[PermissionHistoryResponse] formation_completion_code: FormationCode | None nma_formation_zone: str | None diff --git a/services/thing_helper.py b/services/thing_helper.py index 221cb121..14db8706 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -28,6 +28,7 @@ from db import ( LocationThingAssociation, Thing, + ThingContactAssociation, Location, WellScreen, WellPurpose, @@ -55,6 +56,9 @@ selectinload(Thing.location_associations).selectinload( LocationThingAssociation.location ), + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ), selectinload(Thing.well_purposes), selectinload(Thing.well_casing_materials), selectinload(Thing.links), diff --git a/tests/conftest.py b/tests/conftest.py index 2705e72a..5818707b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,7 +133,10 @@ def location(): loc = Location( point="POINT(-107.949533 33.809665)", elevation=2464.9, + county="Sierra", release_status="draft", + state="NM", + quad_name="Hillsboro Peak", ) session.add(loc) diff --git a/tests/test_thing.py b/tests/test_thing.py index 10f8e05f..8ec2d841 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -668,6 +668,42 @@ def test_get_water_well_details_payload_404_not_found(): assert response.json()["detail"] == "Thing with ID 999999 not found." +def test_get_water_well_by_id_includes_location_properties( + water_well_thing, +): + response = client.get(f"/thing/water-well/{water_well_thing.id}") + + assert response.status_code == 200 + data = response.json() + + assert data["current_location"]["properties"]["county"] == "Sierra" + assert data["current_location"]["properties"]["state"] == "NM" + assert data["current_location"]["properties"]["quad_name"] == "Hillsboro Peak" + + +def test_get_water_wells_includes_contact_summary( + water_well_thing, + contact, +): + response = client.get("/thing/water-well") + + assert response.status_code == 200 + data = response.json() + + well = next(item for item in data["items"] if item["id"] == water_well_thing.id) + assert well["contacts"] == [ + { + "id": contact.id, + "created_at": contact.created_at.astimezone(timezone.utc).strftime(DT_FMT), + "release_status": contact.release_status, + "name": contact.name, + "organization": contact.organization, + "contact_type": contact.contact_type, + "role": contact.role, + } + ] + + def test_get_water_well_by_id_404_not_found(water_well_thing): bad_id = 99999 response = client.get(f"/thing/water-well/{bad_id}") From 452fe0c09b01fbc99a4d1b4e000bcf275f98493f Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 14:42:23 -0600 Subject: [PATCH 139/163] test(water-level-import): ensure unrelated observations are preserved during bulk upload - Add test to validate that bulk water-level uploads do not remove existing unrelated observations - Update testing utility with `get_parameter_id` for parameter resolution - Ensure edge cases where new observations coexist with pre-existing observations function as expected --- services/water_level_csv.py | 2 +- tests/test_water_level_csv_service.py | 119 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index f56cd805..6eaa7ae4 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -445,7 +445,7 @@ def _find_existing_observation(sample: Sample, parameter_id: int) -> Observation for observation in sample.observations: if observation.parameter_id == parameter_id: return observation - return sample.observations[0] if sample.observations else None + return None def _apply_sample_values(sample: Sample, row: _ValidatedRow, sample_name: str) -> None: diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 6b487ada..3f6084c2 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -5,6 +5,7 @@ from db import FieldActivity, FieldEvent, Observation, Sample, Thing from db.measuring_point_history import MeasuringPointHistory from db.engine import session_ctx +from tests import get_parameter_id from services.water_level_csv import ( _build_sample_name, _resolve_measuring_point_height, @@ -200,6 +201,124 @@ def test_bulk_upload_water_levels_is_idempotent(water_well_thing): assert observations[0].measuring_point_height == 1.5 +def test_bulk_upload_water_levels_preserves_unrelated_existing_observations( + water_well_thing, +): + groundwater_parameter_id = get_parameter_id("groundwater level", "Field Parameter") + ph_parameter_id = get_parameter_id("pH", "Field Parameter") + + with session_ctx() as session: + well = session.merge(water_well_thing) + field_event = FieldEvent( + thing=well, + event_date=datetime(2025, 2, 15, 15, 0, tzinfo=timezone.utc), + notes="Existing field event", + ) + field_activity = FieldActivity( + field_event=field_event, + activity_type="groundwater level", + notes="Sampler: Original Sampler", + ) + sample = Sample( + field_activity=field_activity, + sample_date=datetime(2025, 2, 15, 17, 30, tzinfo=timezone.utc), + sample_name="Test Well-WL-202502151730", + sample_matrix="groundwater", + sample_method="Electric tape measurement (E-probe)", + qc_type="Normal", + notes="Existing sample", + ) + unrelated_observation = Observation( + sample=sample, + observation_datetime=datetime(2025, 2, 15, 17, 30, tzinfo=timezone.utc), + parameter_id=ph_parameter_id, + value=7.2, + unit="dimensionless", + notes="Keep me as pH", + ) + session.add_all([field_event, field_activity, sample, unrelated_observation]) + session.commit() + unrelated_observation_id = unrelated_observation.id + + csv_content = "\n".join( + [ + ",".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", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Imported groundwater level", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 0, result.payload + + with session_ctx() as session: + sample = session.scalars( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + Sample.sample_name == "Test Well-WL-202502151730", + ) + ).one() + observations = session.scalars( + select(Observation) + .where(Observation.sample_id == sample.id) + .order_by(Observation.id.asc()) + ).all() + + assert len(observations) == 2 + assert observations[0].id == unrelated_observation_id + assert observations[0].parameter_id == ph_parameter_id + assert observations[0].value == 7.2 + assert observations[0].unit == "dimensionless" + assert observations[0].notes == "Keep me as pH" + + groundwater_observations = [ + observation + for observation in observations + if observation.parameter_id == groundwater_parameter_id + ] + assert len(groundwater_observations) == 1 + assert ( + groundwater_observations[0].id + == result.payload["water_levels"][0]["observation_id"] + ) + assert groundwater_observations[0].value == 7.0 + assert groundwater_observations[0].unit == "ft" + assert groundwater_observations[0].notes == "Imported groundwater level" + + def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( water_well_thing, ): From 2ffbd273c7d0cab9dbca91d965ebe7f8ddcdd458 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 30 Mar 2026 14:49:40 -0600 Subject: [PATCH 140/163] feat: optimize logging for request and asset upload processes, and enhance debug timing functionality --- api/asset.py | 2 +- core/app.py | 9 ++------- db/engine.py | 10 ++++++++++ services/gcs_helper.py | 14 ++++++++------ services/sample_helper.py | 29 ++++++++--------------------- 5 files changed, 29 insertions(+), 35 deletions(-) diff --git a/api/asset.py b/api/asset.py index b6555ab4..456b5d3a 100644 --- a/api/asset.py +++ b/api/asset.py @@ -125,7 +125,7 @@ async def upload_asset( "asset upload request completed", extra={ "event": "asset_upload_request_completed", - "filename": file.filename, + "upload_filename": file.filename, "content_type": file.content_type, "upload_request_ms": round( (time.perf_counter() - request_started_at) * 1000, diff --git a/core/app.py b/core/app.py index 02408ff6..102256d4 100644 --- a/core/app.py +++ b/core/app.py @@ -74,9 +74,7 @@ async def log_request_lifecycle(request: Request, call_next): request_id = uuid4().hex request.state.request_id = request_id logger.info( - "request started %s %s", - request.method, - request.url.path, + "request started", extra={ "event": "request_started", "request_id": request_id, @@ -91,10 +89,7 @@ async def log_request_lifecycle(request: Request, call_next): return response finally: logger.info( - "request completed %s %s status=%s", - request.method, - request.url.path, - status_code, + "request completed", extra={ "event": "request_completed", "request_id": request_id, diff --git a/db/engine.py b/db/engine.py index c7a7721d..2d2f0d9f 100644 --- a/db/engine.py +++ b/db/engine.py @@ -37,7 +37,17 @@ logger = logging.getLogger(__name__) +def is_pool_logging_enabled() -> bool: + return bool( + get_bool_env("DB_POOL_LOGGING", False) + or get_bool_env("API_DEBUG_TIMING", False) + ) + + def _install_pool_logging(engine): + if not is_pool_logging_enabled(): + return + @event.listens_for(engine, "checkout") def log_checkout(dbapi_connection, connection_record, connection_proxy): connection_record.info["checked_out_at"] = time.perf_counter() diff --git a/services/gcs_helper.py b/services/gcs_helper.py index b524f211..da9ce606 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -79,14 +79,16 @@ def get_storage_bucket(client=None, bucket: str = None): def _log_stage(stage: str, started_at: float, **extra): if not is_debug_timing_enabled(): return + record_extra = { + "event": "gcs_stage_timing", + "stage": stage, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + } + if "filename" in extra: + record_extra["upload_filename"] = extra.pop("filename") logger.info( "gcs stage timing", - extra={ - "event": "gcs_stage_timing", - "stage": stage, - "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), - **extra, - }, + extra={**record_extra, **extra}, ) diff --git a/services/sample_helper.py b/services/sample_helper.py index e6591ca2..0f25dd9a 100644 --- a/services/sample_helper.py +++ b/services/sample_helper.py @@ -1,6 +1,3 @@ -import logging -import time - from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select @@ -21,9 +18,6 @@ ) from services.query_helper import order_sort_filter -logger = logging.getLogger(__name__) - - THING_RESPONSE_BASE = ( joinedload(Sample.field_activity) .joinedload(FieldActivity.field_event) @@ -60,6 +54,9 @@ CONTACT_RESPONSE_THING_ASSOCIATIONS = CONTACT_RESPONSE_PARTICIPANT.selectinload( Contact.thing_associations ) +CONTACT_RESPONSE_THING = CONTACT_RESPONSE_THING_ASSOCIATIONS.selectinload( + ThingContactAssociation.thing +) CONTACT_RESPONSE_LOADER_OPTIONS = ( @@ -67,7 +64,7 @@ CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.phones), CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.addresses), CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.incomplete_nma_phones), - CONTACT_RESPONSE_THING_ASSOCIATIONS.selectinload(ThingContactAssociation.thing), + CONTACT_RESPONSE_THING, CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.notes), ) @@ -103,8 +100,10 @@ def get_db_samples( return paginate(query) -def get_sample_by_id_with_relationships(session: Session, sample_id: int) -> Sample: - started_at = time.perf_counter() +def get_sample_by_id_with_relationships( + session: Session, + sample_id: int, +) -> Sample: sql = select(Sample).where(Sample.id == sample_id) sql = sql.options(*SAMPLE_LOADER_OPTIONS) sample = session.execute(sql).unique().scalar_one_or_none() @@ -113,16 +112,4 @@ def get_sample_by_id_with_relationships(session: Session, sample_id: int) -> Sam status_code=HTTP_404_NOT_FOUND, detail=f"Sample with ID {sample_id} not found.", ) - - duration_ms = round((time.perf_counter() - started_at) * 1000, 2) - logger.info( - "sample lookup completed sample_id=%s duration_ms=%s", - sample_id, - duration_ms, - extra={ - "event": "sample_lookup_completed", - "sample_id": sample_id, - "duration_ms": duration_ms, - }, - ) return sample From 14e9066267cc2e3575d3c953d3e18727b0316d76 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 30 Mar 2026 14:53:33 -0600 Subject: [PATCH 141/163] feat: update well response validation and enhance type safety for contact fields --- schemas/thing.py | 6 ++++-- tests/test_thing.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/schemas/thing.py b/schemas/thing.py index b3f19287..baa4ec6c 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -28,6 +28,8 @@ WellPumpType, FormationCode, OriginType, + Role, + ContactType, ) from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel, PastOrTodayDate from schemas.group import GroupResponse @@ -232,8 +234,8 @@ def remove_records_with_end_date(cls, monitoring_frequencies): class WellContactSummaryResponse(BaseResponseModel): name: str | None = None organization: str | None = None - role: str - contact_type: str + role: Role + contact_type: ContactType class WellResponse(BaseThingResponse): diff --git a/tests/test_thing.py b/tests/test_thing.py index 8ec2d841..2dde25fb 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -690,7 +690,10 @@ def test_get_water_wells_includes_contact_summary( assert response.status_code == 200 data = response.json() - well = next(item for item in data["items"] if item["id"] == water_well_thing.id) + well = next( + (item for item in data["items"] if item["id"] == water_well_thing.id), None + ) + assert well is not None, f"Well {water_well_thing.id} not found in response" assert well["contacts"] == [ { "id": contact.id, From 9741d41c43feac59e045b0aaa0e2e847461cd97a Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 14:59:05 -0600 Subject: [PATCH 142/163] fix(water-level-import): skip persistence when no valid rows exist --- services/water_level_csv.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 6eaa7ae4..20b69bf7 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -100,18 +100,19 @@ def bulk_upload_water_levels( valid_rows, row_errors = _validate_rows(session, csv_rows) validation_errors.extend(row_errors) - try: - parameter_id = _get_groundwater_level_parameter_id(session) - created_rows, persistence_errors = _create_records( - session, - parameter_id, - valid_rows, - ) - validation_errors.extend(persistence_errors) - session.commit() - except Exception as exc: # pragma: no cover - safety fallback - session.rollback() - validation_errors.append(str(exc)) + if valid_rows: + try: + parameter_id = _get_groundwater_level_parameter_id(session) + created_rows, persistence_errors = _create_records( + session, + parameter_id, + valid_rows, + ) + validation_errors.extend(persistence_errors) + session.commit() + except Exception as exc: # pragma: no cover - safety fallback + session.rollback() + validation_errors.append(str(exc)) summary = { "total_rows_processed": len(csv_rows), From e0d7e453187fc788f9a6c304cbb1e53ea4b1a807 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 15:07:34 -0600 Subject: [PATCH 143/163] fix(water-level-import): handle savepoint initialization failure gracefully - Move `begin_nested` savepoint call inside the try block for proper error handling. - Ensure session expiration when savepoint creation fails. - Add test coverage for savepoint creation failure scenarios. --- services/water_level_csv.py | 5 +++-- tests/test_water_level_csv_service.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 20b69bf7..7ab5ca7f 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -351,8 +351,9 @@ def _create_records( errors: list[str] = [] for row in rows: - savepoint = session.begin_nested() + savepoint = None try: + savepoint = session.begin_nested() sample_name = _build_sample_name(row) sample = _find_existing_imported_sample(session, row, sample_name) @@ -406,7 +407,7 @@ def _create_records( } ) except Exception as exc: # pragma: no cover - exercised via DB tests - if savepoint.is_active: + if savepoint is not None and savepoint.is_active: savepoint.rollback() else: session.expire_all() diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 3f6084c2..d0f8d6e1 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -8,6 +8,7 @@ from tests import get_parameter_id from services.water_level_csv import ( _build_sample_name, + _create_records, _resolve_measuring_point_height, _validate_depth_to_water_against_well, bulk_upload_water_levels, @@ -116,6 +117,30 @@ def test_build_sample_name_uses_deterministic_well_inventory_style_format(): assert _build_sample_name(row) == "AR0001-WL-202502151030" +def test_create_records_reports_savepoint_creation_failure_as_row_error(): + class BrokenSession: + def __init__(self): + self.expire_all_called = False + + def begin_nested(self): + raise RuntimeError("savepoint failed") + + def expire_all(self): + self.expire_all_called = True + + session = BrokenSession() + + created, errors = _create_records( + session, + parameter_id=1, + rows=[SimpleNamespace(row_index=7)], + ) + + assert created == [] + assert errors == ["Row 7: savepoint failed"] + assert session.expire_all_called is True + + def test_bulk_upload_water_levels_is_idempotent(water_well_thing): csv_content = "\n".join( [ From 8490550ec0f850c6743c02e4390148add51848c0 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 15:36:01 -0600 Subject: [PATCH 144/163] fix(well-transfer): defer WellTransferer external I/O until needed Avoid loading GCS-backed elevation cache and CSV-backed measuring point data during `WellTransferer` construction. This prevents unit tests that only instantiate the transferer from requiring Google Application Default Credentials, while preserving existing behavior for real transfer paths that actually need those dependencies. --- transfers/well_transfer.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index c5b5e598..458a2dfc 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -138,12 +138,24 @@ class WellTransferer(Transferer): def __init__(self, *args, **kw): super().__init__(*args, **kw) - self._cached_elevations = get_cached_elevations() + # Delay external I/O so unit tests can instantiate the transferer + # without requiring GCS credentials or source CSV files. + self._cached_elevations = None self._added_locations = {} self._aquifers = None - self._measuring_point_estimator = MeasuringPointEstimator() + self._measuring_point_estimator = None self._row_by_pointid: dict[str, pd.Series] = {} + def _get_cached_elevations(self) -> dict: + if self._cached_elevations is None: + self._cached_elevations = get_cached_elevations() + return self._cached_elevations + + def _get_measuring_point_estimator(self) -> MeasuringPointEstimator: + if self._measuring_point_estimator is None: + self._measuring_point_estimator = MeasuringPointEstimator() + return self._measuring_point_estimator + def transfer_parallel(self, num_workers: int = None) -> None: """ Transfer wells using parallel processing for improved performance. @@ -300,7 +312,7 @@ def process_batch(batch_idx: int, batch_df: pd.DataFrame) -> dict: logger.info(f"Parallel transfer complete: {n} wells, {len(all_errors)} errors") # Dump cached elevations (minimal after-processing) - dump_cached_elevations(self._cached_elevations) + dump_cached_elevations(self._get_cached_elevations()) def _get_dfs(self): """Load and clean WellData/Location dataframes.""" @@ -658,7 +670,7 @@ def _persist_location(self, session: Session, row, batch_errors: list): """Create a Location from the legacy row.""" try: location, elevation_method, location_notes = make_location( - row, self._cached_elevations + row, self._get_cached_elevations() ) session.add(location) return location, elevation_method, location_notes @@ -745,7 +757,11 @@ def _add_histories(self, session: Session, row, well: Thing) -> None: ) ) else: - mphs = self._measuring_point_estimator.estimate_measuring_point_height(row) + mphs = ( + self._get_measuring_point_estimator().estimate_measuring_point_height( + row + ) + ) added_measuring_point = False for mph, mph_desc, start_date, end_date in zip(*mphs): session.add( @@ -1007,7 +1023,7 @@ def _add_aquifers_parallel(self, session, row, well, local_aquifers, aquifers_lo ) if not existing: local_aquifers.append(aquifer) - except Exception as e: + except Exception: # Race condition - another thread created it session.rollback() aquifer = ( From 81a016c08ef866336068457d68dfa3df454ec4c7 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 21:28:54 -0600 Subject: [PATCH 145/163] fix(well-transfer): improve aquifer persistence with nested savepoints and better error handling - Use a nested transaction around parallel aquifer creation so a duplicate-key race only rolls back the aquifer insert attempt instead of the full in-flight well/location work. - Catch IntegrityError for the expected unique-constraint race, re-query the existing aquifer, and let unexpected flush failures surface normally for debugging and error handling. - Add unit coverage for the aquifer race path and update contact transfer tests to match the current helper contract. --- tests/test_well_transfer.py | 142 ++++++++++++++++++ .../test_contact_with_multiple_wells.py | 32 +++- transfers/well_transfer.py | 39 +++-- 3 files changed, 192 insertions(+), 21 deletions(-) diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py index bae3133a..07ba61bc 100644 --- a/tests/test_well_transfer.py +++ b/tests/test_well_transfer.py @@ -1,5 +1,9 @@ +import threading from types import SimpleNamespace +import pytest +from sqlalchemy.exc import IntegrityError + from schemas.thing import CreateWell from transfers import well_transfer as wt @@ -16,6 +20,53 @@ def expunge(self, obj): self.expunge_calls.append(obj) +class _FakeQuery: + def __init__(self, session): + self.session = session + + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return self.session.query_results.pop(0) + + +class _FakeSavepoint: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeAquiferSession: + def __init__(self, query_results, flush_exc=None): + self.added = [] + self.begin_nested_calls = 0 + self.rollback_calls = 0 + self.query_results = list(query_results) + self.flush_exc = flush_exc + + def add(self, obj): + self.added.append(obj) + + def begin_nested(self): + self.begin_nested_calls += 1 + return _FakeSavepoint() + + def flush(self): + if self.flush_exc is not None: + exc = self.flush_exc + self.flush_exc = None + raise exc + + def query(self, _model): + return _FakeQuery(self) + + def rollback(self): + self.rollback_calls += 1 + + def test_persist_well_excludes_monitoring_status_from_thing_kwargs( monkeypatch, ): @@ -49,3 +100,94 @@ def __init__(self, **kwargs): assert captured_kwargs["nma_pk_location"] == 34 assert batch_errors == [] assert session.expunge_calls == [] + + +def test_add_aquifers_parallel_recovers_from_integrity_error(monkeypatch): + class FakeAquiferSystem: + name = "name" + + def __init__(self, name, primary_aquifer_type, geographic_scale): + self.name = name + self.primary_aquifer_type = primary_aquifer_type + self.geographic_scale = geographic_scale + + class FakeThingAquiferAssociation: + def __init__(self, thing, aquifer_system): + self.thing = thing + self.aquifer_system = aquifer_system + + class FakeAquiferType: + def __init__(self, thing_aquifer_association, aquifer_type): + self.thing_aquifer_association = thing_aquifer_association + self.aquifer_type = aquifer_type + + def fake_map_value(value): + if value.startswith("LU_AquiferClass:"): + return "Test Aquifer" + if value.startswith("LU_AquiferType:"): + return "Confined" + raise KeyError(value) + + existing_aquifer = SimpleNamespace(name="Test Aquifer") + session = _FakeAquiferSession( + query_results=[None, existing_aquifer], + flush_exc=IntegrityError("insert", {}, Exception("duplicate key")), + ) + transferer = wt.WellTransferer() + row = SimpleNamespace(PointID="AR0001", AqClass="AQ", AquiferType="A") + well = SimpleNamespace(name="AR0001") + local_aquifers = [] + + monkeypatch.setattr(wt, "AquiferSystem", FakeAquiferSystem) + monkeypatch.setattr(wt, "ThingAquiferAssociation", FakeThingAquiferAssociation) + monkeypatch.setattr(wt, "AquiferType", FakeAquiferType) + monkeypatch.setattr(wt, "extract_aquifer_type_codes", lambda _value: ["A"]) + monkeypatch.setattr(wt.lexicon_mapper, "map_value", fake_map_value) + + transferer._add_aquifers_parallel( + session, row, well, local_aquifers, threading.Lock() + ) + + associations = [ + obj for obj in session.added if isinstance(obj, FakeThingAquiferAssociation) + ] + + assert session.begin_nested_calls == 1 + assert session.rollback_calls == 0 + assert associations[0].aquifer_system is existing_aquifer + assert local_aquifers == [existing_aquifer] + + +def test_add_aquifers_parallel_reraises_unexpected_flush_errors(monkeypatch): + class FakeAquiferSystem: + name = "name" + + def __init__(self, name, primary_aquifer_type, geographic_scale): + self.name = name + self.primary_aquifer_type = primary_aquifer_type + self.geographic_scale = geographic_scale + + def fake_map_value(value): + if value.startswith("LU_AquiferClass:"): + return "Test Aquifer" + if value.startswith("LU_AquiferType:"): + return "Confined" + raise KeyError(value) + + session = _FakeAquiferSession( + query_results=[None], + flush_exc=RuntimeError("database unavailable"), + ) + transferer = wt.WellTransferer() + row = SimpleNamespace(PointID="AR0001", AqClass="AQ", AquiferType="A") + well = SimpleNamespace(name="AR0001") + + monkeypatch.setattr(wt, "AquiferSystem", FakeAquiferSystem) + monkeypatch.setattr(wt, "extract_aquifer_type_codes", lambda _value: ["A"]) + monkeypatch.setattr(wt.lexicon_mapper, "map_value", fake_map_value) + + with pytest.raises(RuntimeError, match="database unavailable"): + transferer._add_aquifers_parallel(session, row, well, [], threading.Lock()) + + assert session.begin_nested_calls == 1 + assert session.rollback_calls == 0 diff --git a/tests/transfers/test_contact_with_multiple_wells.py b/tests/transfers/test_contact_with_multiple_wells.py index 40b4b26e..60af21b5 100644 --- a/tests/transfers/test_contact_with_multiple_wells.py +++ b/tests/transfers/test_contact_with_multiple_wells.py @@ -93,6 +93,8 @@ def test_owner_comment_absent_skips_notes(): def test_ownerkey_fallback_name_when_name_and_org_missing(water_well_thing): with session_ctx() as sess: thing = sess.get(Thing, water_well_thing.id) + contact_by_owner_type = {} + contact_by_name_org = {} row = SimpleNamespace( FirstName=None, LastName=None, @@ -118,12 +120,18 @@ def test_ownerkey_fallback_name_when_name_and_org_missing(water_well_thing): # Should not raise "Either name or organization must be provided." contact = _add_first_contact( - sess, row=row, thing=thing, organization=None, added=[] + sess, + row=row, + thing=thing, + organization=None, + added=set(), + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) sess.flush() assert contact is not None - assert contact.name == "Fallback OwnerKey Name" + assert contact.name == "Fallback OwnerKey Name-primary" assert contact.organization is None @@ -131,6 +139,8 @@ def test_ownerkey_dedupes_when_fallback_name_differs(water_well_thing): owner_key = f"OwnerKey-{uuid4()}" with session_ctx() as sess: first_thing = sess.get(Thing, water_well_thing.id) + contact_by_owner_type = {} + contact_by_name_org = {} second_thing = Thing( name=f"Second Well {uuid4()}", thing_type="water well", @@ -184,15 +194,27 @@ def test_ownerkey_dedupes_when_fallback_name_differs(water_well_thing): PhysicalZipCode=None, ) - added = [] + added = set() first_contact = _add_first_contact( - sess, row=complete_row, thing=first_thing, organization=None, added=added + sess, + row=complete_row, + thing=first_thing, + organization=None, + added=added, + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) assert first_contact is not None assert first_contact.name == "Casey Owner" second_contact = _add_first_contact( - sess, row=fallback_row, thing=second_thing, organization=None, added=added + sess, + row=fallback_row, + thing=second_thing, + organization=None, + added=added, + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) sess.flush() diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index 458a2dfc..5068acc6 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -25,7 +25,7 @@ import pandas as pd from pandas import isna, notna from pydantic import ValidationError -from sqlalchemy.exc import DatabaseError +from sqlalchemy.exc import DatabaseError, IntegrityError from sqlalchemy.orm import Session from core.enums import ( @@ -902,12 +902,9 @@ def _step_parallel_complete( # Aquifers if notna(row.AquiferType): - try: - self._add_aquifers_parallel( - session, row, well, local_aquifers, aquifers_lock - ) - except Exception as e: - logger.warning(f"Error adding aquifer for {well.name}: {e}") + self._add_aquifers_parallel( + session, row, well, local_aquifers, aquifers_lock + ) # Formation zone formation_code = row.FormationZone if hasattr(row, "FormationZone") else None @@ -1006,13 +1003,14 @@ def _add_aquifers_parallel(self, session, row, well, local_aquifers, aquifers_lo if not aquifer: try: - aquifer = AquiferSystem( - name=aquifer_name, - primary_aquifer_type=primary_type, - geographic_scale=None, - ) - session.add(aquifer) - session.flush() + with session.begin_nested(): + aquifer = AquiferSystem( + name=aquifer_name, + primary_aquifer_type=primary_type, + geographic_scale=None, + ) + session.add(aquifer) + session.flush() logger.info(f"Created aquifer: {aquifer_name}") # Update local cache under lock @@ -1023,14 +1021,23 @@ def _add_aquifers_parallel(self, session, row, well, local_aquifers, aquifers_lo ) if not existing: local_aquifers.append(aquifer) - except Exception: + except IntegrityError: # Race condition - another thread created it - session.rollback() aquifer = ( session.query(AquiferSystem) .filter(AquiferSystem.name == aquifer_name) .first() ) + if aquifer: + with aquifers_lock: + existing = next( + (a for a in local_aquifers if a.name == aquifer_name), + None, + ) + if not existing: + local_aquifers.append(aquifer) + else: + raise if aquifer: aquifer_assoc = ThingAquiferAssociation(thing=well, aquifer_system=aquifer) From 623206ed4729789de13a197d988da8e0c17ac029 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 21:41:06 -0600 Subject: [PATCH 146/163] fix(water-level-import): refine handling of alias and canonical headers in CSV processing --- services/water_level_csv.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 7ab5ca7f..6b2ba49f 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -200,8 +200,9 @@ def _read_csv( continue key = HEADER_ALIASES.get(stripped_key, stripped_key) value = v.strip() if isinstance(v, str) else v or "" - # If both alias and canonical header are present, - # preserve the first non-empty value. + # If both alias and canonical headers are present, keep the later + # non-empty value in CSV column order. An empty later value does not + # overwrite an earlier non-empty value. if key in normalized_row and normalized_row[key] and not value: continue normalized_row[key] = value From fbdc18b4044ea23bba5926a8fbb8244e55f6b1e1 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 21:52:46 -0600 Subject: [PATCH 147/163] fix(well-transfer): preload shared elevation cache before parallel workers Load cached elevations once before starting parallel well transfer workers. This avoids multiple workers loading the same cache at the same time and makes sure they all use the same shared in-memory data during the transfer. --- tests/test_well_transfer.py | 60 +++++++++++++++++++++++++++++++++++++ transfers/well_transfer.py | 4 +++ 2 files changed, 64 insertions(+) diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py index 07ba61bc..b5d2067d 100644 --- a/tests/test_well_transfer.py +++ b/tests/test_well_transfer.py @@ -1,6 +1,8 @@ import threading +from contextlib import contextmanager from types import SimpleNamespace +import pandas as pd import pytest from sqlalchemy.exc import IntegrityError @@ -191,3 +193,61 @@ def fake_map_value(value): assert session.begin_nested_calls == 1 assert session.rollback_calls == 0 + + +def test_transfer_parallel_preloads_cached_elevations_before_worker_submission( + monkeypatch, +): + class FakePreloadSession: + def query(self, _model): + return self + + def all(self): + return [] + + def expunge_all(self): + pass + + class FakeFuture: + def result(self): + return {"errors": []} + + class FakeExecutor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def submit(self, fn, idx, batch): + assert transferer._cached_elevations == {"source": "preloaded"} + return FakeFuture() + + @contextmanager + def fake_session_ctx(): + yield FakePreloadSession() + + load_calls = [] + dumped = [] + + def fake_get_cached_elevations(): + load_calls.append("load") + return {"source": "preloaded"} + + def fake_dump_cached_elevations(lut): + dumped.append(lut) + + transferer = wt.WellTransferer() + df = pd.DataFrame([{"PointID": "AR0001"}]) + + monkeypatch.setattr(wt, "session_ctx", fake_session_ctx) + monkeypatch.setattr(wt, "get_cached_elevations", fake_get_cached_elevations) + monkeypatch.setattr(wt, "dump_cached_elevations", fake_dump_cached_elevations) + monkeypatch.setattr(wt, "ThreadPoolExecutor", lambda max_workers: FakeExecutor()) + monkeypatch.setattr(wt, "as_completed", lambda futures: list(futures)) + monkeypatch.setattr(transferer, "_get_dfs", lambda: (df, df.copy())) + + transferer.transfer_parallel(num_workers=2) + + assert load_calls == ["load"] + assert dumped == [{"source": "preloaded"}] diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index 5068acc6..114b7c46 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -183,6 +183,10 @@ def transfer_parallel(self, num_workers: int = None) -> None: logger.info("No wells to transfer") return + # Pre-load shared cached elevations on the main thread so workers + # mutate a single cache instance instead of racing lazy initialization. + self._get_cached_elevations() + # Calculate batch size batch_size = max(100, n // num_workers) batches = [df.iloc[i : i + batch_size] for i in range(0, n, batch_size)] From a3ce92cdcf17b6ec6064424f0229b811658f8efc Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 22:17:43 -0600 Subject: [PATCH 148/163] Update services/water_level_csv.py Accepted codex's commit suggestion. Seems benign, mostly focused on clean up Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- services/water_level_csv.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 6b2ba49f..cf57e643 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -399,10 +399,7 @@ def _create_records( "field_activity_id": field_activity.id, "sample_id": sample.id, "observation_id": observation.id, - "measurement_date_time": ( - row.raw.get("water_level_date_time") - or row.raw.get("measurement_date_time") - ), + "measurement_date_time": row.raw.get("water_level_date_time"), "level_status": row.level_status, "data_quality": row.data_quality, } From 91aaaf418031de7e73b519d133cfbe9362ebb850 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 22:20:19 -0600 Subject: [PATCH 149/163] fix(well-transfer): preload measuring point estimator before parallel workers --- tests/test_well_transfer.py | 63 +++++++++++++++++++++++++++++++++++++ transfers/well_transfer.py | 1 + 2 files changed, 64 insertions(+) diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py index b5d2067d..05cda5b3 100644 --- a/tests/test_well_transfer.py +++ b/tests/test_well_transfer.py @@ -251,3 +251,66 @@ def fake_dump_cached_elevations(lut): assert load_calls == ["load"] assert dumped == [{"source": "preloaded"}] + + +def test_transfer_parallel_preloads_measuring_point_estimator_before_workers( + monkeypatch, +): + class FakePreloadSession: + def query(self, _model): + return self + + def all(self): + return [] + + def expunge_all(self): + pass + + class FakeFuture: + def result(self): + return {"errors": []} + + class FakeExecutor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def submit(self, fn, idx, batch): + assert transferer._measuring_point_estimator is estimator + return FakeFuture() + + @contextmanager + def fake_session_ctx(): + yield FakePreloadSession() + + dumped = [] + estimator = object() + build_calls = [] + + def fake_get_cached_elevations(): + return {} + + def fake_dump_cached_elevations(lut): + dumped.append(lut) + + def fake_estimator_ctor(): + build_calls.append("build") + return estimator + + transferer = wt.WellTransferer() + df = pd.DataFrame([{"PointID": "AR0001"}]) + + monkeypatch.setattr(wt, "session_ctx", fake_session_ctx) + monkeypatch.setattr(wt, "get_cached_elevations", fake_get_cached_elevations) + monkeypatch.setattr(wt, "dump_cached_elevations", fake_dump_cached_elevations) + monkeypatch.setattr(wt, "MeasuringPointEstimator", fake_estimator_ctor) + monkeypatch.setattr(wt, "ThreadPoolExecutor", lambda max_workers: FakeExecutor()) + monkeypatch.setattr(wt, "as_completed", lambda futures: list(futures)) + monkeypatch.setattr(transferer, "_get_dfs", lambda: (df, df.copy())) + + transferer.transfer_parallel(num_workers=2) + + assert build_calls == ["build"] + assert dumped == [{}] diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index 114b7c46..e477eb3b 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -186,6 +186,7 @@ def transfer_parallel(self, num_workers: int = None) -> None: # Pre-load shared cached elevations on the main thread so workers # mutate a single cache instance instead of racing lazy initialization. self._get_cached_elevations() + self._get_measuring_point_estimator() # Calculate batch size batch_size = max(100, n // num_workers) From a2584c98921a03bf86261a1b77c812a657b46499 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 22:32:30 -0600 Subject: [PATCH 150/163] feat(water-level-csv): warn when uploaded mp height differs from well history Report a row-level warning when a CSV mp_height differs from the well's existing measuring point height while still importing the row and using the CSV value. This makes the existing measuring point comparison visible to users instead of carrying unused state through validation. Update service and API tests to cover warning-only imports and mixed warning/error uploads. --- services/water_level_csv.py | 8 ++++ tests/test_observation.py | 18 +++++--- tests/test_water_level_csv_service.py | 63 +++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 6b2ba49f..26c38a83 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -392,6 +392,14 @@ def _create_records( session.flush() savepoint.commit() + if row.mp_height_differs_from_history: + errors.append( + "Row " + f"{row.row_index}: CSV mp_height ({row.mp_height}) differs " + "from existing measuring point height " + f"({row.existing_mp_height}); CSV value will be used" + ) + created.append( { "well_name_point_id": row.raw["well_name_point_id"], diff --git a/tests/test_observation.py b/tests/test_observation.py index d1806788..be43b808 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -180,8 +180,11 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): 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"] == [] + assert data["summary"]["validation_errors_or_warnings"] == 1 + assert data["validation_errors"] == [ + "Row 1: CSV mp_height (1.5) differs from existing measuring point height " + "(2.0); CSV value will be used" + ] row = data["water_levels"][0] assert row["well_name_point_id"] == water_well_thing.name @@ -273,9 +276,14 @@ def test_bulk_upload_groundwater_levels_api_partial_success(water_well_thing): assert response.status_code == 200 assert data["summary"]["total_rows_imported"] == 1 assert data["summary"]["total_rows_processed"] == 2 - assert data["summary"]["validation_errors_or_warnings"] == 1 - assert len(data["validation_errors"]) == 1 - assert "Bad Well" in data["validation_errors"][0] + assert data["summary"]["validation_errors_or_warnings"] == 2 + assert len(data["validation_errors"]) == 2 + assert any( + "CSV mp_height (1.5) differs from existing measuring point height (2.0)" + in message + for message in data["validation_errors"] + ) + assert any("Bad Well" in message for message in data["validation_errors"]) row = data["water_levels"][0] with session_ctx() as session: diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index d0f8d6e1..64c145bd 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -226,6 +226,55 @@ def test_bulk_upload_water_levels_is_idempotent(water_well_thing): assert observations[0].measuring_point_height == 1.5 +def test_bulk_upload_water_levels_warns_when_mp_height_differs_from_history( + water_well_thing, +): + csv_content = "\n".join( + [ + ",".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", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Measurement with warning", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 0, result.payload + assert result.payload["summary"]["total_rows_imported"] == 1 + assert result.payload["summary"]["validation_errors_or_warnings"] == 1 + assert result.payload["validation_errors"] == [ + "Row 1: CSV mp_height (1.5) differs from existing measuring point height " + "(2.0); CSV value will be used" + ] + + def test_bulk_upload_water_levels_preserves_unrelated_existing_observations( water_well_thing, ): @@ -402,9 +451,15 @@ def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( assert result.exit_code == 0 assert result.payload["summary"]["total_rows_processed"] == 2 assert result.payload["summary"]["total_rows_imported"] == 1 - assert result.payload["summary"]["validation_errors_or_warnings"] == 1 + assert result.payload["summary"]["validation_errors_or_warnings"] == 2 assert len(result.payload["water_levels"]) == 1 - assert ( - "Unknown well_name_point_id 'Unknown Well'" - in result.payload["validation_errors"][0] + assert len(result.payload["validation_errors"]) == 2 + assert any( + "CSV mp_height (1.5) differs from existing measuring point height (2.0)" + in message + for message in result.payload["validation_errors"] + ) + assert any( + "Unknown well_name_point_id 'Unknown Well'" in message + for message in result.payload["validation_errors"] ) From 256af686a7b5df92252fa463b0cbd8e1df37e39c Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 30 Mar 2026 22:38:30 -0600 Subject: [PATCH 151/163] test(well-transfer): isolate parallel preload tests from external estimator setup --- tests/test_well_transfer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py index 05cda5b3..373e70b1 100644 --- a/tests/test_well_transfer.py +++ b/tests/test_well_transfer.py @@ -243,6 +243,7 @@ def fake_dump_cached_elevations(lut): monkeypatch.setattr(wt, "session_ctx", fake_session_ctx) monkeypatch.setattr(wt, "get_cached_elevations", fake_get_cached_elevations) monkeypatch.setattr(wt, "dump_cached_elevations", fake_dump_cached_elevations) + monkeypatch.setattr(wt, "MeasuringPointEstimator", lambda: object()) monkeypatch.setattr(wt, "ThreadPoolExecutor", lambda max_workers: FakeExecutor()) monkeypatch.setattr(wt, "as_completed", lambda futures: list(futures)) monkeypatch.setattr(transferer, "_get_dfs", lambda: (df, df.copy())) From 7f83e4c83121fce58eb0d81ade65aeb780217a9b Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 1 Apr 2026 14:46:10 -0600 Subject: [PATCH 152/163] fix(importers): prevent duplicate well-name collisions during CSV imports Preserve well inventory re-import idempotency while blocking creation of a new water well when the same `Thing.name` already exists in the database. Also keep the water-level importer defensive against ambiguous well lookups so duplicate well names produce row-level validation errors instead of crashing the CLI with `MultipleResultsFound`. This reduces the risk of creating duplicate `Thing` records and makes import failures clearer for operators. --- services/water_level_csv.py | 10 ++++- services/well_inventory_csv.py | 20 ++++++++- tests/test_water_level_csv_service.py | 62 +++++++++++++++++++++++++++ tests/test_well_inventory.py | 36 ++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 895e4099..7ae651ca 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -34,6 +34,7 @@ WATER_LEVEL_IGNORED_FIELDS, ) from sqlalchemy import select +from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import Session, selectinload REQUIRED_FIELDS: List[str] = list(WATER_LEVEL_REQUIRED_FIELDS) @@ -259,7 +260,14 @@ def _validate_rows( Thing.thing_type == "water well", ) ) - well = session.scalars(sql).one_or_none() + try: + well = session.scalars(sql).one_or_none() + except MultipleResultsFound: + errors.append( + f"Row {idx}: Multiple wells found for well_name_point_id " + f"'{well_name}'" + ) + continue if well is None: errors.append(f"Row {idx}: Unknown well_name_point_id '{well_name}'") continue diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 2a3dcc2f..1289658f 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -280,7 +280,12 @@ class dialect: field = "Database error" else: error_text = str(e) - field = _extract_field_from_value_error(error_text) + if error_text.startswith( + "Well already exists in database for well_name_point_id " + ): + field = "well_name_point_id" + else: + field = _extract_field_from_value_error(error_text) logging.error( f"Error while importing row {row_number} ('{current_row_id}'): {error_text}" @@ -666,6 +671,19 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) if existing_well is not None: return existing_well.name + existing_named_well = session.scalars( + select(Thing) + .where( + Thing.name == model.well_name_point_id, + Thing.thing_type == "water well", + ) + .order_by(Thing.id.asc()) + ).first() + if existing_named_well is not None: + raise ValueError( + f"Well already exists in database for well_name_point_id '{model.well_name_point_id}'" + ) + # -------------------- # Location and associated tables # -------------------- diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py index 64c145bd..e4b01d9c 100644 --- a/tests/test_water_level_csv_service.py +++ b/tests/test_water_level_csv_service.py @@ -463,3 +463,65 @@ def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( "Unknown well_name_point_id 'Unknown Well'" in message for message in result.payload["validation_errors"] ) + + +def test_bulk_upload_water_levels_reports_duplicate_well_name_matches(): + with session_ctx() as session: + well_one = Thing(name="Duplicate Well", thing_type="water well") + well_two = Thing(name="Duplicate Well", thing_type="water well") + session.add_all([well_one, well_two]) + session.commit() + well_one_id = well_one.id + well_two_id = well_two.id + + csv_content = "\n".join( + [ + ",".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", + ] + ), + ",".join( + [ + "A Lopez", + "Duplicate Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ] + ) + + try: + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 1 + assert result.payload["summary"]["total_rows_processed"] == 1 + assert result.payload["summary"]["total_rows_imported"] == 0 + assert result.payload["validation_errors"] == [ + "Row 1: Multiple wells found for well_name_point_id 'Duplicate Well'" + ] + finally: + with session_ctx() as session: + for well_id in (well_one_id, well_two_id): + well = session.get(Thing, well_id) + if well is not None: + session.delete(well) + session.commit() diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index e3a5f45a..4e3be31b 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -833,6 +833,42 @@ def test_upload_duplicate_well_ids(self): errors = result.payload.get("validation_errors", []) assert any("Duplicate" in str(e) for e in errors) + def test_upload_fails_when_well_name_already_exists_in_database(self, tmp_path): + """Upload fails when a water well with the same Thing.name already exists.""" + row = _minimal_valid_well_inventory_row() + + with session_ctx() as session: + session.add(Thing(name=row["well_name_point_id"], thing_type="water well")) + session.commit() + + file_path = tmp_path / "well-inventory-existing-db-well.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + + assert result.exit_code == 1, result.stderr + errors = result.payload.get("validation_errors", []) + assert errors + assert errors[0]["field"] == "well_name_point_id" + assert ( + errors[0]["error"] + == "Well already exists in database for well_name_point_id 'TEST-0001'" + ) + + with session_ctx() as session: + things = ( + session.query(Thing) + .filter( + Thing.name == row["well_name_point_id"], + Thing.thing_type == "water well", + ) + .all() + ) + assert len(things) == 1 + def test_upload_blank_well_name_point_id_autogenerates(self, tmp_path): """Upload succeeds when well_name_point_id is blank and auto-generates IDs.""" source_path = Path("tests/features/data/well-inventory-valid.csv") From ae6b1538996021c5c36bc8a906e6b17f0de799fb Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 8 Apr 2026 09:17:34 -0600 Subject: [PATCH 153/163] refactor(importers): share water well lookup by Thing.name Extract the generic water-well lookup by `Thing.name` into a shared helper and reuse it in the well inventory and water-level CSV importers. Keep importer-specific behavior separate: - water-level import still distinguishes unknown vs ambiguous matches - well-inventory import still applies its own re-import detection before enforcing the duplicate-name check --- services/thing_helper.py | 23 ++++++++++++++++++++++- services/water_level_csv.py | 18 +++++++----------- services/well_inventory_csv.py | 13 +++---------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index 5b32f54c..6457396c 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -13,9 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime import logging import time +from datetime import datetime +from typing import Sequence from zoneinfo import ZoneInfo from fastapi import Request, HTTPException @@ -78,6 +79,26 @@ def is_debug_timing_enabled() -> bool: WATER_WELL_THING_TYPE = "water well" +def find_water_wells_by_name( + session: Session, + name: str, + *, + options: Sequence | None = None, +) -> list[Thing]: + sql = ( + select(Thing) + .where( + Thing.name == name, + Thing.thing_type == WATER_WELL_THING_TYPE, + ) + .order_by(Thing.id.asc()) + ) + if options: + sql = sql.options(*options) + + return session.scalars(sql).all() + + def wkb_to_geojson(wkb_element): if wkb_element is None: return None diff --git a/services/water_level_csv.py b/services/water_level_csv.py index 7ae651ca..9faa5af2 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -34,8 +34,8 @@ WATER_LEVEL_IGNORED_FIELDS, ) from sqlalchemy import select -from sqlalchemy.exc import MultipleResultsFound from sqlalchemy.orm import Session, selectinload +from services.thing_helper import find_water_wells_by_name REQUIRED_FIELDS: List[str] = list(WATER_LEVEL_REQUIRED_FIELDS) HEADER_ALIASES: dict[str, str] = dict(WATER_LEVEL_HEADER_ALIASES) @@ -252,22 +252,18 @@ def _validate_rows( well_name = model.well_name_point_id well = wells_by_name.get(well_name) if well is None: - sql = ( - select(Thing) - .options(selectinload(Thing.measuring_points)) - .where( - Thing.name == well_name, - Thing.thing_type == "water well", - ) + matches = find_water_wells_by_name( + session, + well_name, + options=(selectinload(Thing.measuring_points),), ) - try: - well = session.scalars(sql).one_or_none() - except MultipleResultsFound: + if len(matches) > 1: errors.append( f"Row {idx}: Multiple wells found for well_name_point_id " f"'{well_name}'" ) continue + well = matches[0] if matches else None if well is None: errors.append(f"Row {idx}: Unknown well_name_point_id '{well_name}'") continue diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 1289658f..18e9a4f5 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -51,7 +51,7 @@ from schemas.well_inventory import WellInventoryRow from services.contact_helper import add_contact from services.exceptions_helper import PydanticStyleException -from services.thing_helper import add_thing +from services.thing_helper import add_thing, find_water_wells_by_name from services.util import transform_srid, convert_ft_to_m AUTOGEN_DEFAULT_PREFIX = "NM-" @@ -671,15 +671,8 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) if existing_well is not None: return existing_well.name - existing_named_well = session.scalars( - select(Thing) - .where( - Thing.name == model.well_name_point_id, - Thing.thing_type == "water well", - ) - .order_by(Thing.id.asc()) - ).first() - if existing_named_well is not None: + existing_named_wells = find_water_wells_by_name(session, model.well_name_point_id) + if existing_named_wells: raise ValueError( f"Well already exists in database for well_name_point_id '{model.well_name_point_id}'" ) From 48ed5ce0e00d4621396d0c3ca878f5f18a40983e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:17:10 +0000 Subject: [PATCH 154/163] build(deps): bump cryptography from 46.0.6 to 46.0.7 (#643) Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.6 to 46.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.6...46.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.7 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 100 +++++++++++++++++++++++------------------------ uv.lock | 92 +++++++++++++++++++++---------------------- 3 files changed, 97 insertions(+), 97 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 085a7cc0..147715ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "charset-normalizer==3.4.6", "click==8.3.1", "cloud-sql-python-connector==1.20.1", - "cryptography==46.0.6", + "cryptography==46.0.7", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", diff --git a/requirements.txt b/requirements.txt index 4090f32f..383a800c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -660,56 +660,56 @@ coverage==7.13.5 \ --hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \ --hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f # via pytest-cov -cryptography==46.0.6 \ - --hash=sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70 \ - --hash=sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d \ - --hash=sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a \ - --hash=sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0 \ - --hash=sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97 \ - --hash=sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30 \ - --hash=sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759 \ - --hash=sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c \ - --hash=sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead \ - --hash=sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275 \ - --hash=sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58 \ - --hash=sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f \ - --hash=sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361 \ - --hash=sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507 \ - --hash=sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa \ - --hash=sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b \ - --hash=sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b \ - --hash=sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8 \ - --hash=sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8 \ - --hash=sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72 \ - --hash=sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175 \ - --hash=sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e \ - --hash=sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124 \ - --hash=sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a \ - --hash=sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c \ - --hash=sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f \ - --hash=sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d \ - --hash=sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4 \ - --hash=sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c \ - --hash=sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290 \ - --hash=sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca \ - --hash=sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d \ - --hash=sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a \ - --hash=sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed \ - --hash=sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a \ - --hash=sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb \ - --hash=sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8 \ - --hash=sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707 \ - --hash=sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410 \ - --hash=sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736 \ - --hash=sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2 \ - --hash=sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4 \ - --hash=sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013 \ - --hash=sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19 \ - --hash=sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b \ - --hash=sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738 \ - --hash=sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463 \ - --hash=sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77 \ - --hash=sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4 +cryptography==46.0.7 \ + --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ + --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ + --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ + --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ + --hash=sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4 \ + --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ + --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ + --hash=sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968 \ + --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ + --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ + --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ + --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ + --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ + --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ + --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ + --hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \ + --hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \ + --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ + --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ + --hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \ + --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ + --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ + --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ + --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ + --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ + --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ + --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ + --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ + --hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \ + --hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \ + --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ + --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ + --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ + --hash=sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455 \ + --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ + --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ + --hash=sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15 \ + --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ + --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ + --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ + --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ + --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ + --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ + --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ + --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ + --hash=sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f \ + --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ + --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ + --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce # via # authlib # cloud-sql-python-connector diff --git a/uv.lock b/uv.lock index 69907f75..c801b7c5 100644 --- a/uv.lock +++ b/uv.lock @@ -609,55 +609,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -1578,7 +1578,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.4.6" }, { name = "click", specifier = "==8.3.1" }, { name = "cloud-sql-python-connector", specifier = "==1.20.1" }, - { name = "cryptography", specifier = "==46.0.6" }, + { name = "cryptography", specifier = "==46.0.7" }, { name = "dnspython", specifier = "==2.8.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "email-validator", specifier = "==2.3.0" }, From b2b60e56a94ba3b699068c67638670ac53e19ab1 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 10 Apr 2026 12:19:09 -0600 Subject: [PATCH 155/163] feat(cli): add scoped transfer command for targeted imports Add a new `oco scoped-transfer` command and the supporting scoped transfer service for running legacy transfers by PointID. This adds scoped planning and execution, dependency expansion, family-level summary output, scoped well handling, scoped water-level idempotency, and contact-collision fallback reuse. It also suppresses legacy transfer warnings that are misleading in scoped CLI runs. --- cli/cli.py | 127 +++ services/scoped_transfer.py | 2003 +++++++++++++++++++++++++++++++++++ 2 files changed, 2130 insertions(+) create mode 100644 services/scoped_transfer.py diff --git a/cli/cli.py b/cli/cli.py index 50b2b24a..30c9742f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -191,6 +191,133 @@ def transfer_results( typer.echo(f"Transfer comparisons: {len(results.results)}") +@cli.command("scoped-transfer") +def scoped_transfer( + pointid: list[str] = typer.Option( + ..., + "--pointid", + help="Legacy PointID to transfer. Repeat --pointid for multiple values.", + ), + only: list[str] = typer.Option( + None, + "--only", + help="Optional transfer family to include. Repeat for multiple values.", + ), + skip: list[str] = typer.Option( + None, + "--skip", + help="Optional transfer family to skip. Repeat for multiple values.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Plan the scoped transfer without writing any records.", + ), + output_format: OutputFormat | None = typer.Option( + None, + "--output", + help="Optional output format", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + from services.scoped_transfer import ( + ScopedTransferError, + ScopedTransferOptions, + format_scoped_transfer_json, + run_scoped_transfer, + ) + + colors = _palette(theme) + normalized_pointids = [ + pid.strip().upper() for pid in pointid if pid and pid.strip() + ] + + if output_format != OutputFormat.json: + # Print a quick status line so a long scoped run does not look stuck. + verb = "Planning" if dry_run else "Starting" + phase = "planning" if dry_run else "execution" + typer.secho( + f"{verb} scoped transfer for PointIDs: {', '.join(normalized_pointids)}", + fg=colors["accent"], + bold=True, + ) + typer.secho( + f"Validating requested scope and preparing {phase}...", + fg=colors["muted"], + ) + + try: + result = run_scoped_transfer( + ScopedTransferOptions( + pointids=pointid, + only=only or [], + skip=skip or [], + dry_run=dry_run, + ) + ) + except ScopedTransferError as exc: + typer.secho(str(exc), fg=colors["issue"], bold=True, err=True) + raise typer.Exit(1) from exc + + if output_format == OutputFormat.json: + typer.echo(format_scoped_transfer_json(result)) + raise typer.Exit(result.exit_code) + + header = "[SCOPED TRANSFER] DRY RUN" if result.dry_run else "[SCOPED TRANSFER]" + header_color = colors["ok"] if result.exit_code == 0 else colors["issue"] + typer.secho(header, fg=header_color, bold=True) + typer.secho("=" * 72, fg=colors["accent"]) + typer.secho( + f"Requested PointIDs: {', '.join(result.pointids)}", + fg=colors["accent"], + ) + typer.secho( + f"Selected families: {', '.join(result.selected_families)}", + fg=colors["accent"], + ) + if result.added_prerequisites: + typer.secho( + f"Auto-added prerequisites: {', '.join(result.added_prerequisites)}", + fg=colors["muted"], + ) + typer.echo() + + typer.secho("FAMILY SUMMARY", fg=colors["accent"], bold=True) + for family_result in result.family_results: + detail_parts = [f"rows={family_result.applicable_source_rows}"] + if family_result.created is not None: + detail_parts.append(f"created={family_result.created}") + if family_result.skipped_existing is not None: + detail_parts.append(f"skipped_existing={family_result.skipped_existing}") + if family_result.added_as_prerequisite: + detail_parts.append("prerequisite") + if family_result.detail: + detail_parts.append(family_result.detail) + typer.secho( + f" {family_result.family:<28} {family_result.status:<10} {' '.join(detail_parts)}", + fg=( + colors["ok"] + if family_result.status in ("completed", "planned") + else colors["muted"] + ), + ) + + if result.validation_errors: + typer.echo() + typer.secho("VALIDATION ERRORS", fg=colors["issue"], bold=True) + for error in result.validation_errors: + typer.secho(f" - {error}", fg=colors["issue"]) + + if result.execution_error: + typer.echo() + typer.secho("EXECUTION ERROR", fg=colors["issue"], bold=True) + typer.secho(result.execution_error, fg=colors["issue"]) + + raise typer.Exit(result.exit_code) + + @cli.command("compare-duplicated-welldata") def compare_duplicated_welldata( pointid: list[str] = typer.Option( diff --git a/services/scoped_transfer.py b/services/scoped_transfer.py new file mode 100644 index 00000000..39749de2 --- /dev/null +++ b/services/scoped_transfer.py @@ -0,0 +1,2003 @@ +from __future__ import annotations + +import io +import json +import logging +import re +import warnings +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from functools import cached_property +from typing import Any, Callable + +import pandas as pd +from pandas.errors import DtypeWarning +from sqlalchemy import insert, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.datastructures import UploadFile + +from db import ( + Asset, + AssetThingAssociation, + Contact, + FieldActivity, + FieldEvent, + FieldEventParticipant, + Group, + GroupThingAssociation, + Location, + LocationThingAssociation, + NMA_Chemistry_SampleInfo, + Notes, + Observation, + PermissionHistory, + Sample, + Thing, + ThingContactAssociation, + ThingIdLink, +) +from db.engine import session_ctx +from services.asset_helper import upload_and_associate +from services.util import ( + get_county_from_point, + get_quad_name_from_point, + get_state_from_point, + retrieve_latest_polymorphic_history_table_record, +) +from transfers.asset_transfer import AssetTransferer +from transfers.associated_data import AssociatedDataTransferer +from transfers.chemistry_sampleinfo import ChemistrySampleInfoTransferer +from transfers.contact_transfer import ContactTransfer +from transfers.field_parameters_transfer import FieldParametersTransferer +from transfers.group_transfer import ProjectGroupTransferer +from transfers.hydraulicsdata import HydraulicsDataTransferer +from transfers.link_ids_transfer import ( + LinkIdsLocationDataTransferer, + LinkIdsWellDataTransferer, +) +from transfers.logger import logger +from transfers.major_chemistry import MajorChemistryTransferer +from transfers.minor_trace_chemistry_transfer import MinorTraceChemistryTransferer +from transfers.ngwmn_views import ( + NGWMNLithologyTransferer, + NGWMNWaterLevelsTransferer, + NGWMNWellConstructionTransferer, +) +from transfers.permissions_transfer import _make_permission +from transfers.radionuclides import RadionuclidesTransferer +from transfers.sensor_transfer import SensorTransferer +from transfers.soil_rock_results import SoilRockResultsTransferer +from transfers.stratigraphy_legacy import StratigraphyLegacyTransferer +from transfers.surface_water_data import SurfaceWaterDataTransferer +from transfers.surface_water_photos import SurfaceWaterPhotosTransferer +from transfers.thing_transfer import _release_status +from transfers.transferer import ChemistryTransferer, Transferer +from transfers.util import ( + filter_non_transferred_wells, + filter_by_valid_measuring_agency, + filter_to_valid_point_ids, + get_transferable_wells, + make_location, + make_location_data_provenance, + read_csv, + replace_nans, +) +from transfers.waterlevels_transfer import WaterLevelTransferer, get_contacts_info +from transfers.waterlevels_transducer_transfer import ( + WaterLevelsContinuousAcousticTransferer, + WaterLevelsContinuousPressureTransferer, +) +from transfers.waterlevelscontinuous_pressure_daily import ( + NMA_WaterLevelsContinuous_Pressure_DailyTransferer, +) +from transfers.weather_data import WeatherDataTransferer +from transfers.weather_photos import WeatherPhotosTransferer +from transfers.well_transfer import WellScreenTransferer, WellTransferer + + +class ScopedTransferError(RuntimeError): + pass + + +@dataclass(slots=True) +class ScopedTransferOptions: + pointids: list[str] + only: list[str] = field(default_factory=list) + skip: list[str] = field(default_factory=list) + dry_run: bool = False + + +@dataclass(slots=True) +class ScopedFamilyResult: + family: str + status: str + applicable_source_rows: int = 0 + created: int | None = None + skipped_existing: int | None = None + detail: str | None = None + added_as_prerequisite: bool = False + + +@dataclass(slots=True) +class ScopedTransferResult: + pointids: list[str] + selected_families: list[str] + added_prerequisites: list[str] + dry_run: bool + family_results: list[ScopedFamilyResult] + validation_errors: list[str] + execution_error: str | None = None + exit_code: int = 0 + + def to_payload(self) -> dict[str, Any]: + payload = asdict(self) + payload["family_results"] = [asdict(result) for result in self.family_results] + return payload + + +@dataclass(frozen=True, slots=True) +class FamilySpec: + name: str + planner: Callable[["ScopedTransferRuntime"], ScopedFamilyResult] + executor: Callable[["ScopedTransferRuntime"], ScopedFamilyResult] + dependencies: tuple[str, ...] = () + + +class ScopedTransferLogFilter(logging.Filter): + """Hide legacy transfer warnings that are misleading in scoped CLI mode.""" + + _suppressed_message_patterns = ( + re.compile(r"^\d+ PointIDs have duplicates; will skip\.$"), + re.compile(r"^Duplicate PointIDs: "), + re.compile(r"^Filtered out \d+ .+ without matching "), + re.compile(r"^No second contact info for PointID .+, skipping\.$"), + ) + + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + return not any( + pattern.match(message) for pattern in self._suppressed_message_patterns + ) + + +@contextmanager +def _suppress_transfer_noise(): + """Temporarily reduce reused transfer-module logging to scoped-CLI signal only.""" + + root_logger = logging.getLogger() + previous_level = root_logger.level + previous_handler_levels = [handler.level for handler in root_logger.handlers] + scoped_filter = ScopedTransferLogFilter() + try: + root_logger.setLevel(logging.WARNING) + root_logger.addFilter(scoped_filter) + for handler in root_logger.handlers: + handler.setLevel(logging.WARNING) + handler.addFilter(scoped_filter) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DtypeWarning) + yield + finally: + root_logger.setLevel(previous_level) + root_logger.removeFilter(scoped_filter) + for handler, level in zip(root_logger.handlers, previous_handler_levels): + handler.setLevel(level) + handler.removeFilter(scoped_filter) + + +def normalize_pointids(pointids: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw in pointids: + value = (raw or "").strip().upper() + if not value or value in seen: + continue + seen.add(value) + normalized.append(value) + if not normalized: + raise ScopedTransferError("At least one --pointid value is required.") + return normalized + + +def _filter_requested_pointids( + df: pd.DataFrame | None, pointids: list[str] | None, column: str = "PointID" +) -> pd.DataFrame | None: + if df is None or not pointids or column not in df.columns: + return df + normalized = df[column].astype(str).str.strip().str.upper() + return df[normalized.isin(set(pointids))].copy() + + +def _matches_pointid_prefix(value: Any, pointids: list[str]) -> bool: + if value is None or pd.isna(value): + return False + text = str(value).strip().upper() + return any(text == pointid or text.startswith(f"{pointid}") for pointid in pointids) + + +class _PointIDFilteringMixin: + def _get_dfs(self): + input_df, cleaned_df = super()._get_dfs() + cleaned_df = _filter_requested_pointids(cleaned_df, self.pointids) + return input_df, cleaned_df + + +class ScopedWellScreenTransferer(_PointIDFilteringMixin, WellScreenTransferer): + pass + + +class ScopedWellTransferer(WellTransferer): + """Well transferer variant that applies PointID scoping before duplicate checks.""" + + def _get_dfs(self): + wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) + ldf = read_csv("Location") + ldf = ldf.drop(["PointID", "SSMA_TimeStamp"], axis=1) + wdf = wdf.join(ldf.set_index("LocationId"), on="LocationId") + wdf = wdf[wdf["SiteType"] == "GW"] + wdf = wdf[wdf["Easting"].notna() & wdf["Northing"].notna()] + + input_df = wdf + wdf = replace_nans(wdf) + + cleaned_df = get_transferable_wells(wdf) + cleaned_df = filter_non_transferred_wells(cleaned_df) + # In scoped mode, duplicate warnings should only consider the requested subset. + cleaned_df = _filter_requested_pointids(cleaned_df, self.pointids) + + dupes = cleaned_df["PointID"].duplicated(keep=False) + if dupes.any(): + dup_ids = set(cleaned_df.loc[dupes, "PointID"]) + logger.critical(f"{len(dup_ids)} PointIDs have duplicates; will skip.") + logger.critical(f"Duplicate PointIDs: {dup_ids}") + cleaned_df = cleaned_df[~cleaned_df["PointID"].isin(dup_ids)] + + cleaned_df = cleaned_df.sort_values(by=["PointID"]) + return input_df, cleaned_df + + +class ScopedSensorTransferer(_PointIDFilteringMixin, SensorTransferer): + pass + + +class ScopedSurfaceWaterDataTransferer( + _PointIDFilteringMixin, SurfaceWaterDataTransferer +): + pass + + +class ScopedSurfaceWaterPhotosTransferer( + _PointIDFilteringMixin, SurfaceWaterPhotosTransferer +): + pass + + +class ScopedWeatherDataTransferer(_PointIDFilteringMixin, WeatherDataTransferer): + pass + + +class ScopedWeatherPhotosTransferer(_PointIDFilteringMixin, WeatherPhotosTransferer): + pass + + +class ScopedSoilRockResultsTransferer( + _PointIDFilteringMixin, SoilRockResultsTransferer +): + def _get_dfs(self): + input_df = self._read_csv(self.source_table) + cleaned_df = replace_nans(input_df) + cleaned_df = _filter_requested_pointids( + cleaned_df, self.pointids, column="Point_ID" + ) + return input_df, cleaned_df + + +class ScopedHydraulicsDataTransferer(_PointIDFilteringMixin, HydraulicsDataTransferer): + pass + + +class ScopedNGWMNWellConstructionTransferer( + _PointIDFilteringMixin, NGWMNWellConstructionTransferer +): + pass + + +class ScopedNGWMNWaterLevelsTransferer( + _PointIDFilteringMixin, NGWMNWaterLevelsTransferer +): + pass + + +class ScopedNGWMNLithologyTransferer(_PointIDFilteringMixin, NGWMNLithologyTransferer): + pass + + +class ScopedPressureDailyTransferer( + _PointIDFilteringMixin, NMA_WaterLevelsContinuous_Pressure_DailyTransferer +): + pass + + +class ScopedPressureTransferer( + _PointIDFilteringMixin, WaterLevelsContinuousPressureTransferer +): + pass + + +class ScopedAcousticTransferer( + _PointIDFilteringMixin, WaterLevelsContinuousAcousticTransferer +): + pass + + +class ScopedAssociatedDataTransferer(_PointIDFilteringMixin, AssociatedDataTransferer): + pass + + +class ScopedStratigraphyLegacyTransferer(StratigraphyLegacyTransferer): + pass + + +class ScopedChemistrySampleInfoTransferer(ChemistrySampleInfoTransferer): + def _build_thing_id_cache(self): + with session_ctx() as session: + query = ( + session.query( + Location.nma_pk_location, LocationThingAssociation.thing_id + ) + .join( + LocationThingAssociation, + Location.id == LocationThingAssociation.location_id, + ) + .join(Thing, Thing.id == LocationThingAssociation.thing_id) + .filter(Location.nma_pk_location.isnot(None)) + ) + if self.pointids: + query = query.filter(Thing.name.in_(self.pointids)) + + results = query.all() + location_to_thing = {} + for nma_pk_location, thing_id in results: + if nma_pk_location is None: + continue + location_to_thing[str(nma_pk_location).lower()] = thing_id + self._thing_id_cache = location_to_thing + + if not self._thing_id_cache: + logger.info("No matching Thing/Location rows found for ChemistrySampleInfo") + + +class _ScopedChemistryMixin(ChemistryTransferer): + def _build_sample_info_cache(self) -> None: + with session_ctx() as session: + query = ( + session.query( + NMA_Chemistry_SampleInfo.nma_sample_pt_id, + NMA_Chemistry_SampleInfo.id, + ) + .join(Thing, Thing.id == NMA_Chemistry_SampleInfo.thing_id) + .filter(NMA_Chemistry_SampleInfo.nma_sample_pt_id.isnot(None)) + ) + if self.pointids: + query = query.filter(Thing.name.in_(self.pointids)) + + sample_infos = query.all() + self._sample_info_cache = { + nma_sample_pt_id: csi_id for nma_sample_pt_id, csi_id in sample_infos + } + + +class ScopedFieldParametersTransferer(_ScopedChemistryMixin, FieldParametersTransferer): + pass + + +class ScopedMajorChemistryTransferer(_ScopedChemistryMixin, MajorChemistryTransferer): + pass + + +class ScopedMinorTraceChemistryTransferer( + _ScopedChemistryMixin, MinorTraceChemistryTransferer +): + pass + + +class ScopedRadionuclidesTransferer(_ScopedChemistryMixin, RadionuclidesTransferer): + pass + + +class ScopedProjectGroupTransferer(ProjectGroupTransferer): + def _step(self, session: Session, df: pd.DataFrame, i: int, row: pd.Series): + sql = select(Group).where(Group.name == row.Project) + group = session.scalars(sql).one_or_none() + if not group: + group = Group(name=row.Project) + + for prefix in row.PointIDPrefix.split(","): + prefix = prefix.strip() + if not prefix: + continue + + sql = select(Thing).where(Thing.name.like(f"{prefix}%")) + if self.pointids: + sql = sql.where(Thing.name.in_(self.pointids)) + records = session.scalars(sql).unique().all() + if not records: + continue + + existing_thing_ids = {assoc.thing_id for assoc in group.thing_associations} + group_is_monitoring_plan = False + for record in records: + if not group_is_monitoring_plan and record.status_history: + monitoring_status = [ + sh + for sh in record.status_history + if sh.status_type == "Monitoring Status" + ] + if monitoring_status: + monitoring_status = ( + retrieve_latest_polymorphic_history_table_record( + record, + "status_history", + "Monitoring Status", + ) + ) + if monitoring_status.status_value == "Currently monitored": + group_is_monitoring_plan = True + group.group_type = "Monitoring Plan" + + if record.id in existing_thing_ids: + continue + + gta = GroupThingAssociation(group=group, thing=record) + session.add(gta) + group.thing_associations.append(gta) + existing_thing_ids.add(record.id) + + session.add(group) + session.commit() + + +class ScopedAssetTransferer(AssetTransferer): + def _get_dfs(self): + input_df = read_csv(self.source_table) + cleaned_df = filter_to_valid_point_ids(input_df, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session: Session): + added_pointid = [] + for i, row in enumerate(self.cleaned_df.itertuples()): + if row.PointID in added_pointid: + continue + + added_pointid.append(row.PointID) + well = ( + session.query(Thing) + .filter(Thing.name == row.PointID, Thing.thing_type == "water well") + .one_or_none() + ) + if well is None: + self._capture_error(row.PointID, "Thing not found", "PointID") + continue + self._asset_step(session, i, well) + session.commit() + + def _asset_step(self, session, i, db_item): + df = self.cleaned_df + photos = df[df["PointID"] == db_item.name] + if photos.empty: + photos = df[df["PointID"] == db_item.name.replace("-", "")] + if photos.empty: + return + + existing_asset_names = { + name + for (name,) in session.query(Asset.name) + .join(AssetThingAssociation, AssetThingAssociation.asset_id == Asset.id) + .filter(AssetThingAssociation.thing_id == db_item.id) + .all() + if name + } + existing_asset_paths = { + storage_path + for (storage_path,) in session.query(Asset.storage_path) + .join(AssetThingAssociation, AssetThingAssociation.asset_id == Asset.id) + .filter(AssetThingAssociation.thing_id == db_item.id) + .all() + if storage_path + } + + for row in photos.itertuples(): + photo_path = row.OLEPath + srcblob = self._bucket.get_blob(f"nma-photos/{photo_path}") + if not srcblob: + self._capture_error( + db_item.name, f"No photo found for {photo_path}", "OLEPath" + ) + continue + + _, filename = srcblob.name.split("/") + if filename in existing_asset_names or any( + storage_path.endswith(filename) for storage_path in existing_asset_paths + ): + continue + + payload = srcblob.download_as_bytes() + upload = UploadFile( + file=io.BytesIO(payload), filename=filename, size=len(payload) + ) + uri = upload_and_associate( + session, + upload, + self._bucket, + db_item, + filename, + **{"label": filename, "mime_type": "image/png"}, + ) + existing_asset_names.add(filename) + if isinstance(uri, tuple) and len(uri) > 1: + existing_asset_paths.add(uri[1]) + + +class ScopedLinkIdsWellDataTransferer(LinkIdsWellDataTransferer): + def _get_dfs(self): + input_df = read_csv(self.source_table, self.source_dtypes) + wdf = replace_nans(input_df) + cleaned_df = filter_to_valid_point_ids(wdf, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session): + df = self._get_df_to_iterate() + for ci, chunk in enumerate(self._chunked_df(df)): + thing_id_by_pointid = { + name: thing_id + for name, thing_id in session.query(Thing.name, Thing.id) + .filter(Thing.name.in_(chunk.PointID.tolist())) + .all() + } + logger.info( + "Processing LinkIdsWellData chunk %s, %s rows, %s db items", + ci, + len(chunk), + len(thing_id_by_pointid), + ) + existing_link_keys = _fetch_existing_link_keys( + session, thing_id_by_pointid.values() + ) + + rows_to_insert: list[dict[str, Any]] = [] + for row in chunk.itertuples(index=False): + thing_id = thing_id_by_pointid.get(row.PointID) + if thing_id is None: + self._missing_db_item_warning(row) + continue + + if pd.isna(row.OSEWellID) and pd.isna(row.OSEWelltagID): + continue + + for aid, relation, regex in ( + (row.OSEWellID, "OSEPOD", self._ose_wellid_regex), + (row.OSEWelltagID, "OSEWellTagID", None), + ): + if pd.isna(aid): + continue + + aid_text = str(aid).strip() + if not aid_text or aid_text.casefold() in ("x", "?", "exempt"): + continue + + if regex and not regex.match(aid_text): + continue + + link_row = { + "thing_id": thing_id, + "relation": relation, + "alternate_id": aid_text, + "alternate_organization": "NMOSE", + } + link_key = _link_row_key(link_row) + if link_key in existing_link_keys: + continue + + rows_to_insert.append(link_row) + existing_link_keys.add(link_key) + + if rows_to_insert: + session.execute(insert(ThingIdLink), rows_to_insert) + session.commit() + session.expunge_all() + + def _chunked_df(self, df: pd.DataFrame): + chunk_size = getattr(self, "chunk_size", 1000) + for start in range(0, len(df), chunk_size): + yield df.iloc[start : start + chunk_size] + + +class ScopedLinkIdsLocationTransferer(LinkIdsLocationDataTransferer): + def _get_dfs(self): + input_df = read_csv( + self.source_table, + { + "SiteID": str, + "Township": str, + "TownshipDirection": str, + "Range": str, + "RangeDirection": str, + "SectionQuarters": str, + }, + ) + ldf = input_df[input_df["SiteType"] == self.site_type] + ldf = ldf[ldf["Easting"].notna() & ldf["Northing"].notna()] + ldf = replace_nans(ldf) + cleaned_df = filter_to_valid_point_ids(ldf, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session): + df = self._get_df_to_iterate() + for ci, chunk in enumerate(self._chunked_df(df)): + thing_id_by_pointid = { + name: thing_id + for name, thing_id in session.query(Thing.name, Thing.id) + .filter(Thing.name.in_(chunk.PointID.tolist())) + .all() + } + logger.info( + "Processing LinkIdsLocationData chunk %s, %s rows, %s db items", + ci, + len(chunk), + len(thing_id_by_pointid), + ) + existing_link_keys = _fetch_existing_link_keys( + session, thing_id_by_pointid.values() + ) + + rows_to_insert: list[dict[str, Any]] = [] + for row in chunk.itertuples(index=False): + thing_id = thing_id_by_pointid.get(row.PointID) + if thing_id is None: + self._missing_db_item_warning(row) + continue + + for func in ( + self._add_link_alternate_site_id, + self._add_link_site_id, + self._add_link_plss, + ): + link_row = func(row, thing_id) + if not link_row: + continue + link_key = _link_row_key(link_row) + if link_key in existing_link_keys: + continue + rows_to_insert.append(link_row) + existing_link_keys.add(link_key) + + if rows_to_insert: + session.execute(insert(ThingIdLink), rows_to_insert) + session.commit() + session.expunge_all() + + def _chunked_df(self, df: pd.DataFrame): + chunk_size = getattr(self, "chunk_size", 1000) + for start in range(0, len(df), chunk_size): + yield df.iloc[start : start + chunk_size] + + +class ScopedWaterLevelTransferer(WaterLevelTransferer): + """Scoped water-level transferer with rerun-safe contact and row handling.""" + + def _build_caches(self) -> None: + with session_ctx() as session: + thing_query = session.query(Thing.name, Thing.id) + if self.pointids: + thing_query = thing_query.filter(Thing.name.in_(self.pointids)) + self._thing_id_by_pointid = { + name: thing_id for name, thing_id in thing_query.all() + } + self._created_contact_id_by_key = { + (name, organization): contact_id + for name, organization, contact_id in session.query( + Contact.name, Contact.organization, Contact.id + ).all() + } + owner_query = ( + session.query(Thing.name, ThingContactAssociation.contact_id) + .join( + ThingContactAssociation, + Thing.id == ThingContactAssociation.thing_id, + ) + .order_by(Thing.name, ThingContactAssociation.id.asc()) + ) + if self.pointids: + owner_query = owner_query.filter(Thing.name.in_(self.pointids)) + owner_rows = owner_query.all() + owner_contact_cache: dict[str, int] = {} + for pointid, contact_id in owner_rows: + owner_contact_cache.setdefault(pointid, contact_id) + self._owner_contact_id_by_pointid = owner_contact_cache + + def _get_dfs(self) -> tuple[pd.DataFrame, pd.DataFrame]: + input_df = read_csv(self.source_table, dtype={"MeasuredBy": str}) + input_df = replace_nans(input_df) + cleaned_df = filter_to_valid_point_ids(input_df, self.pointids) + cleaned_df = filter_by_valid_measuring_agency(cleaned_df) + return input_df, cleaned_df + + def _lookup_existing_contact_ids( + self, session: Session, keys: list[tuple[str, str]] + ) -> dict[tuple[str, str], int]: + existing_contact_ids: dict[tuple[str, str], int] = {} + for name, organization in keys: + contact_id = ( + session.query(Contact.id) + .filter( + Contact.name == name, + Contact.organization == organization, + ) + .scalar() + ) + if contact_id is not None: + existing_contact_ids[(name, organization)] = contact_id + return existing_contact_ids + + def _get_field_event_participant_ids(self, session, row) -> list[int]: + self._last_contacts_created_count = 0 + self._last_contacts_reused_count = 0 + field_event_participant_ids: list[int] = [] + measured_by = None if pd.isna(row.MeasuredBy) else row.MeasuredBy + + if measured_by not in ["Owner", "Owner report", "Well owner"]: + if measured_by: + contact_info = get_contacts_info( + row, measured_by, self._measured_by_mapper + ) + contacts_to_create: list[dict[str, Any]] = [] + missing_keys: list[tuple[str, str]] = [] + for name, organization, role in contact_info: + key = (name, organization) + contact_id = self._created_contact_id_by_key.get(key) + if contact_id is not None: + field_event_participant_ids.append(contact_id) + self._last_contacts_reused_count += 1 + else: + contacts_to_create.append( + { + "name": name, + "role": role, + "contact_type": "Field Event Participant", + "organization": organization, + "nma_pk_waterlevels": row.GlobalID, + } + ) + missing_keys.append(key) + + if contacts_to_create: + try: + with session.begin_nested(): + created_contact_ids = ( + session.execute( + insert(Contact).returning(Contact.id), + contacts_to_create, + ) + .scalars() + .all() + ) + except Exception as e: + # Match the scoped reference branch behavior: if insert loses a race + # against an existing contact, reuse that contact instead of failing + # the whole water-level group. + logger.critical( + "Contact insert failed for PointID=%s, GlobalID=%s: %s", + row.PointID, + row.GlobalID, + str(e), + ) + existing_contact_ids = self._lookup_existing_contact_ids( + session, missing_keys + ) + unresolved_keys: list[tuple[str, str]] = [] + for key in missing_keys: + existing_contact_id = existing_contact_ids.get(key) + if existing_contact_id is None: + unresolved_keys.append(key) + continue + self._created_contact_id_by_key[key] = existing_contact_id + field_event_participant_ids.append(existing_contact_id) + self._last_contacts_reused_count += 1 + + if unresolved_keys: + logger.critical( + "Unable to resolve existing contact ids for PointID=%s, GlobalID=%s, keys=%s", + row.PointID, + row.GlobalID, + unresolved_keys, + ) + else: + for key, created_contact_id, payload in zip( + missing_keys, created_contact_ids, contacts_to_create + ): + self._created_contact_id_by_key[key] = created_contact_id + field_event_participant_ids.append(created_contact_id) + self._last_contacts_created_count += 1 + else: + owner_contact_id = self._owner_contact_id_by_pointid.get(row.PointID) + if owner_contact_id is None: + self._capture_error( + row.PointID, + "Thing has no contacts for owner fallback", + "MeasuredBy", + ) + else: + field_event_participant_ids.append(owner_contact_id) + self._last_contacts_reused_count += 1 + + return field_event_participant_ids + + def _transfer_hook(self, session: Session) -> None: + stats: dict[str, int] = { + "rows_skipped_existing": 0, + "field_events_created": 0, + "field_activities_created": 0, + "samples_created": 0, + "observations_created": 0, + } + + gwd = self.cleaned_df.groupby(["PointID"]) + for index, group in gwd: + pointid = index[0] + thing_id = self._thing_id_by_pointid.get(pointid) + if thing_id is None: + self._capture_error(pointid, "Thing not found", "PointID") + continue + + group_globalids = [ + str(global_id) + for global_id in group["GlobalID"].tolist() + if pd.notna(global_id) + ] + existing_globalids: set[str] = set() + if group_globalids: + existing_globalids.update( + global_id + for (global_id,) in session.query(Sample.nma_pk_waterlevels) + .filter(Sample.nma_pk_waterlevels.in_(group_globalids)) + .all() + if global_id + ) + existing_globalids.update( + global_id + for (global_id,) in session.query(Observation.nma_pk_waterlevels) + .filter(Observation.nma_pk_waterlevels.in_(group_globalids)) + .all() + if global_id + ) + + prepared_rows: list[dict[str, Any]] = [] + for row in group.itertuples(): + row_globalid = str(row.GlobalID) if pd.notna(row.GlobalID) else None + if row_globalid and row_globalid in existing_globalids: + # Scoped reruns should skip already-imported legacy water-level rows. + stats["rows_skipped_existing"] += 1 + continue + + dt_utc = self._get_dt_utc(row) + if dt_utc is None: + continue + try: + glv = self._get_groundwater_level_reason(row) + except (KeyError, ValueError) as exc: + self._capture_error( + row.PointID, + f"invalid groundwater level reason: {exc}", + "LevelStatus", + ) + continue + + release_status = "public" if row.PublicRelease else "private" + participant_ids = self._get_field_event_participant_ids(session, row) + is_destroyed = ( + glv + == "Well was destroyed (no subsequent water levels should be recorded)" + ) + prepared_rows.append( + { + "row": row, + "dt_utc": dt_utc, + "glv": glv, + "release_status": release_status, + "participant_ids": participant_ids, + "is_destroyed": is_destroyed, + } + ) + + for prep in prepared_rows: + field_event = FieldEvent( + thing_id=thing_id, + event_date=prep["dt_utc"], + release_status=prep["release_status"], + notes=prep["glv"] if prep["is_destroyed"] else None, + ) + session.add(field_event) + session.flush() + stats["field_events_created"] += 1 + + lead_participant = None + participants: list[FieldEventParticipant] = [] + for participant_idx, participant_id in enumerate( + prep["participant_ids"] + ): + participant = FieldEventParticipant( + field_event_id=field_event.id, + contact_id=participant_id, + participant_role=( + "Lead" if participant_idx == 0 else "Participant" + ), + release_status=prep["release_status"], + ) + session.add(participant) + participants.append(participant) + if participants: + session.flush() + lead_participant = participants[0] + + if prep["is_destroyed"]: + continue + + field_activity = FieldActivity( + field_event_id=field_event.id, + activity_type="groundwater level", + release_status=prep["release_status"], + ) + session.add(field_activity) + session.flush() + stats["field_activities_created"] += 1 + + sample = self._make_sample( + prep["row"], + field_activity, + prep["dt_utc"], + lead_participant, + ) + sample.release_status = prep["release_status"] + session.add(sample) + session.flush() + stats["samples_created"] += 1 + + observation = self._make_observation( + prep["row"], + sample, + prep["dt_utc"], + prep["glv"], + ) + observation.release_status = prep["release_status"] + session.add(observation) + stats["observations_created"] += 1 + + unique_notes: dict[tuple[str, Any], Any] = {} + for prep in prepared_rows: + site_notes = getattr(prep["row"], "SiteNotes", None) + if site_notes: + content = str(site_notes).strip() + if content: + dt = prep["dt_utc"] + key = (content, dt.date()) + if key not in unique_notes: + unique_notes[key] = dt + + for (content, _), dt in unique_notes.items(): + date_prefix = dt.strftime("%Y-%m-%d") + session.add( + Notes( + target_table="thing", + target_id=thing_id, + note_type="Site Notes (legacy)", + content=f"{date_prefix}: {content}", + release_status="public", + ) + ) + + try: + session.commit() + except IntegrityError: + session.rollback() + raise + + +def _fetch_existing_link_keys( + session: Session, thing_ids: list[int] | Any +) -> set[tuple[int, str, str, str]]: + thing_ids = list(thing_ids) + if not thing_ids: + return set() + return { + ( + thing_id, + relation, + alternate_id, + alternate_organization, + ) + for thing_id, relation, alternate_id, alternate_organization in session.query( + ThingIdLink.thing_id, + ThingIdLink.relation, + ThingIdLink.alternate_id, + ThingIdLink.alternate_organization, + ) + .filter(ThingIdLink.thing_id.in_(thing_ids)) + .all() + } + + +def _link_row_key(row: dict[str, Any]) -> tuple[int, str, str, str]: + return ( + int(row["thing_id"]), + str(row["relation"]), + str(row["alternate_id"]), + str(row["alternate_organization"]), + ) + + +class ScopedTransferRuntime: + def __init__(self, options: ScopedTransferOptions): + self.options = ScopedTransferOptions( + pointids=normalize_pointids(options.pointids), + only=list(options.only or []), + skip=list(options.skip or []), + dry_run=bool(options.dry_run), + ) + self._registry = build_family_registry() + + @cached_property + def selected_family_names(self) -> list[str]: + only = [name.strip() for name in self.options.only if name and name.strip()] + skip = [name.strip() for name in self.options.skip if name and name.strip()] + overlap = set(only) & set(skip) + if overlap: + names = ", ".join(sorted(overlap)) + raise ScopedTransferError( + f"Cannot use the same family in both --only and --skip: {names}" + ) + + if only: + selected = list(dict.fromkeys(only)) + else: + selected = list(DEFAULT_FAMILY_ORDER) + if skip: + selected = [name for name in selected if name not in set(skip)] + + unknown = [name for name in selected if name not in self._registry] + if unknown: + raise ScopedTransferError( + f"Unknown scoped-transfer family: {', '.join(sorted(unknown))}" + ) + + resolved: list[str] = [] + added: set[str] = set() + + def add_family(name: str): + if name in resolved: + return + for dep in self._registry[name].dependencies: + add_family(dep) + added.add(dep) + if name not in resolved: + resolved.append(name) + + for family in selected: + add_family(family) + self._added_prerequisites = sorted( + dep for dep in added if dep not in set(only or selected) + ) + return resolved + + @property + def added_prerequisites(self) -> list[str]: + _ = self.selected_family_names + return getattr(self, "_added_prerequisites", []) + + @property + def registry(self) -> dict[str, FamilySpec]: + return self._registry + + +DEFAULT_FAMILY_ORDER = [ + "wells", + "springs", + "perennial-streams", + "ephemeral-streams", + "met-stations", + "rock-sample-locations", + "diversion-of-surface-water", + "lake-pond-reservoir", + "soil-gas-sample-locations", + "other-site-types", + "outfall-wastewater-return-flow", + "screens", + "contacts", + "permissions", + "waterlevels", + "link-ids", + "groups", + "assets", + "associated-data", + "hydraulics-data", + "chemistry-sampleinfo", + "field-parameters", + "major-chemistry", + "radionuclides", + "minor-trace-chemistry", + "sensors", + "pressure", + "acoustic", + "pressure-daily", + "ngwmn-views", + "nma-stratigraphy", + "surface-water-data", + "surface-water-photos", + "weather-data", + "weather-photos", + "soil-rock-results", + "cleanup-locations", +] + + +def _run_transferer_class( + klass: type[Transferer], pointids: list[str] +) -> ScopedFamilyResult: + transferer = klass(pointids=pointids) + transferer.transfer() + applicable_rows = ( + len(transferer.cleaned_df) if transferer.cleaned_df is not None else 0 + ) + status = "completed" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _execute_wells(pointids: list[str]) -> ScopedFamilyResult: + transferer = ScopedWellTransferer(pointids=pointids) + # WellTransferer only supports the parallel entrypoint; run it with a + # single worker so the CLI stays effectively serial. + transferer.transfer_parallel(num_workers=1) + applicable_rows = ( + len(transferer.cleaned_df) if transferer.cleaned_df is not None else 0 + ) + status = "completed" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _plan_transferer_class( + klass: type[Transferer], pointids: list[str] +) -> ScopedFamilyResult: + transferer = klass(pointids=pointids) + _input_df, cleaned_df = transferer._get_dfs() + applicable_rows = len(cleaned_df) if cleaned_df is not None else 0 + status = "planned" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _plan_direct_pointid_table( + source_table: str, + pointids: list[str], + *, + site_type: str | None = None, + pointid_column: str = "PointID", +) -> ScopedFamilyResult: + df = read_csv(source_table) + if site_type and "SiteType" in df.columns: + df = df[df["SiteType"] == site_type] + filtered = _filter_requested_pointids(df, pointids, pointid_column) + count = len(filtered) if filtered is not None else 0 + return ScopedFamilyResult( + family="", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_waterlevels(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("WaterLevels", dtype={"MeasuredBy": str}) + df = replace_nans(df) + df = _filter_requested_pointids(df, pointids) + df = filter_by_valid_measuring_agency(df) + count = len(df) if df is not None else 0 + return ScopedFamilyResult( + family="waterlevels", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_sensors(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Equipment") + if " " in "".join(df.columns.tolist()): + df.columns = df.columns.str.replace(" ", "_") + df = df[df["SerialNo"].notna()] + df = _filter_requested_pointids(df, pointids) + count = len(df) if df is not None else 0 + return ScopedFamilyResult( + family="sensors", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_contacts(pointids: list[str]) -> ScopedFamilyResult: + owners_df = read_csv("OwnersData") + owner_link_df = read_csv("OwnerLink") + location_df = read_csv("Location") + owner_link_df = owner_link_df.join( + location_df.set_index("LocationId"), on="LocationId" + ) + owner_link_df = _filter_requested_pointids(owner_link_df, pointids) + if owner_link_df is None or owner_link_df.empty: + return ScopedFamilyResult( + family="contacts", + status="no-op", + applicable_source_rows=0, + ) + + owner_keys = ( + owner_link_df["OwnerKey"] + .dropna() + .astype(str) + .str.strip() + .str.casefold() + .unique() + ) + owners_df = owners_df.copy() + owner_key_column = next( + (column for column in owners_df.columns if column.lower() == "ownerkey"), + None, + ) + if owner_key_column is None: + return ScopedFamilyResult( + family="contacts", + status="no-op", + applicable_source_rows=0, + ) + normalized_owner_keys = ( + owners_df[owner_key_column].fillna("").astype(str).str.strip().str.casefold() + ) + owners_df = owners_df[normalized_owner_keys.isin(set(owner_keys))] + return ScopedFamilyResult( + family="contacts", + status="planned" if not owners_df.empty else "no-op", + applicable_source_rows=len(owners_df), + ) + + +def _plan_groups(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Projects", {"Project": str, "PointIDPrefix": str}) + if df is None or df.empty: + return ScopedFamilyResult( + family="groups", + status="no-op", + applicable_source_rows=0, + ) + + matched = 0 + for row in df.itertuples(index=False): + prefixes = [ + prefix.strip().upper() for prefix in str(row.PointIDPrefix).split(",") + ] + if any( + any(pointid.startswith(prefix) for prefix in prefixes if prefix) + for pointid in pointids + ): + matched += 1 + + return ScopedFamilyResult( + family="groups", + status="planned" if matched else "no-op", + applicable_source_rows=matched, + ) + + +def _get_sample_point_ids_for_pointids(pointids: list[str]) -> set[str]: + df = read_csv("Chemistry_SampleInfo") + if df is None or df.empty: + return set() + sample_info_df = df[ + df["SamplePointID"].map(lambda value: _matches_pointid_prefix(value, pointids)) + ] + return { + str(value).strip().upper() + for value in sample_info_df["SamplePtID"].tolist() + if value is not None and pd.notna(value) + } + + +def _plan_chemistry_sampleinfo(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Chemistry_SampleInfo") + if df is None or df.empty: + return ScopedFamilyResult( + family="chemistry-sampleinfo", + status="no-op", + applicable_source_rows=0, + ) + filtered = df[ + df["SamplePointID"].map(lambda value: _matches_pointid_prefix(value, pointids)) + ] + return ScopedFamilyResult( + family="chemistry-sampleinfo", + status="planned" if not filtered.empty else "no-op", + applicable_source_rows=len(filtered), + ) + + +def _plan_chemistry_child_table( + source_table: str, pointids: list[str] +) -> ScopedFamilyResult: + sample_pt_ids = _get_sample_point_ids_for_pointids(pointids) + if not sample_pt_ids: + return ScopedFamilyResult(family="", status="no-op", applicable_source_rows=0) + + df = read_csv(source_table) + if df is None or df.empty: + return ScopedFamilyResult(family="", status="no-op", applicable_source_rows=0) + + sample_ids = df["SamplePtID"].fillna("").astype(str).str.strip().str.upper() + filtered = df[sample_ids.isin(sample_pt_ids)] + return ScopedFamilyResult( + family="", + status="planned" if not filtered.empty else "no-op", + applicable_source_rows=len(filtered), + ) + + +def _plan_ngwmn_views(pointids: list[str]) -> ScopedFamilyResult: + total = 0 + for table_name in ( + "view_NGWMN_WaterLevels", + "view_NGWMN_WellConstruction", + "view_NGWMN_Lithology", + ): + df = read_csv(table_name) + filtered = _filter_requested_pointids(df, pointids) + total += len(filtered) if filtered is not None else 0 + return ScopedFamilyResult( + family="ngwmn-views", + status="planned" if total else "no-op", + applicable_source_rows=total, + ) + + +def _execute_permissions(pointids: list[str]) -> ScopedFamilyResult: + with session_ctx() as session: + wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) + wdf = replace_nans(wdf) + wdf = _filter_requested_pointids(wdf, pointids) + + transferred_wells = ( + session.query(Thing, Contact) + .select_from(Thing) + .join(ThingContactAssociation, ThingContactAssociation.thing_id == Thing.id) + .join(Contact, Contact.id == ThingContactAssociation.contact_id) + .filter(Thing.thing_type == "water well") + .filter(Thing.name.in_(pointids)) + .order_by(Thing.name) + .all() + ) + + existing_permissions = { + (target_id, contact_id, permission_type) + for target_id, contact_id, permission_type in session.query( + PermissionHistory.target_id, + PermissionHistory.contact_id, + PermissionHistory.permission_type, + ) + .filter(PermissionHistory.target_table == "thing") + .all() + } + + created_count = 0 + skipped_existing = 0 + for thing, contact in transferred_wells: + for field_name, permission_type in ( + ("SampleOK", "Water Chemistry Sample"), + ("MonitorOK", "Water Level Sample"), + ): + permission = _make_permission( + wdf, thing, contact.id, field_name, permission_type + ) + if permission is None: + continue + key = (thing.id, contact.id, permission.permission_type) + if key in existing_permissions: + skipped_existing += 1 + continue + session.add(permission) + existing_permissions.add(key) + created_count += 1 + + session.commit() + return ScopedFamilyResult( + family="permissions", + status="completed" if transferred_wells else "no-op", + applicable_source_rows=len(transferred_wells), + created=created_count, + skipped_existing=skipped_existing, + ) + + +_THING_SITE_TYPE_SPECS: dict[str, tuple[str, str]] = { + "springs": ("SP", "spring"), + "perennial-streams": ("PS", "perennial stream"), + "ephemeral-streams": ("ES", "ephemeral stream"), + "met-stations": ("MS", "met station"), + "rock-sample-locations": ("R", "rock sample location"), + "diversion-of-surface-water": ("D", "diversion of surface water"), + "lake-pond-reservoir": ("L", "lake, pond, reservoir"), + "soil-gas-sample-locations": ("SG", "soil gas sample location"), + "other-site-types": ("O", "other site type"), + "outfall-wastewater-return-flow": ("OW", "outfall wastewater return flow"), +} + + +def _make_thing_payload_factory(thing_type: str): + def make_payload(row): + return { + "name": row.PointID, + "thing_type": thing_type, + "release_status": _release_status(row), + } + + return make_payload + + +def _plan_non_well_family(pointids: list[str], site_type: str) -> ScopedFamilyResult: + df = read_csv("Location") + df = replace_nans(df) + df = df[df["SiteType"] == site_type] + df = df[df["Easting"].notna() & df["Northing"].notna()] + df = _filter_requested_pointids(df, pointids) + count = len(df) + return ScopedFamilyResult( + family="", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _execute_non_well_family( + family: str, pointids: list[str], site_type: str, thing_type: str +) -> ScopedFamilyResult: + df = read_csv("Location") + df = replace_nans(df) + df = df[df["SiteType"] == site_type] + df = df[df["Easting"].notna() & df["Northing"].notna()] + df = _filter_requested_pointids(df, pointids) + + if df is None or df.empty: + return ScopedFamilyResult( + family=family, + status="no-op", + applicable_source_rows=0, + ) + + duplicate_mask = df["PointID"].duplicated(keep=False) + duplicate_pointids = set(df.loc[duplicate_mask, "PointID"]) + cached_elevations: dict[str, Any] = {} + payload_factory = _make_thing_payload_factory(thing_type) + created = 0 + skipped_existing = 0 + + with session_ctx() as session: + existing_names = { + name + for (name,) in session.query(Thing.name) + .filter(Thing.name.in_(df["PointID"].tolist())) + .all() + } + + for row in df.itertuples(index=False): + if row.PointID in duplicate_pointids: + continue + if row.PointID in existing_names: + skipped_existing += 1 + continue + + location, elevation_method, location_notes = make_location( + row, cached_elevations + ) + session.add(location) + session.flush() + payload = payload_factory(row) + thing = Thing( + name=payload["name"], + thing_type=payload["thing_type"], + release_status=payload["release_status"], + nma_pk_location=row.LocationId, + ) + session.add(thing) + session.flush() + session.add( + LocationThingAssociation(location_id=location.id, thing_id=thing.id) + ) + for note_type, note_content in location_notes.items(): + if pd.notna(note_content): + session.add( + Notes( + target_id=location.id, + target_table="location", + note_type=note_type, + content=note_content, + release_status="draft", + ) + ) + location_stub = type("LocationStub", (), {"id": location.id})() + for provenance in make_location_data_provenance( + row, location_stub, elevation_method + ): + session.add(provenance) + created += 1 + existing_names.add(row.PointID) + + session.commit() + + return ScopedFamilyResult( + family=family, + status="completed" if created or skipped_existing else "no-op", + applicable_source_rows=len(df), + created=created, + skipped_existing=skipped_existing, + ) + + +def _execute_cleanup_locations(pointids: list[str]) -> ScopedFamilyResult: + with session_ctx() as session: + locations = ( + session.query(Location) + .join( + LocationThingAssociation, + LocationThingAssociation.location_id == Location.id, + ) + .join(Thing, Thing.id == LocationThingAssociation.thing_id) + .filter(Thing.name.in_(pointids)) + .all() + ) + + updates = [] + for location in locations: + y, x = location.latlon + updates.append( + { + "id": location.id, + "state": location.state or get_state_from_point(x, y), + "county": location.county or get_county_from_point(x, y), + "quad_name": location.quad_name or get_quad_name_from_point(x, y), + } + ) + if updates: + session.bulk_update_mappings(Location, updates) + session.commit() + + return ScopedFamilyResult( + family="cleanup-locations", + status="completed" if updates else "no-op", + applicable_source_rows=len(locations), + created=len(updates), + ) + + +def build_family_registry() -> dict[str, FamilySpec]: + registry: dict[str, FamilySpec] = { + "wells": FamilySpec( + "wells", + planner=lambda rt: _plan_transferer_class( + ScopedWellTransferer, rt.options.pointids + ), + executor=lambda rt: _execute_wells(rt.options.pointids), + ), + "screens": FamilySpec( + "screens", + planner=lambda rt: _plan_transferer_class( + ScopedWellScreenTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWellScreenTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "contacts": FamilySpec( + "contacts", + planner=lambda rt: _plan_contacts(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ContactTransfer, rt.options.pointids + ), + dependencies=("wells",), + ), + "permissions": FamilySpec( + "permissions", + planner=lambda rt: _plan_direct_pointid_table( + "WellData", rt.options.pointids + ), + executor=lambda rt: _execute_permissions(rt.options.pointids), + dependencies=("wells", "contacts"), + ), + "waterlevels": FamilySpec( + "waterlevels", + planner=lambda rt: _plan_waterlevels(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedWaterLevelTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "pressure": FamilySpec( + "pressure", + planner=lambda rt: _plan_transferer_class( + ScopedPressureTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedPressureTransferer, rt.options.pointids + ), + dependencies=("wells", "sensors"), + ), + "acoustic": FamilySpec( + "acoustic", + planner=lambda rt: _plan_transferer_class( + ScopedAcousticTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAcousticTransferer, rt.options.pointids + ), + dependencies=("wells", "sensors"), + ), + "pressure-daily": FamilySpec( + "pressure-daily", + planner=lambda rt: _plan_transferer_class( + ScopedPressureDailyTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedPressureDailyTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "sensors": FamilySpec( + "sensors", + planner=lambda rt: _plan_sensors(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedSensorTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "groups": FamilySpec( + "groups", + planner=lambda rt: _plan_groups(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedProjectGroupTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "assets": FamilySpec( + "assets", + planner=lambda rt: _plan_direct_pointid_table( + "WellPhotos", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAssetTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "associated-data": FamilySpec( + "associated-data", + planner=lambda rt: _plan_transferer_class( + ScopedAssociatedDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAssociatedDataTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "hydraulics-data": FamilySpec( + "hydraulics-data", + planner=lambda rt: _plan_transferer_class( + ScopedHydraulicsDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedHydraulicsDataTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "chemistry-sampleinfo": FamilySpec( + "chemistry-sampleinfo", + planner=lambda rt: _plan_chemistry_sampleinfo(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedChemistrySampleInfoTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "field-parameters": FamilySpec( + "field-parameters", + planner=lambda rt: _plan_chemistry_child_table( + "FieldParameters", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedFieldParametersTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "major-chemistry": FamilySpec( + "major-chemistry", + planner=lambda rt: _plan_chemistry_child_table( + "MajorChemistry", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedMajorChemistryTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "minor-trace-chemistry": FamilySpec( + "minor-trace-chemistry", + planner=lambda rt: _plan_chemistry_child_table( + "MinorandTraceChemistry", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedMinorTraceChemistryTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "radionuclides": FamilySpec( + "radionuclides", + planner=lambda rt: _plan_chemistry_child_table( + "Radionuclides", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedRadionuclidesTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "ngwmn-views": FamilySpec( + "ngwmn-views", + planner=lambda rt: _plan_ngwmn_views(rt.options.pointids), + executor=lambda rt: _execute_ngwmn_views(rt.options.pointids), + dependencies=("wells",), + ), + "nma-stratigraphy": FamilySpec( + "nma-stratigraphy", + planner=lambda rt: _plan_transferer_class( + ScopedStratigraphyLegacyTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedStratigraphyLegacyTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "surface-water-data": FamilySpec( + "surface-water-data", + planner=lambda rt: _plan_transferer_class( + ScopedSurfaceWaterDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedSurfaceWaterDataTransferer, rt.options.pointids + ), + ), + "surface-water-photos": FamilySpec( + "surface-water-photos", + planner=lambda rt: _plan_transferer_class( + ScopedSurfaceWaterPhotosTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedSurfaceWaterPhotosTransferer, rt.options.pointids + ), + ), + "weather-data": FamilySpec( + "weather-data", + planner=lambda rt: _plan_transferer_class( + ScopedWeatherDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWeatherDataTransferer, rt.options.pointids + ), + ), + "weather-photos": FamilySpec( + "weather-photos", + planner=lambda rt: _plan_transferer_class( + ScopedWeatherPhotosTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWeatherPhotosTransferer, rt.options.pointids + ), + ), + "soil-rock-results": FamilySpec( + "soil-rock-results", + planner=lambda rt: _plan_direct_pointid_table( + "Soil_Rock_Results", + rt.options.pointids, + pointid_column="Point_ID", + ), + executor=lambda rt: _run_transferer_class( + ScopedSoilRockResultsTransferer, rt.options.pointids + ), + ), + "cleanup-locations": FamilySpec( + "cleanup-locations", + planner=lambda rt: ScopedFamilyResult( + family="cleanup-locations", + status="planned", + applicable_source_rows=len(rt.options.pointids), + ), + executor=lambda rt: _execute_cleanup_locations(rt.options.pointids), + ), + "link-ids": FamilySpec( + "link-ids", + planner=lambda rt: _plan_direct_pointid_table( + "WellData", rt.options.pointids + ), + executor=lambda rt: _execute_link_ids(rt.options.pointids), + dependencies=("wells",), + ), + } + + for family_name, (site_type, thing_type) in _THING_SITE_TYPE_SPECS.items(): + registry[family_name] = FamilySpec( + family_name, + planner=lambda rt, st=site_type: _plan_non_well_family( + rt.options.pointids, st + ), + executor=lambda rt, fn=family_name, st=site_type, tt=thing_type: _execute_non_well_family( + fn, rt.options.pointids, st, tt + ), + ) + + return registry + + +def _execute_link_ids(pointids: list[str]) -> ScopedFamilyResult: + _run_transferer_class(ScopedLinkIdsWellDataTransferer, pointids) + _run_transferer_class(ScopedLinkIdsLocationTransferer, pointids) + well_df = _filter_requested_pointids( + replace_nans(read_csv("WellData", {"OSEWellID": str, "OSEWelltagID": str})), + pointids, + ) + location_df = _filter_requested_pointids( + replace_nans( + read_csv( + "Location", + { + "SiteID": str, + "Township": str, + "TownshipDirection": str, + "Range": str, + "RangeDirection": str, + "SectionQuarters": str, + }, + ) + ), + pointids, + ) + count = len(well_df) + len(location_df) + return ScopedFamilyResult( + family="link-ids", + status="completed" if count else "no-op", + applicable_source_rows=count, + ) + + +def _execute_ngwmn_views(pointids: list[str]) -> ScopedFamilyResult: + results = [ + _run_transferer_class(ScopedNGWMNWellConstructionTransferer, pointids), + _run_transferer_class(ScopedNGWMNWaterLevelsTransferer, pointids), + _run_transferer_class(ScopedNGWMNLithologyTransferer, pointids), + ] + count = sum(result.applicable_source_rows for result in results) + return ScopedFamilyResult( + family="ngwmn-views", + status="completed" if count else "no-op", + applicable_source_rows=count, + ) + + +def run_scoped_transfer(options: ScopedTransferOptions) -> ScopedTransferResult: + runtime = ScopedTransferRuntime(options) + + with _suppress_transfer_noise(): + plan_results: list[ScopedFamilyResult] = [] + matched_pointids: set[str] = set() + for family_name in runtime.selected_family_names: + spec = runtime.registry[family_name] + result = spec.planner(runtime) + result.family = family_name + result.added_as_prerequisite = family_name in runtime.added_prerequisites + plan_results.append(result) + + if result.applicable_source_rows: + if family_name in _THING_SITE_TYPE_SPECS: + site_type, _thing_type = _THING_SITE_TYPE_SPECS[family_name] + location_df = read_csv("Location") + location_df = replace_nans(location_df) + location_df = location_df[location_df["SiteType"] == site_type] + location_df = _filter_requested_pointids( + location_df, runtime.options.pointids + ) + matched_pointids.update( + location_df["PointID"] + .astype(str) + .str.strip() + .str.upper() + .tolist() + ) + elif family_name == "wells": + well_df = read_csv("WellData", dtype={"OSEWelltagID": str}) + well_df = replace_nans(well_df) + well_df = _filter_requested_pointids( + well_df, runtime.options.pointids + ) + matched_pointids.update( + well_df["PointID"].astype(str).str.strip().str.upper().tolist() + ) + else: + matched_pointids.update(runtime.options.pointids) + + missing = sorted(set(runtime.options.pointids) - matched_pointids) + if missing: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=runtime.options.dry_run, + family_results=plan_results, + validation_errors=[ + "Requested PointIDs not found in applicable source data: " + + ", ".join(missing) + ], + exit_code=1, + ) + + if runtime.options.dry_run: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=True, + family_results=plan_results, + validation_errors=[], + exit_code=0, + ) + + executed_results: list[ScopedFamilyResult] = [] + try: + for family_name in runtime.selected_family_names: + spec = runtime.registry[family_name] + result = spec.executor(runtime) + result.family = family_name + result.added_as_prerequisite = ( + family_name in runtime.added_prerequisites + ) + executed_results.append(result) + except Exception as exc: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=False, + family_results=executed_results, + validation_errors=[], + execution_error=str(exc), + exit_code=1, + ) + + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=False, + family_results=executed_results, + validation_errors=[], + exit_code=0, + ) + + +def format_scoped_transfer_json(result: ScopedTransferResult) -> str: + return json.dumps(result.to_payload(), indent=2, default=str) From f22a5603d06cac6362a8907fa9fa780948e05e6d Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 10 Apr 2026 12:24:01 -0600 Subject: [PATCH 156/163] test(cli): add comprehensive unit tests and regression coverage for scoped transfer CLI and services Add focused tests for scoped transfer CLI output and planning behavior. This covers water-level contact collision reuse, scoped duplicate filtering and scoped log suppression for the targeted transfer workflow. --- tests/test_scoped_transfer_cli.py | 414 ++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 tests/test_scoped_transfer_cli.py diff --git a/tests/test_scoped_transfer_cli.py b/tests/test_scoped_transfer_cli.py new file mode 100644 index 00000000..7011b96e --- /dev/null +++ b/tests/test_scoped_transfer_cli.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace + +from sqlalchemy.exc import IntegrityError +from typer.testing import CliRunner + +from cli.cli import cli +import services.scoped_transfer as scoped_transfer_module +from services.scoped_transfer import ( + FamilySpec, + ScopedFamilyResult, + ScopedTransferOptions, + ScopedTransferResult, + ScopedTransferRuntime, + ScopedTransferLogFilter, + ScopedWaterLevelTransferer, + ScopedWellTransferer, + _plan_chemistry_child_table, + _plan_chemistry_sampleinfo, + _plan_groups, + normalize_pointids, + run_scoped_transfer, +) + + +class _FakeSavepoint: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeScalarQuery: + def __init__(self, session): + self.session = session + + def filter(self, *_args, **_kwargs): + return self + + def scalar(self): + return self.session.scalar_results.pop(0) + + +class _FakeParticipantSession: + def __init__(self, scalar_results): + self.begin_nested_calls = 0 + self.scalar_results = list(scalar_results) + self.execute_calls = [] + + def begin_nested(self): + self.begin_nested_calls += 1 + return _FakeSavepoint() + + def execute(self, statement, params): + self.execute_calls.append((statement, params)) + raise IntegrityError("insert", {}, Exception("duplicate key")) + + def query(self, *_args, **_kwargs): + return _FakeScalarQuery(self) + + +def test_scoped_transfer_cli_json_output(monkeypatch): + def fake_run(_options): + return ScopedTransferResult( + pointids=["SM-0001"], + selected_families=["wells"], + added_prerequisites=[], + dry_run=True, + family_results=[ + ScopedFamilyResult( + family="wells", + status="planned", + applicable_source_rows=1, + ) + ], + validation_errors=[], + exit_code=0, + ) + + monkeypatch.setattr("services.scoped_transfer.run_scoped_transfer", fake_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "scoped-transfer", + "--pointid", + "SM-0001", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + assert '"pointids": [' in result.output + assert '"selected_families": [' in result.output + + +def test_scoped_transfer_cli_human_output(monkeypatch): + def fake_run(_options): + return ScopedTransferResult( + pointids=["SM-0001"], + selected_families=["wells", "contacts"], + added_prerequisites=["contacts"], + dry_run=False, + family_results=[ + ScopedFamilyResult( + family="wells", + status="completed", + applicable_source_rows=1, + created=1, + ), + ScopedFamilyResult( + family="contacts", + status="completed", + applicable_source_rows=1, + created=1, + added_as_prerequisite=True, + ), + ], + validation_errors=[], + exit_code=0, + ) + + monkeypatch.setattr("services.scoped_transfer.run_scoped_transfer", fake_run) + + runner = CliRunner() + result = runner.invoke(cli, ["scoped-transfer", "--pointid", "SM-0001"]) + + assert result.exit_code == 0, result.output + assert "Starting scoped transfer for PointIDs: SM-0001" in result.output + assert "Validating requested scope and preparing execution..." in result.output + assert "[SCOPED TRANSFER]" in result.output + assert "Requested PointIDs: SM-0001" in result.output + assert "Auto-added prerequisites: contacts" in result.output + assert "wells" in result.output + assert "contacts" in result.output + + +def test_normalize_pointids_dedupes_and_uppercases(): + assert normalize_pointids([" sm-0001 ", "SM-0001", "sp-1"]) == [ + "SM-0001", + "SP-1", + ] + + +def test_scoped_transfer_runtime_expands_dependencies(): + runtime = ScopedTransferRuntime( + ScopedTransferOptions(pointids=["SM-0001"], only=["field-parameters"]) + ) + + assert runtime.selected_family_names == [ + "wells", + "chemistry-sampleinfo", + "field-parameters", + ] + assert runtime.added_prerequisites == ["chemistry-sampleinfo", "wells"] + + +def test_run_scoped_transfer_fails_preflight_when_pointid_missing(monkeypatch): + def fake_registry(): + return { + "wells": FamilySpec( + name="wells", + planner=lambda _runtime: ScopedFamilyResult( + family="wells", + status="no-op", + applicable_source_rows=0, + ), + executor=lambda _runtime: ScopedFamilyResult( + family="wells", + status="no-op", + applicable_source_rows=0, + ), + ) + } + + monkeypatch.setattr("services.scoped_transfer.build_family_registry", fake_registry) + + result = run_scoped_transfer( + ScopedTransferOptions(pointids=["DOES-NOT-EXIST"], only=["wells"], dry_run=True) + ) + + assert result.exit_code == 1 + assert result.validation_errors + assert "DOES-NOT-EXIST" in result.validation_errors[0] + + +def test_run_scoped_transfer_dry_run_returns_planned_results(monkeypatch): + def fake_registry(): + return { + "wells": FamilySpec( + name="wells", + planner=lambda _runtime: ScopedFamilyResult( + family="wells", + status="planned", + applicable_source_rows=1, + ), + executor=lambda _runtime: ScopedFamilyResult( + family="wells", + status="completed", + applicable_source_rows=1, + ), + ) + } + + monkeypatch.setattr("services.scoped_transfer.build_family_registry", fake_registry) + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda *args, **kwargs: __import__("pandas").DataFrame( + {"PointID": ["SM-0001"]} + ), + ) + monkeypatch.setattr( + "services.scoped_transfer.replace_nans", + lambda df: df, + ) + + result = run_scoped_transfer( + ScopedTransferOptions(pointids=["SM-0001"], only=["wells"], dry_run=True) + ) + + assert result.exit_code == 0 + assert result.dry_run is True + assert len(result.family_results) == 1 + assert result.family_results[0].status == "planned" + + +def test_plan_chemistry_sampleinfo_uses_samplepointid_prefix(monkeypatch): + import pandas as pd + + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda name, *args, **kwargs: ( + pd.DataFrame( + { + "SamplePointID": ["SM-0001A", "SM-0001B", "SM-9999A"], + "SamplePtID": ["a", "b", "c"], + } + ) + if name == "Chemistry_SampleInfo" + else pd.DataFrame() + ), + ) + + result = _plan_chemistry_sampleinfo(["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 2 + + +def test_plan_chemistry_child_table_uses_sample_pt_ids_from_sampleinfo(monkeypatch): + import pandas as pd + + def fake_read_csv(name, *args, **kwargs): + if name == "Chemistry_SampleInfo": + return pd.DataFrame( + { + "SamplePointID": ["SM-0001A", "SM-0001B", "ZZ-0001A"], + "SamplePtID": ["A", "B", "Z"], + } + ) + if name == "MajorChemistry": + return pd.DataFrame( + { + "SamplePtID": ["A", "A", "B", "Z"], + } + ) + return pd.DataFrame() + + monkeypatch.setattr("services.scoped_transfer.read_csv", fake_read_csv) + + result = _plan_chemistry_child_table("MajorChemistry", ["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 3 + + +def test_plan_groups_counts_matching_prefixes_only(monkeypatch): + import pandas as pd + + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda *args, **kwargs: pd.DataFrame( + { + "Project": ["Sacramento", "Questa", "Other"], + "PointIDPrefix": ["SM, SO", "QU", "AB"], + } + ), + ) + + result = _plan_groups(["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 1 + + +def test_scoped_waterlevels_reuses_existing_contacts_after_insert_collision( + monkeypatch, +): + monkeypatch.setattr( + scoped_transfer_module, + "get_contacts_info", + lambda row, measured_by, mapper: [ + ("Alice Example", "NMBGMR", "Technician"), + ], + ) + + transferer = ScopedWaterLevelTransferer.__new__(ScopedWaterLevelTransferer) + transferer._created_contact_id_by_key = {} + transferer._owner_contact_id_by_pointid = {} + transferer._measured_by_mapper = {} + transferer._last_contacts_created_count = 0 + transferer._last_contacts_reused_count = 0 + + session = _FakeParticipantSession(scalar_results=[42]) + row = SimpleNamespace( + PointID="SM-0001", + GlobalID="gid-1", + MeasuredBy="NMBGMR_TECH", + ) + + participant_ids = transferer._get_field_event_participant_ids(session, row) + + assert participant_ids == [42] + assert session.begin_nested_calls == 1 + assert len(session.execute_calls) == 1 + assert transferer._created_contact_id_by_key == { + ("Alice Example", "NMBGMR"): 42, + } + assert transferer._last_contacts_created_count == 0 + assert transferer._last_contacts_reused_count == 1 + + +def test_scoped_wells_duplicate_check_only_applies_to_requested_pointids(monkeypatch): + import pandas as pd + + well_df = pd.DataFrame( + { + "PointID": ["SM-0001", "QU-047", "QU-047", "DA-0047", "DA-0047"], + "LocationId": [1, 2, 3, 4, 5], + "SiteType": ["GW"] * 5, + "Easting": [1] * 5, + "Northing": [1] * 5, + "OSEWelltagID": [None] * 5, + } + ) + location_df = pd.DataFrame( + { + "LocationId": [1, 2, 3, 4, 5], + "PointID": ["SM-0001", "QU-047", "QU-047", "DA-0047", "DA-0047"], + "SSMA_TimeStamp": [None] * 5, + } + ) + + def fake_read_csv(name, *args, **kwargs): + if name == "WellData": + return well_df.copy() + if name == "Location": + return location_df.copy() + raise AssertionError(f"Unexpected table {name}") + + monkeypatch.setattr(scoped_transfer_module, "read_csv", fake_read_csv) + monkeypatch.setattr(scoped_transfer_module, "replace_nans", lambda df: df) + monkeypatch.setattr( + scoped_transfer_module, + "get_transferable_wells", + lambda df: df, + ) + monkeypatch.setattr( + scoped_transfer_module, + "filter_non_transferred_wells", + lambda df: df, + ) + + transferer = ScopedWellTransferer.__new__(ScopedWellTransferer) + transferer.pointids = ["SM-0001"] + + _input_df, cleaned_df = transferer._get_dfs() + + assert cleaned_df["PointID"].tolist() == ["SM-0001"] + + +def test_scoped_transfer_log_filter_suppresses_known_noise_patterns(): + log_filter = ScopedTransferLogFilter() + + suppressed = logging.LogRecord( + name="test", + level=logging.WARNING, + pathname=__file__, + lineno=1, + msg=( + "Filtered out 288 HydraulicsData records without matching Things " + "(0 valid, 288 orphan records prevented)" + ), + args=(), + exc_info=None, + ) + allowed = logging.LogRecord( + name="test", + level=logging.WARNING, + pathname=__file__, + lineno=1, + msg="Actual scoped warning that should remain visible", + args=(), + exc_info=None, + ) + + assert log_filter.filter(suppressed) is False + assert log_filter.filter(allowed) is True From f7a1a9852462f24678c39ebaa4849dcb7637be83 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 10 Apr 2026 14:28:33 -0600 Subject: [PATCH 157/163] docs(cli): add beginner's guide for scoped transfer command Introduce a comprehensive guide for the `oco scoped-transfer` CLI command, including examples, common scenarios, troubleshooting tips, and JSON output details. Also, link the guide in `README.md`. --- cli/README.md | 5 + cli/scoped-transfer.md | 346 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 cli/scoped-transfer.md diff --git a/cli/README.md b/cli/README.md index 2433081c..a96f745c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -17,10 +17,15 @@ python -m cli.cli --help - `python -m cli.cli restore-local-db path/to/dump.sql` - `python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz` +- `python -m cli.cli scoped-transfer --pointid SM-0001` - `python -m cli.cli transfer-results` - `python -m cli.cli compare-duplicated-welldata` - `python -m cli.cli alembic-upgrade-and-data` +## Guides + +- Beginner guide for scoped transfers: [scoped-transfer.md](./scoped-transfer.md) + ## Notes - CLI logging is written to `cli/logs/`. diff --git a/cli/scoped-transfer.md b/cli/scoped-transfer.md new file mode 100644 index 00000000..a738a93b --- /dev/null +++ b/cli/scoped-transfer.md @@ -0,0 +1,346 @@ +# Scoped Transfer Guide + +This guide explains how to use `oco scoped-transfer` to run a targeted legacy data transfer for one or more `PointID` values. + +It is written for a beginner who may not use CLI tools often. + +## What `scoped-transfer` does + +`oco scoped-transfer` imports only the records related to the `PointID` values you request. + +This is useful when you want to: + +- test a single well or site +- rerun a small transfer after fixing an issue +- avoid running a full legacy transfer +- inspect what would be imported before writing data + +## Before you start + +Run commands from the project root. + +Activate the virtual environment: + +```bash +source .venv/bin/activate +``` + +Load environment variables from `.env`: + +```bash +set -a +source .env +set +a +``` + +If you skip these steps, the CLI may fail because it cannot find the right Python packages or database settings. + +If `oco` is not available in your shell, you can run the same command with: + +```bash +python -m cli.cli scoped-transfer --pointid SM-0001 +``` + +The examples below use `oco`, but both forms are valid. + +## Basic command + +Transfer one `PointID`: + +```bash +oco scoped-transfer --pointid SM-0001 +``` + +Transfer more than one `PointID`: + +```bash +oco scoped-transfer --pointid SM-0001 --pointid SM-0002 +``` + +The command will: + +1. validate your requested `PointID` values +2. determine which transfer families need to run +3. run the scoped transfer +4. print a final summary + +## What you will see + +At the start of the run, the CLI prints a short status message so you know it is working: + +```text +Starting scoped transfer for PointIDs: SM-0001 +Validating requested scope and preparing execution... +``` + +At the end, it prints a scoped transfer summary like this: + +```text +[SCOPED TRANSFER] +======================================================================== +Requested PointIDs: SM-0001 +Selected families: wells, contacts, permissions, waterlevels, ... + +FAMILY SUMMARY + wells completed rows=1 + contacts completed rows=1 + permissions completed rows=1 created=2 skipped_existing=0 + waterlevels completed rows=38 +``` + +## Understanding the summary + +Each line in `FAMILY SUMMARY` is a transfer family. + +Common statuses: + +- `completed`: the family ran and found matching data +- `planned`: shown during `--dry-run`; the family would run +- `no-op`: the family had no matching data for your requested `PointID` + +Common fields: + +- `rows=...`: number of matching source rows for that family +- `created=...`: number of records created or updated by that step +- `skipped_existing=...`: records skipped because they already existed + +`no-op` is normal. It does not mean the run failed. + +## What are "families"? + +In `scoped-transfer`, a **family** is a group of related records that are imported together. + +You will see family names: + +- in the `Selected families` line +- in the `FAMILY SUMMARY` output +- when using `--only` +- when using `--skip` + +Think of a family as a transfer step for one kind of data. + +For example: + +- `wells` imports the main well/site record +- `contacts` imports owner or related contact records +- `waterlevels` imports manual water-level measurements + +Not every `PointID` has data in every family. That is why many families may show `no-op` in the summary. + +### Family list + +| Family | What it means | +|---|---| +| `wells` | Main water well records and core well details. | +| `springs` | Spring site records. | +| `perennial-streams` | Perennial stream site records. | +| `ephemeral-streams` | Ephemeral stream site records. | +| `met-stations` | Meteorological station site records. | +| `rock-sample-locations` | Rock sample site records. | +| `diversion-of-surface-water` | Surface-water diversion site records. | +| `lake-pond-reservoir` | Lake, pond, or reservoir site records. | +| `soil-gas-sample-locations` | Soil gas sample site records. | +| `other-site-types` | Other site records that do not fit the main site groups. | +| `outfall-wastewater-return-flow` | Outfall or wastewater return flow site records. | +| `screens` | Well screen records linked to wells. | +| `contacts` | Owner or related contact records linked to a site. | +| `permissions` | Permission history such as monitoring or sampling permission. | +| `waterlevels` | Manual groundwater level measurements. | +| `link-ids` | Alternate IDs linked to a site, such as OSE or PLSS-style identifiers. | +| `groups` | Project or grouping records that associate sites together. | +| `assets` | Site images or files, such as photos. | +| `associated-data` | Additional attached data records related to a site. | +| `hydraulics-data` | Hydraulics test or aquifer property data linked to a well. | +| `chemistry-sampleinfo` | Chemistry sample header records for water-quality sampling. | +| `field-parameters` | Field-measured chemistry values linked to a chemistry sample. | +| `major-chemistry` | Major ion chemistry results linked to a chemistry sample. | +| `radionuclides` | Radionuclide chemistry results linked to a chemistry sample. | +| `minor-trace-chemistry` | Minor and trace chemistry results linked to a chemistry sample. | +| `sensors` | Sensor and deployment records for monitoring equipment. | +| `pressure` | Continuous pressure-based water-level records. | +| `acoustic` | Continuous acoustic water-level records. | +| `pressure-daily` | Daily summarized pressure-based water-level records. | +| `ngwmn-views` | NGWMN legacy view records related to well construction and water levels. | +| `nma-stratigraphy` | Legacy stratigraphy records. | +| `surface-water-data` | Surface-water measurement records. | +| `surface-water-photos` | Surface-water photo assets. | +| `weather-data` | Weather measurement records. | +| `weather-photos` | Weather photo assets. | +| `soil-rock-results` | Soil or rock analysis result records. | +| `cleanup-locations` | A cleanup step that fills in location fields such as state, county, or quad name after transfer. | + +## Dry run mode + +Use `--dry-run` to see what would run without writing to the database. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run +``` + +This is the safest way to check your scope before making changes. + +## Limiting the run to specific families + +Use `--only` to run just a few transfer families. + +Example: run only wells + +```bash +oco scoped-transfer --pointid SM-0001 --only wells +``` + +Example: run only water levels + +```bash +oco scoped-transfer --pointid SM-0001 --only waterlevels +``` + +Example: run only chemistry sample info + +```bash +oco scoped-transfer --pointid SM-0001 --only chemistry-sampleinfo +``` + +Important: + +- some families depend on others +- the CLI may automatically add prerequisite families + +For example, if you request `field-parameters`, the CLI may also add `wells` and `chemistry-sampleinfo`. + +You will see that in the final output as: + +```text +Auto-added prerequisites: chemistry-sampleinfo, wells +``` + +## Skipping families + +Use `--skip` to leave out families you do not want to run. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --skip assets --skip weather-photos +``` + +This is useful when: + +- you are narrowing a test run +- a family is known to be irrelevant for your target +- you want faster iteration while debugging + +## JSON output + +Use `--output json` if you want machine-readable output. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run --output json +``` + +This is useful for scripting or saving results to another tool. + +When JSON output is enabled, the CLI prints JSON instead of the human summary. + +## Common examples + +### Example 1: Preview a transfer for one well + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run +``` + +Use this first when you are not sure what data exists. + +### Example 2: Run the full scoped transfer for one well + +```bash +oco scoped-transfer --pointid SM-0001 +``` + +Use this after the dry run looks correct. + +### Example 3: Re-run only water levels for one well + +```bash +oco scoped-transfer --pointid SM-0001 --only waterlevels +``` + +Use this when you are debugging water-level behavior. + +### Example 4: Run wells and contacts only + +```bash +oco scoped-transfer --pointid SM-0001 --only wells --only contacts +``` + +Use this when you want a smaller targeted import. + +### Example 5: Run two PointIDs together + +```bash +oco scoped-transfer --pointid SM-0001 --pointid SM-0002 +``` + +Use this when the same test or fix should be checked for more than one site. + +## Troubleshooting + +### The command says a `PointID` was not found + +That usually means the requested `PointID` does not appear in the source data for the selected scope. + +Try: + +- checking for typos +- confirming letter case and punctuation +- running a dry run again + +### A family shows `no-op` + +That means the family had no matching rows for the requested `PointID`. + +This is expected for many families. Not every site has data in every table. + +### The command finishes but creates less than expected + +Check: + +- whether you used `--only` or `--skip` +- whether prerequisites were auto-added +- the `rows=...` counts in the summary +- whether data may already exist and be counted as `skipped_existing` + + +## Related files + +Main CLI command: + +- `cli/cli.py` + +Scoped transfer service: + +- `services/scoped_transfer.py` + +## Quick reference + +```bash +# Basic run +oco scoped-transfer --pointid SM-0001 + +# Dry run +oco scoped-transfer --pointid SM-0001 --dry-run + +# Only one family +oco scoped-transfer --pointid SM-0001 --only waterlevels + +# Skip one family +oco scoped-transfer --pointid SM-0001 --skip assets + +# JSON output +oco scoped-transfer --pointid SM-0001 --output json +``` From 5cfe3980744ed482c157fa8a046a75aeeda2122c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 10 Apr 2026 10:57:14 -0500 Subject: [PATCH 158/163] feat(api/thing): Add optional flag to see contacts --- api/thing.py | 25 +++++++++++++------------ services/thing_helper.py | 15 +++++++++++---- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/api/thing.py b/api/thing.py index 0babb871..ce5e6ec2 100644 --- a/api/thing.py +++ b/api/thing.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from typing import Optional from fastapi import APIRouter, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select @@ -153,19 +154,18 @@ async def get_water_wells( user: viewer_dependency, session: session_dependency, request: Request, - sort: str = None, - order: str = None, + sort: Optional[str] = None, + order: Optional[str] = None, filter_: str = Query(alias="filter", default=None), - query: str = None, - name: str = None, + query: Optional[str] = None, + name: Optional[str] = None, + include_contacts: bool = False, ) -> CustomPage[WellResponse]: """ Retrieve all wells from the database. """ thing_type = request.url.path.split("/")[2].replace("-", " ") - return get_db_things( - filter_, order, query, session, sort, name=name, thing_type=thing_type - ) + return get_db_things(filter_, order, query, session, sort, name=name, thing_type=thing_type, include_contacts=include_contacts) @router.get( @@ -348,11 +348,11 @@ async def get_thing_id_links( async def get_things( user: viewer_dependency, session: session_dependency, - # thing_id: int = None, - within: str = None, - query: str = None, - sort: str = None, - order: str = None, + within: Optional[str] = None, + query: Optional[str] = None, + sort: Optional[str] = None, + order: Optional[str] = None, + include_contacts: bool = False, filter_: str = Query( default=None, alias="filter", @@ -369,6 +369,7 @@ async def get_things( session, sort, within=within, + include_contacts=include_contacts, ) diff --git a/services/thing_helper.py b/services/thing_helper.py index 68218a95..e96b208e 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -16,7 +16,7 @@ import logging import time from datetime import datetime -from typing import Sequence +from typing import Sequence, Optional from zoneinfo import ZoneInfo from fastapi import Request, HTTPException @@ -116,9 +116,10 @@ def get_db_things( query, session, sort, - thing_type: str = None, - within: str = None, - name: str = None, + thing_type: Optional[str] = None, + within: Optional[str] = None, + name: Optional[str] = None, + include_contacts: bool = False, ) -> list: if query: @@ -135,6 +136,12 @@ def get_db_things( # add all eager loads for generic thing query until/unless GET /thing is deprecated sql = sql.options(*WATER_WELL_LOADER_OPTIONS) + if include_contacts: + sql = sql.options( + selectinload(Thing.contact_associations) + .selectinload(ThingContactAssociation.contact) + ) + if name: sql = sql.where(Thing.name == name) From 03cebb9fb73d5cd2033c444c54537b9b4f560615 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 10 Apr 2026 13:11:09 -0500 Subject: [PATCH 159/163] feat(thing_helper): Query now go through search --- services/thing_helper.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index e96b208e..0360babf 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -43,6 +43,7 @@ ThingIdLink, MonitoringFrequencyHistory, StatusHistory, + search, ) from services.audit_helper import audit_add from services.crud_helper import model_patcher @@ -123,7 +124,11 @@ def get_db_things( ) -> list: if query: - sql = select(Thing).where(make_query(Thing, query)) + sql = search( + select(Thing), + query, + vector=Thing.search_vector, + ) else: sql = select(Thing) From 284a2f2eb433f72824fdf8f2874f219015283509 Mon Sep 17 00:00:00 2001 From: TylerAdamMartinez <57375362+TylerAdamMartinez@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:12:40 +0000 Subject: [PATCH 160/163] Formatting changes --- api/thing.py | 11 ++++++++++- services/thing_helper.py | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/api/thing.py b/api/thing.py index ce5e6ec2..5b8a52e1 100644 --- a/api/thing.py +++ b/api/thing.py @@ -165,7 +165,16 @@ async def get_water_wells( Retrieve all wells from the database. """ thing_type = request.url.path.split("/")[2].replace("-", " ") - return get_db_things(filter_, order, query, session, sort, name=name, thing_type=thing_type, include_contacts=include_contacts) + return get_db_things( + filter_, + order, + query, + session, + sort, + name=name, + thing_type=thing_type, + include_contacts=include_contacts, + ) @router.get( diff --git a/services/thing_helper.py b/services/thing_helper.py index 0360babf..652a893c 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -143,8 +143,9 @@ def get_db_things( if include_contacts: sql = sql.options( - selectinload(Thing.contact_associations) - .selectinload(ThingContactAssociation.contact) + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ) ) if name: From e4fbed0a37b7a0e3e0a42e771cde42fc85844a9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:31:22 +0000 Subject: [PATCH 161/163] build(deps): bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/dependabot_automerge.yml | 2 +- .github/workflows/jira_issue_on_open.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml index e63bf81d..fca49bf2 100644 --- a/.github/workflows/dependabot_automerge.yml +++ b/.github/workflows/dependabot_automerge.yml @@ -23,7 +23,7 @@ jobs: # Auto-approve (only matters if your branch protection requires reviews) - name: Approve PR if: steps.metadata.outputs.update-type != 'version-update:semver-major' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/jira_issue_on_open.yml b/.github/workflows/jira_issue_on_open.yml index 4b13fcc0..99029c87 100644 --- a/.github/workflows/jira_issue_on_open.yml +++ b/.github/workflows/jira_issue_on_open.yml @@ -176,7 +176,7 @@ jobs: echo "jira_browse_url=${JIRA_BASE_URL}/browse/${JIRA_KEY}" >> "$GITHUB_OUTPUT" - name: Comment Jira link back on the GitHub issue - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: JIRA_KEY: ${{ steps.jira.outputs.jira_key }} JIRA_URL: ${{ steps.jira.outputs.jira_browse_url }} From 58d451876345ec901812fb79aba7d40d0b11d525 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:31:26 +0000 Subject: [PATCH 162/163] build(deps): bump dependabot/fetch-metadata from 2 to 3 Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3. - [Release notes](https://github.com/dependabot/fetch-metadata/releases) - [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3) --- updated-dependencies: - dependency-name: dependabot/fetch-metadata dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/dependabot_automerge.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml index e63bf81d..9acf0192 100644 --- a/.github/workflows/dependabot_automerge.yml +++ b/.github/workflows/dependabot_automerge.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 with: github-token: ${{ secrets.GITHUB_TOKEN }} From 66ae6861a945cf3d2a09bfd3b0e5bb69c083c597 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:55:14 +0000 Subject: [PATCH 163/163] build(deps): bump pytest from 9.0.2 to 9.0.3 (#649) Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 ++-- requirements.txt | 6 +++--- uv.lock | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 147715ad..a999e575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ "pygeoapi==0.22.0", "pyproj==3.7.2", "pyshp==2.3.1", - "pytest==9.0.2", + "pytest==9.0.3", "pytest-cov==6.2.1", "python-dateutil==2.9.0.post0", "python-jose>=3.5.0", @@ -138,7 +138,7 @@ dev = [ "faker>=25.0.0", "flake8>=7.3.0", "pyhamcrest>=2.0.3", - "pytest>=8.4.0", + "pytest>=9.0.3", "python-dotenv>=1.1.1", "requests>=2.32.5", ] diff --git a/requirements.txt b/requirements.txt index 383a800c..1bd3bb28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1692,9 +1692,9 @@ pyshp==2.3.1 \ --hash=sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1 \ --hash=sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49 # via ocotilloapi -pytest==9.0.2 \ - --hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \ - --hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11 +pytest==9.0.3 \ + --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ + --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c # via # ocotilloapi # pytest-cov diff --git a/uv.lock b/uv.lock index c801b7c5..bc8fea56 100644 --- a/uv.lock +++ b/uv.lock @@ -1628,7 +1628,7 @@ requires-dist = [ { name = "pyjwt", specifier = "==2.12.1" }, { name = "pyproj", specifier = "==3.7.2" }, { name = "pyshp", specifier = "==2.3.1" }, - { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-cov", specifier = "==6.2.1" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-jose", specifier = ">=3.5.0" }, @@ -1664,7 +1664,7 @@ dev = [ { name = "faker", specifier = ">=25.0.0" }, { name = "flake8", specifier = ">=7.3.0" }, { name = "pyhamcrest", specifier = ">=2.0.3" }, - { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "requests", specifier = ">=2.32.5" }, ] @@ -2323,7 +2323,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2332,9 +2332,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]]