From cd6d21252ff22e44febc3bddd006ab9358d5416e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 14 Jan 2026 11:58:31 -0700 Subject: [PATCH 001/126] fix: use psycopg2-binary instead of psycopg2 for better compatibility --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8680ef1..15193eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ gunicorn httpx mypy pandas -psycopg2 +psycopg2-binary pytest pyyaml types-pyyaml diff --git a/setup.py b/setup.py index 36bc100..ce24562 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ "httpx", "mypy", "pandas", - "psycopg2", + "psycopg2-binary", "pytest", "pyyaml", "types-pyyaml", From 7ecc5c98fc18e5f2460b031321b952089debf912 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 14 Jan 2026 12:00:42 -0700 Subject: [PATCH 002/126] fix: bump version to 0.9.10 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ce24562..55446be 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( name="nmuwd", - version="0.9.9", + version="0.9.10", author="Jake Ross", description="New Mexico Water Data Integration Engine", long_description=long_description, From 9ccdda43a8f931066f8cb16eec25574c55467b01 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 6 Apr 2026 09:46:25 -0600 Subject: [PATCH 003/126] feat(constants): add specific conductance to constants Added specific conductance and micro siemens per centimeter to the constants file to support new functionality in the data integration engine. --- backend/constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/constants.py b/backend/constants.py index 2482900..a9bf3e4 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -30,6 +30,7 @@ POTASSIUM = "potassium" SILICA = "silica" SODIUM = "sodium" +SPECIFIC_CONDUCTANCE = "specific_conductance" SULFATE = "sulfate" TDS = "tds" URANIUM = "uranium" @@ -37,6 +38,7 @@ MILLIGRAMS_PER_LITER = "mg/L" MICROGRAMS_PER_LITER = "ug/L" +MICROSIEMENS_PER_CENTIMETER = "uS/cm" PARTS_PER_MILLION = "ppm" PARTS_PER_BILLION = "ppb" TONS_PER_ACRE_FOOT = "tons/ac ft" @@ -76,6 +78,7 @@ POTASSIUM, SILICA, SODIUM, + SPECIFIC_CONDUCTANCE, SULFATE, TDS, URANIUM, From 02750b1df045e60d9891b77c5a75abfebc136e3d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 6 Apr 2026 14:45:57 -0600 Subject: [PATCH 004/126] fix(source): decrease chunk size for nmbgmr site source Getting more than 10 at a time often causes HTTP timeout errors. Decreasing chunk size to 10 seems to fix the issue. --- backend/connectors/nmbgmr/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 490c5d1..45e0a16 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -55,7 +55,7 @@ def _make_url(endpoint): class NMBGMRSiteSource(BaseSiteSource): transformer_klass = NMBGMRSiteTransformer - chunk_size = 100 + chunk_size = 10 bounding_polygon = NM_STATE_BOUNDING_POLYGON def __repr__(self): From 860a653ce31c793bb1043c71eed1c0f78a1f45ea Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 6 Apr 2026 14:48:30 -0600 Subject: [PATCH 005/126] feat(parameter): add specific conductance as a parameter with unit uS/cm This update adds specific conductance as a parameter in the configuration, with the output unit of microsiemens per centimeter (uS/cm). It also updates the transformer to handle conversions for specific conductance, and adds the appropriate mapping for this parameter in the connectors. --- README.md | 28 ++++++++++++++-------------- UNIT_CONVERSIONS.md | 7 +++++++ backend/config.py | 5 +++++ backend/connectors/mappings.py | 6 ++++++ backend/transformer.py | 4 ++++ 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e34155b..3c3ba4a 100644 --- a/README.md +++ b/README.md @@ -56,20 +56,20 @@ where `{parameter}` is the name of the parameter whose data is to be retrieved, #### Available Parameters -| | waterlevels | arsenic | bicarbonate | calcium | carbonate | chloride | fluoride | magnesium | nitrate | ph | potassium | silica | sodium | sulfate | tds | uranium | -| -------------------------- | ----------- | ------- | ----------- | ------- | --------- | -------- | -------- | --------- | ------- | --- | --------- | ------ | ------ | ------- | --- | ------- | -| **bernco** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **bor** | - | X | - | X | - | X | X | X | X | X | X | X | X | X | X | X | -| **cabq** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **ebid** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nmbgmr-amp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | -| **nmed-dwb** | - | X | X | X | - | X | X | X | X | X | X | X | X | X | X | X | -| **nmose-isc-seven-rivers** | X | - | X | X | - | X | X | X | X | X | X | X | X | X | X | - | -| **nmose-pod** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nmose-roswell** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nwis** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **pvacd** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **wqp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X* | X | +| | waterlevels | arsenic | bicarbonate | calcium | carbonate | chloride | fluoride | magnesium | nitrate | ph | potassium | silica | sodium | specific conductance | sulfate | tds | uranium | +| -------------------------- | ----------- | ------- | ----------- | ------- | --------- | -------- | -------- | --------- | ------- | --- | --------- | ------ | ------ | -------------------- |-------- | --- | ------- | +| **bernco** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **bor** | - | X | - | X | - | X | X | X | X | X | X | X | X | X | X | X | X | +| **cabq** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **ebid** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nmbgmr-amp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | +| **nmed-dwb** | - | X | X | X | - | X | X | X | X | X | X | X | X | X | X | X | X | +| **nmose-isc-seven-rivers** | X | - | X | X | - | X | X | X | X | X | X | X | X | X | X | X | - | +| **nmose-pod** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nmose-roswell** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nwis** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **pvacd** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **wqp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X* | X | *TDS data from WQP may contain duplicates. Duplicates are identified when they have the same ActivityIdentifier. If duplicates are identified, only one is kept as identified by its USGS pCode. The order of preference for the pCodes is: [70300](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70300&fmt=html), [70301](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70301&fmt=html), [70303](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70303&fmt=html). diff --git a/UNIT_CONVERSIONS.md b/UNIT_CONVERSIONS.md index d5171e0..682adfb 100644 --- a/UNIT_CONVERSIONS.md +++ b/UNIT_CONVERSIONS.md @@ -15,6 +15,13 @@ The conversion factor is applied to the reported value from the source to obtain | nitrate | mg/L as N | nitrate | mg/L | 4.427 | - | | nitrate | ug/L as N | nitrate | mg/L | 0.004427 | - | | nitrate | mg/L as NO3 | nitrate | mg/L | 1 | - | +| specific conductance | umho/cm | specific conductance | uS/cm | 1 | - | +| specific conductance | umhos/cm | specific conductance | uS/cm | 1 | - | +| specific conductance | uS/cm @25C | specific conductance | uS/cm | 1 | - | +| specific conductance | uS/cm @25C | specific conductance | uS/cm | 1 | - | +| conductivity @ 25 c umhos/cm | cm-1 | specific conductance | uS/cm | 1 | - | +| conductivity @ 25 c umhos/cm | micromhos per centimeter | specific conductance | uS/cm | 1 | - | +| conductivity @ 25 c umhos/cm | su. | specific conductance | uS/cm | 1 | - | | sulfate as SO4 | mg/L | sulfate | mg/L | 1 | - | | sulfur sulfate | mg/L | sulfate | mg/L | 1 | - | | uranium | pCi/L | uranium | mg/L | 0.00149 | [conversion factor source](https://www.epa.gov/sites/default/files/2015-09/documents/qa_rad_webcast.pdf) | diff --git a/backend/config.py b/backend/config.py index dec3b42..338182d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -32,6 +32,7 @@ from .connectors.nmose.source import NMOSEPODSiteSource from .constants import ( MILLIGRAMS_PER_LITER, + MICROSIEMENS_PER_CENTIMETER, WGS84, FEET, WATERLEVELS, @@ -47,6 +48,7 @@ POTASSIUM, SILICA, SODIUM, + SPECIFIC_CONDUCTANCE, SULFATE, TDS, URANIUM, @@ -263,6 +265,7 @@ def get_config_and_false_agencies(self): POTASSIUM, SILICA, SODIUM, + SPECIFIC_CONDUCTANCE, SULFATE, TDS, ]: @@ -555,6 +558,8 @@ def _update_output_units(self): parameter = self.parameter.lower() if parameter == "ph": self.analyte_output_units = "" + elif parameter == SPECIFIC_CONDUCTANCE: + self.analyte_output_units = MICROSIEMENS_PER_CENTIMETER @property def start_dt(self): diff --git a/backend/connectors/mappings.py b/backend/connectors/mappings.py index d2468d0..e1bec16 100644 --- a/backend/connectors/mappings.py +++ b/backend/connectors/mappings.py @@ -29,6 +29,7 @@ CARBONATE, PH, BICARBONATE, + SPECIFIC_CONDUCTANCE ) # DWB =============================================================================== @@ -45,6 +46,7 @@ POTASSIUM: 33, SILICA: 37, SODIUM: 38, + SPECIFIC_CONDUCTANCE: 49, SULFATE: 41, TDS: 90, # "Uranium-238": 386, @@ -100,6 +102,7 @@ POTASSIUM: "Potassium", SILICA: "SiO2", SODIUM: "Sodium", + SPECIFIC_CONDUCTANCE: "Specific Conductance", SULFATE: "Sulfate", TDS: "TDS calc", URANIUM: None, @@ -137,6 +140,7 @@ CALCIUM: "Calcium", CARBONATE: "Carbonate", CHLORIDE: "Chloride", + SPECIFIC_CONDUCTANCE: "Conductivity, laboratory", FLUORIDE: "Fluoride", MAGNESIUM: "Magnesium", NITRATE: "Nitrate (as N)", @@ -161,6 +165,7 @@ NITRATE: ["Nitrate", "Nitrate-N", "Nitrate as N"], POTASSIUM: ["Potassium"], SILICA: ["Silica"], + SPECIFIC_CONDUCTANCE: ["Specific conductance"], SODIUM: ["Sodium"], SULFATE: [ "Sulfate", @@ -240,6 +245,7 @@ POTASSIUM: "K", SILICA: "SiO2", SODIUM: "Na", + SPECIFIC_CONDUCTANCE: "Cond", SULFATE: "SO4", TDS: "TDS", URANIUM: "U", diff --git a/backend/transformer.py b/backend/transformer.py index 85b6a9f..3a3e147 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -199,6 +199,10 @@ def convert_units( """ if die_parameter_name == "ph": conversion_factor = 1.0 + elif die_parameter_name == "specific_conductance": + # mg/l is assumed to be a mistake. the name of the source parameter is "CONDUCTIVITY @ 25 C UMHOS/CM" + if input_units in ["�mhos/cm", "umho/cm", "cm-1", "micromhos per centimeter", "mg/l", "su", "us/cm", "us/cm @25c", "µs/cm", "\u03bcs/cm",]: + conversion_factor = 1.0 elif output_units == mgl: if input_units in ["mg/l caco3", "mg/l caco3**"]: if die_parameter_name == "bicarbonate": From 40a9f2cff54d3348a726e4e85995ce7149385656 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 6 Apr 2026 17:10:29 -0600 Subject: [PATCH 006/126] fix(specific conductance): update mappings | remove BOR Update specific conductance to use the correct mappings and remove BOR from specific conductance agencies. BOR sites in NM report conductivity, not specific conductance (in a lab at 25 deg C) --- README.md | 2 +- backend/config.py | 13 ++++++++++++- backend/connectors/mappings.py | 12 ++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c3ba4a..4ea4473 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ where `{parameter}` is the name of the parameter whose data is to be retrieved, | | waterlevels | arsenic | bicarbonate | calcium | carbonate | chloride | fluoride | magnesium | nitrate | ph | potassium | silica | sodium | specific conductance | sulfate | tds | uranium | | -------------------------- | ----------- | ------- | ----------- | ------- | --------- | -------- | -------- | --------- | ------- | --- | --------- | ------ | ------ | -------------------- |-------- | --- | ------- | | **bernco** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **bor** | - | X | - | X | - | X | X | X | X | X | X | X | X | X | X | X | X | +| **bor** | - | X | - | X | - | X | X | X | X | X | X | X | X | - | X | X | X | | **cabq** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | | **ebid** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | | **nmbgmr-amp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | diff --git a/backend/config.py b/backend/config.py index 338182d..0644098 100644 --- a/backend/config.py +++ b/backend/config.py @@ -254,6 +254,18 @@ def get_config_and_false_agencies(self): "nwis", "pvacd", ] + elif self.parameter in [SPECIFIC_CONDUCTANCE]: + config_agencies = ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"] + false_agencies = [ + "bor", + "bernco", + "cabq", + "ebid", + "nmose_roswell", + "nmose_pod", + "nwis", + "pvacd", + ] elif self.parameter in [ BICARBONATE, CALCIUM, @@ -265,7 +277,6 @@ def get_config_and_false_agencies(self): POTASSIUM, SILICA, SODIUM, - SPECIFIC_CONDUCTANCE, SULFATE, TDS, ]: diff --git a/backend/connectors/mappings.py b/backend/connectors/mappings.py index e1bec16..18dfac0 100644 --- a/backend/connectors/mappings.py +++ b/backend/connectors/mappings.py @@ -46,7 +46,7 @@ POTASSIUM: 33, SILICA: 37, SODIUM: 38, - SPECIFIC_CONDUCTANCE: 49, + SPECIFIC_CONDUCTANCE: 49, # name = "CONDUCTIVITY @ 25 C UMHOS/CM" SULFATE: 41, TDS: 90, # "Uranium-238": 386, @@ -56,7 +56,7 @@ # ISC Seven Rivers =============================================================================== """ pH -Specific Conductance +Specific Conductance <-- field parameter Temperature Potassium Magnesium @@ -68,7 +68,7 @@ Turbidity Iron Manganese -Electrical Conductance +Electrical Conductance <-- lab parameter with EPA method 120.1, which specifies "Measurement of Electrical Conductivity at 25 degrees C." It is assumed that the records with method "UNKNOWN" still used EPA 120.1 Ion Balance SiO2 Chloride @@ -102,7 +102,7 @@ POTASSIUM: "Potassium", SILICA: "SiO2", SODIUM: "Sodium", - SPECIFIC_CONDUCTANCE: "Specific Conductance", + SPECIFIC_CONDUCTANCE: "Electrical Conductance", SULFATE: "Sulfate", TDS: "TDS calc", URANIUM: None, @@ -189,7 +189,7 @@ P ALK pH Color -Cond +Cond <-- "Electrical Conductactivity" specifies "Measurement of Electrical conductivity at 25 degrees C." This will therefore be used for "conductivity" but not "Specific Conductance" Br Cl CN @@ -245,7 +245,7 @@ POTASSIUM: "K", SILICA: "SiO2", SODIUM: "Na", - SPECIFIC_CONDUCTANCE: "Cond", + SPECIFIC_CONDUCTANCE: None, SULFATE: "SO4", TDS: "TDS", URANIUM: "U", From 6d424cbf19e542fce0343c54f7c52df1f0f0303a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 6 Apr 2026 17:11:23 -0600 Subject: [PATCH 007/126] fix(specific conductance): remove records where temp not 25C To be specific conductance it needs to be measured at 25C, otherwise it is conductivity --- backend/connectors/wqp/source.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/connectors/wqp/source.py b/backend/connectors/wqp/source.py index 926b2b4..ba53056 100644 --- a/backend/connectors/wqp/source.py +++ b/backend/connectors/wqp/source.py @@ -30,6 +30,7 @@ LATEST, TDS, WATERLEVELS, + SPECIFIC_CONDUCTANCE, USGS_PCODE_30210, USGS_PCODE_70300, USGS_PCODE_70301, @@ -172,6 +173,16 @@ def _clean_records(self, records): kept_record = list(ai_records.values())[0] return_records.append(kept_record) return return_records + elif self.config.parameter == SPECIFIC_CONDUCTANCE and len(clean_records) > 0: + return_records = [] + dirty_records = [] + for record in clean_records: + if record["ResultTemperatureBasisText"].strip() in ["25 deg C", "25 Deg C"]: + return_records.append(record) + else: + # nothing is being done these at the moment, but they are being logged in case they need to be inspected at a later date + dirty_records.append((record["MonitoringLocationIdentifier"], record["ActivityStartDate"], record["ResultTemperatureBasisText"])) + return return_records else: return clean_records From 89537debf88a4b4bf069fb24f2a4b9808c061d15 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:23:33 -0600 Subject: [PATCH 008/126] feat(parameter): add conductivity to README --- .DS_Store | Bin 0 -> 10244 bytes README.md | 34 ++++++++++++++++++---------------- 2 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e6588872ee53d89ff7611a199efaca63c1dcef6a GIT binary patch literal 10244 zcmeI1J!lj`6vt;wOimPyl^}?VC|F7Ea=8y#Wj$7{1T})?X-rf?j=RFEv{<-Ov9Qrf z2o@5w6bV`gLJ({`D+@(prIiYH27_-taOd65jB6tayn)$wxBH*n-~D&r+`T!*7;kiE zii`z}u>y1v$F{-7(!_DmGi{UJ6GP-Mp0O^wHh-l!b*bIzWYn~S93ThC0djyGAP3fi z1Mrk-k&cOqYB=L+IOF=tTvsSuULD2-QqFj!B5lb5avld0dsMp*b>7HMS_|#;c z-=xWRbLjdXLKX~Tg0$rMsK3xv38s_SsvOHfkldE;U_r<{lx|GYO%fVI8!Bsc8 zW`CSqb#Q?$<{9 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 4ea4473..bb05d17 100644 --- a/README.md +++ b/README.md @@ -56,22 +56,24 @@ where `{parameter}` is the name of the parameter whose data is to be retrieved, #### Available Parameters -| | waterlevels | arsenic | bicarbonate | calcium | carbonate | chloride | fluoride | magnesium | nitrate | ph | potassium | silica | sodium | specific conductance | sulfate | tds | uranium | -| -------------------------- | ----------- | ------- | ----------- | ------- | --------- | -------- | -------- | --------- | ------- | --- | --------- | ------ | ------ | -------------------- |-------- | --- | ------- | -| **bernco** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **bor** | - | X | - | X | - | X | X | X | X | X | X | X | X | - | X | X | X | -| **cabq** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **ebid** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nmbgmr-amp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | -| **nmed-dwb** | - | X | X | X | - | X | X | X | X | X | X | X | X | X | X | X | X | -| **nmose-isc-seven-rivers** | X | - | X | X | - | X | X | X | X | X | X | X | X | X | X | X | - | -| **nmose-pod** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nmose-roswell** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **nwis** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **pvacd** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -| **wqp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X* | X | - -*TDS data from WQP may contain duplicates. Duplicates are identified when they have the same ActivityIdentifier. If duplicates are identified, only one is kept as identified by its USGS pCode. The order of preference for the pCodes is: [70300](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70300&fmt=html), [70301](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70301&fmt=html), [70303](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70303&fmt=html). +| | waterlevels | arsenic | bicarbonate | conductivity | calcium | carbonate | chloride | fluoride | magnesium | nitrate | ph | potassium | silica | sodium | specific conductance | sulfate | tds | uranium | +| -------------------------- | ----------- | ------- | ----------- | ------------ | ------- | --------- | -------- | -------- | --------- | ------- | --- | --------- | ------ | ------ | -------------------- |-------- | --- | ------- | +| **bernco** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **bor** | - | X | - | X | X | - | X | X | X | X | X | X | X | X | - | X | X | X | +| **cabq** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **ebid** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nmbgmr-amp** | X | X | X | - | X | X | X | X | X | X | X | X | X | X | X | X | X | X | +| **nmed-dwb** | - | X | X | - | X | - | X | X | X | X | X | X | X | X | X | X | X | X | +| **nmose-isc-seven-rivers** | X | - | X | X | X | - | X | X | X | X | X | X | X | X | X | X | X | - | +| **nmose-pod** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nmose-roswell** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **nwis** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **pvacd** | X | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **wqp** | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X* | X | + +*TDS data from WQP may contain duplicates. Duplicates are identified when they have the same ActivityIdentifier. If duplicates are identified, only one is kept as identified by its USGS pCode. The order of preference for the pCodes is: [70300](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70300&fmt=html), [70301](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70301&fmt=html), [70303](https://help.waterdata.usgs.gov/code/parameter_cd_nm_query?parm_nm_cd=70303&fmt=html). + +**While conductivity and specific conductance are often used interchangeably, they are distinguished here by the methods with which they are determined. A record is defined as `specific conductance` if it was determined at the standard 25°C (e.g. [EPA method 120.1](https://www.epa.gov/sites/default/files/2015-08/documents/method_120-1_1982.pdf)), otherwise it is defined as `conductivity` ### Output Type The `--output-type` option is required and used to set the output type: From 2deec341fd885028a71039082dbd8a5d9d2ac40f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:24:51 -0600 Subject: [PATCH 009/126] fix(.gitignore): ignore .DS_Store --- .DS_Store | Bin 10244 -> 0 bytes .gitignore | 1 + 2 files changed, 1 insertion(+) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e6588872ee53d89ff7611a199efaca63c1dcef6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeI1J!lj`6vt;wOimPyl^}?VC|F7Ea=8y#Wj$7{1T})?X-rf?j=RFEv{<-Ov9Qrf z2o@5w6bV`gLJ({`D+@(prIiYH27_-taOd65jB6tayn)$wxBH*n-~D&r+`T!*7;kiE zii`z}u>y1v$F{-7(!_DmGi{UJ6GP-Mp0O^wHh-l!b*bIzWYn~S93ThC0djyGAP3fi z1Mrk-k&cOqYB=L+IOF=tTvsSuULD2-QqFj!B5lb5avld0dsMp*b>7HMS_|#;c z-=xWRbLjdXLKX~Tg0$rMsK3xv38s_SsvOHfkldE;U_r<{lx|GYO%fVI8!Bsc8 zW`CSqb#Q?$<{9 diff --git a/.gitignore b/.gitignore index cc04cc7..2ad8ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ frontend/requirements.txt frontend/output_timeseries backend/test00112233.csv frontend/api/cache +.DS_Store # Byte-compiled / optimized / DLL files __pycache__/ From fd9c809a39e2e856b3f3e89cce6cfd98e6e0f04c Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:27:08 -0600 Subject: [PATCH 010/126] feat(parameter): add conductivity to constants --- backend/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/constants.py b/backend/constants.py index a9bf3e4..1eab493 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -23,6 +23,7 @@ CALCIUM = "calcium" CARBONATE = "carbonate" CHLORIDE = "chloride" +CONDUCTIVITY = "conductivity" FLUORIDE = "fluoride" MAGNESIUM = "magnesium" NITRATE = "nitrate" From 7bafedbd0ee0d28419028e2524425f43e015df9b Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:34:59 -0600 Subject: [PATCH 011/126] feat(parameter): add conductivity mappings --- backend/config.py | 15 +++++++++++++++ backend/connectors/mappings.py | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/config.py b/backend/config.py index 0644098..bcec2fc 100644 --- a/backend/config.py +++ b/backend/config.py @@ -41,6 +41,7 @@ CALCIUM, CARBONATE, CHLORIDE, + CONDUCTIVITY, FLUORIDE, MAGNESIUM, NITRATE, @@ -266,6 +267,20 @@ def get_config_and_false_agencies(self): "nwis", "pvacd", ] + elif self.parameter in [CONDUCTIVITY]: + config_agencies = ["bor", "nmose_isc_seven_rivers", "wqp"] + false_agencies = [ + "bor", + "bernco", + "cabq", + "ebid", + "nmbgmr_amp", + "nmed_dwb", + "nmose_roswell", + "nmose_pod", + "nwis", + "pvacd", + ] elif self.parameter in [ BICARBONATE, CALCIUM, diff --git a/backend/connectors/mappings.py b/backend/connectors/mappings.py index 18dfac0..61aecd6 100644 --- a/backend/connectors/mappings.py +++ b/backend/connectors/mappings.py @@ -29,7 +29,8 @@ CARBONATE, PH, BICARBONATE, - SPECIFIC_CONDUCTANCE + SPECIFIC_CONDUCTANCE, + CONDUCTIVITY ) # DWB =============================================================================== @@ -40,6 +41,7 @@ CALCIUM: 11, CARBONATE: None, CHLORIDE: 15, + CONDUCTIVITY: None, FLUORIDE: 19, MAGNESIUM: 23, NITRATE: 35, @@ -96,6 +98,7 @@ CHLORIDE: "Chloride", CALCIUM: "Calcium", CARBONATE: "Carbonate (CO3)", + CONDUCTIVITY: "Specific Conductance", FLUORIDE: "Fluoride", MAGNESIUM: "Magnesium", NITRATE: "Nitrate", @@ -140,6 +143,7 @@ CALCIUM: "Calcium", CARBONATE: "Carbonate", CHLORIDE: "Chloride", + CONDUCTIVITY: None, SPECIFIC_CONDUCTANCE: "Conductivity, laboratory", FLUORIDE: "Fluoride", MAGNESIUM: "Magnesium", @@ -160,6 +164,7 @@ CALCIUM: ["Calcium"], CARBONATE: ["Carbonate"], CHLORIDE: ["Chloride"], + CONDUCTIVITY: ["Specific conductance"], FLUORIDE: ["Fluoride"], MAGNESIUM: ["Magnesium"], NITRATE: ["Nitrate", "Nitrate-N", "Nitrate as N"], @@ -239,6 +244,7 @@ CALCIUM: "Ca", CARBONATE: None, CHLORIDE: "Cl", + CONDUCTIVITY: "Cond", FLUORIDE: "F", MAGNESIUM: "Mg", NITRATE: "NO3", From 8e61bf28b90a6d5fdbb14de6770cd375f316f96f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:42:15 -0600 Subject: [PATCH 012/126] fix(mappings): ensure conducitivy and sc in each mapping --- backend/connectors/mappings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/connectors/mappings.py b/backend/connectors/mappings.py index 61aecd6..bd07545 100644 --- a/backend/connectors/mappings.py +++ b/backend/connectors/mappings.py @@ -279,11 +279,13 @@ def get_var_name(var): CALCIUM, CARBONATE, CHLORIDE, + CONDUCTIVITY, FLUORIDE, MAGNESIUM, NITRATE, POTASSIUM, SODIUM, + SPECIFIC_CONDUCTANCE, SULFATE, TDS, URANIUM, From 919678971b14d9d55b9f9a323b44cfd67b1bf7ce Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:42:32 -0600 Subject: [PATCH 013/126] fix(config): make units for conductivity uS/cm --- backend/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/config.py b/backend/config.py index bcec2fc..2b4af34 100644 --- a/backend/config.py +++ b/backend/config.py @@ -584,7 +584,7 @@ def _update_output_units(self): parameter = self.parameter.lower() if parameter == "ph": self.analyte_output_units = "" - elif parameter == SPECIFIC_CONDUCTANCE: + elif parameter in [CONDUCTIVITY, SPECIFIC_CONDUCTANCE]: self.analyte_output_units = MICROSIEMENS_PER_CENTIMETER @property From 5f60b1ab7873033f908c20eb2c61b0a5b7859a5f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 09:44:43 -0600 Subject: [PATCH 014/126] fix(parameter): add conductivity to parameter list --- backend/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/constants.py b/backend/constants.py index 1eab493..889c718 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -72,6 +72,7 @@ CALCIUM, CARBONATE, CHLORIDE, + CONDUCTIVITY, FLUORIDE, MAGNESIUM, NITRATE, From 15d57b935932f6ca9c745e44f6bc26c38abd2f2f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 10:02:10 -0600 Subject: [PATCH 015/126] fix(unit conversion): convert units for conductivity --- backend/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/transformer.py b/backend/transformer.py index 3a3e147..a42ca89 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -199,7 +199,7 @@ def convert_units( """ if die_parameter_name == "ph": conversion_factor = 1.0 - elif die_parameter_name == "specific_conductance": + elif die_parameter_name in ["conductivity","specific_conductance"]: # mg/l is assumed to be a mistake. the name of the source parameter is "CONDUCTIVITY @ 25 C UMHOS/CM" if input_units in ["�mhos/cm", "umho/cm", "cm-1", "micromhos per centimeter", "mg/l", "su", "us/cm", "us/cm @25c", "µs/cm", "\u03bcs/cm",]: conversion_factor = 1.0 From 5b6a5b76681486556ffc036a9f866a64312a4e84 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 10:02:32 -0600 Subject: [PATCH 016/126] feat(conductivity): filter for conductivity for WQP records --- backend/connectors/wqp/source.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/connectors/wqp/source.py b/backend/connectors/wqp/source.py index ba53056..4adc95d 100644 --- a/backend/connectors/wqp/source.py +++ b/backend/connectors/wqp/source.py @@ -31,6 +31,7 @@ TDS, WATERLEVELS, SPECIFIC_CONDUCTANCE, + CONDUCTIVITY, USGS_PCODE_30210, USGS_PCODE_70300, USGS_PCODE_70301, @@ -183,6 +184,16 @@ def _clean_records(self, records): # nothing is being done these at the moment, but they are being logged in case they need to be inspected at a later date dirty_records.append((record["MonitoringLocationIdentifier"], record["ActivityStartDate"], record["ResultTemperatureBasisText"])) return return_records + elif self.config.parameter == CONDUCTIVITY and len(clean_records) > 0: + return_records = [] + dirty_records = [] + for record in clean_records: + if record["ResultTemperatureBasisText"].strip() not in ["25 deg C", "25 Deg C"]: + return_records.append(record) + else: + # nothing is being done these at the moment, but they are being logged in case they need to be inspected at a later date + dirty_records.append((record["MonitoringLocationIdentifier"], record["ActivityStartDate"], record["ResultTemperatureBasisText"])) + return return_records else: return clean_records From 401f1c9e6f32dd0bb2235349d96ece3dc9079fea Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 10:14:56 -0600 Subject: [PATCH 017/126] feat(setup): bump version to 0.10.0 for new parameters --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 55446be..fb987e1 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( name="nmuwd", - version="0.9.10", + version="0.10.0", author="Jake Ross", description="New Mexico Water Data Integration Engine", long_description=long_description, From b60e5aa44e76ef95087643e43d9dacf84f2d08df Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 7 Apr 2026 10:21:16 -0600 Subject: [PATCH 018/126] fix(github actions): use python 3.13 and setup-python@v5 --- .github/workflows/publish-to-pypi.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 699394f..9df9955 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -18,10 +18,10 @@ jobs: id-token: write steps: - uses: actions/checkout@master - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.13 - name: Install pypa/build run: >- From c3224025c98bda041603a907a967d2c31891175c Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 27 Apr 2026 13:32:41 -0600 Subject: [PATCH 019/126] fix(source): enable tag paramter for json requests to be Null Previously if the tag parameter was set to Null or was not provided it would default to "data". This caused issues for some endpoints that did not have a "data" key in their response or for which the full object was desired. --- backend/connectors/bor/source.py | 8 +++++--- backend/connectors/isc_seven_rivers/source.py | 6 ++++-- backend/source.py | 4 +--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/connectors/bor/source.py b/backend/connectors/bor/source.py index 5ad03e1..4a28ee2 100644 --- a/backend/connectors/bor/source.py +++ b/backend/connectors/bor/source.py @@ -57,7 +57,7 @@ def get_records(self): # locationTypeId 10 is for wells url = "https://data.usbr.gov/rise/api/location" params = {"stateId": "NM", "locationTypeId": 10} - return self._execute_json_request(url, params) + return self._execute_json_request(url, params, tag="data") def parse_dt(dt): @@ -119,13 +119,14 @@ def get_records(self, site_record): code = get_analyte_search_param(self.config.parameter, BOR_ANALYTE_MAPPING) catalog_record_data = self._execute_json_request( - f"https://data.usbr.gov{site_record.catalogRecords[0]['id']}" + f"https://data.usbr.gov{site_record.catalogRecords[0]['id']}", + tag="data" ) catalog_items = catalog_record_data["relationships"]["catalogItems"]["data"] for i, item in enumerate(self._reorder_catalog_items(catalog_items)): - data = self._execute_json_request(f'https://data.usbr.gov{item["id"]}') + data = self._execute_json_request(f'https://data.usbr.gov{item["id"]}', tag="data") if not data: continue @@ -142,6 +143,7 @@ def get_records(self, site_record): return self._execute_json_request( "https://data.usbr.gov/rise/api/result", params={"itemId": data["attributes"]["_id"]}, + tag="data" ) diff --git a/backend/connectors/isc_seven_rivers/source.py b/backend/connectors/isc_seven_rivers/source.py index 5679fad..691bbac 100644 --- a/backend/connectors/isc_seven_rivers/source.py +++ b/backend/connectors/isc_seven_rivers/source.py @@ -85,6 +85,7 @@ def health(self): def get_records(self): return self._execute_json_request( _make_url("getMonitoringPoints.ashx"), + tag="data", ) @@ -100,7 +101,7 @@ def _get_analyte_id_and_name(self, analyte): """ """ if self._analyte_ids is None: - resp = self._execute_json_request(_make_url("getAnalytes.ashx")) + resp = self._execute_json_request(_make_url("getAnalytes.ashx"), tag="data") if resp: self._analyte_ids = {r["name"]: r["id"] for r in resp} @@ -164,7 +165,7 @@ def get_records(self, site_record): self._source_parameter_name = analyte_id_and_name["name"] return self._execute_json_request( - _make_url("getReadings.ashx"), params=params + _make_url("getReadings.ashx"), params=params, tag="data" ) @@ -184,6 +185,7 @@ def get_records(self, site_record): return self._execute_json_request( _make_url("getWaterLevels.ashx"), params=params, + tag="data", ) def _clean_records(self, records): diff --git a/backend/source.py b/backend/source.py index 5189258..ff6160c 100644 --- a/backend/source.py +++ b/backend/source.py @@ -252,9 +252,7 @@ def _execute_json_request( the json response """ resp = httpx.get(url, params=params, **kw) - if tag is None: - tag = "data" - + if resp.status_code == 200: try: obj = resp.json() From ec9f6e31c35b80a7f71933986adf933f8f7d269a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 27 Apr 2026 13:33:44 -0600 Subject: [PATCH 020/126] fix(config): update config/false agencies for bicarbonate BoR does not report bicarbonate data --- backend/config.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/config.py b/backend/config.py index 2b4af34..17a7bee 100644 --- a/backend/config.py +++ b/backend/config.py @@ -281,6 +281,18 @@ def get_config_and_false_agencies(self): "nwis", "pvacd", ] + elif self.parameter in [BICARBONATE]: + config_agencies = ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"] + false_agencies = [ + "bor", + "bernco", + "cabq", + "ebid", + "nmose_roswell", + "nmose_pod", + "nwis", + "pvacd", + ] elif self.parameter in [ BICARBONATE, CALCIUM, From dcaf8d3bb4f56c26202ee597d9d0836d108928e1 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 27 Apr 2026 14:48:07 -0600 Subject: [PATCH 021/126] fix(USGS): use new USGS API endpoints for water level retrieval The USGS deprecated the old water level retrieval API endpoints, so this commit updates the NWISWaterLevelSource to use the new OGC API endpoints. Additionally, the chunk size for site retrieval is reduced to 5 to avoid URI length issues and httpx read timeouts. --- backend/connectors/usgs/source.py | 223 +++++++++++++++---------- backend/connectors/usgs/transformer.py | 32 ++-- backend/transformer.py | 1 + 3 files changed, 144 insertions(+), 112 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index cac4f2a..675d546 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime import httpx from backend.connectors import NM_STATE_BOUNDING_POLYGON @@ -42,55 +41,28 @@ get_terminal_record, ) +""" +-- sites -- +https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items? +state_code=35 +site_type_code=GW -def parse_rdb(text): - """' - Parses rdb tab-delimited responses for NWIS Site Services - """ - - def line_generator(): - header = None - for line in text.split("\n"): - if line.startswith("#"): - continue - elif line.startswith("agency_cd"): - header = [h.strip() for h in line.split("\t")] - continue - elif line.startswith("5s"): - continue - elif line == "": - continue +-- water levels -- +https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items? +monitoring_location_id=<>&monitoring_location_id=<>... - vals = [v.strip() for v in line.split("\t")] - if header and any(vals): - yield dict(zip(header, vals)) +parameter_code=72019 +""" - return list(line_generator()) +KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" - -def parse_json(data): +def parse_waterlevels_json(data): """ - Parses JSON responses for NWIS Groundwater Level Services + Parses JSON responses for USGS field measurements (water levels) into a list of records with standardized keys. """ records = [] - for location in data["timeSeries"]: - site_code = location["sourceInfo"]["siteCode"][0]["value"] - agency = location["sourceInfo"]["siteCode"][0]["agencyCode"] - source_parameter_name = location["variable"]["variableName"] - source_parameter_units = location["variable"]["unit"]["unitCode"] - for value in location["values"][0]["value"]: - record = { - "site_id": f"{agency}-{site_code}", - "source_parameter_name": source_parameter_name, - "value": value["value"], - "datetime_measured": value["dateTime"], - # "date_measured": value["dateTime"].split("T")[0], - # "time_measured": value["dateTime"].split("T")[1], - "source_parameter_units": source_parameter_units, - } - records.append(record) - return records + class NWISSiteSource(BaseSiteSource): @@ -107,80 +79,147 @@ def tag(self): def health(self): try: - self._execute_text_request( - "https://waterservices.usgs.gov/nwis/site/", - { - "format": "rdb", - "siteOutput": "expanded", - "siteType": "GW", - "site": "325754103461301", - }, + params = { + "state_code": "35", + "site_type_code": "GW" + } + self._execute_json_request( + url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items", + params=params ) return True except httpx.HTTPStatusError: pass def get_records(self): - params = {"format": "rdb", "siteOutput": "expanded", "siteType": "GW"} + params = { + "site_type_code": "GW", + "limit": self.chunk_size, + } config = self.config if config.has_bounds(): bbox = config.bbox_bounding_points() - params["bBox"] = ",".join([str(b) for b in bbox]) + params["bbox"] = ",".join([str(b) for b in bbox]) else: - params["stateCd"] = "NM" + params["state_code"] = "35" + + # if config.start_date: + # params["startDt"] = config.start_dt.date().isoformat() + # if config.end_date: + # params["endDt"] = config.end_dt.date().isoformat() + + reached_end: bool = False + records: list = [] + sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items" + + """ + TODO + + update the site transformer to transform into standardized format + """ + + while not reached_end: + response = self._execute_json_request( + url=sites_url, + params=params, + headers={"X-API-Key": KEY} + ) - if config.start_date: - params["startDt"] = config.start_dt.date().isoformat() - if config.end_date: - params["endDt"] = config.end_dt.date().isoformat() + records.extend(response.get("features", [])) - text = self._execute_text_request( - "https://waterservices.usgs.gov/nwis/site/", params - ) - if text: - records = parse_rdb(text) - self.log(f"Retrieved {len(records)} records") - return records + found_next_link: bool = False + for link in response["links"]: + if link["rel"] == "next": + sites_url = link["href"] + params = None # next link already has params encoded + found_next_link = True + break + + if not found_next_link : + reached_end = True + + return records + + +# TODO: IMPLEMENT! and transform as necessary. keep in mind "next" links for pagination class NWISWaterLevelSource(BaseWaterLevelSource): transformer_klass = NWISWaterLevelTransformer + # chunk_size=5 to avoid URI length and httpx read timed out issue + chunk_size = 5 def __repr__(self): return "NWISWaterLevelSource" def get_records(self, site_record): - # query sites with the agency, which need to be in the form of "{agency}:{site number}" - sites = make_site_list(site_record) - sites_with_colons = [s.replace("-", ":") for s in sites] + records: list = [] - params = { - "format": "json", - "siteType": "GW", - "siteStatus": "all", - "parameterCd": "72019", - "sites": ",".join(sites_with_colons), - } + # if more than 5 sites are provided the URI is too long + sites: list = make_site_list(site_record) - config = self.config - if config.start_date: - params["startDt"] = config.start_dt.date().isoformat() - else: - params["startDt"] = "1900-01-01" - - if config.end_date: - params["endDt"] = config.end_dt.date().isoformat() - - data = self._execute_json_request( - url="https://waterservices.usgs.gov/nwis/gwlevels/", - params=params, - tag="value", - ) - if data: - records = parse_json(data) - self.log(f"Retrieved {len(records)} records") - return records + # chunk the sites into groups of 5 to avoid URI length issues + chunks_of_sites: list = [] + for i in range(0, len(sites), self.chunk_size): + chunks_of_sites.append(sites[i:i + self.chunk_size]) + + + for chunked_sites in chunks_of_sites: + delineated_sites: str = ",".join(chunked_sites) + + obs_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items" + + reached_end: bool = False + + params: dict = { + "parameter_code": "72019", + "monitoring_location_id": delineated_sites, + "limit": 500, + } + + config = self.config + # if config.start_date: + # params["startDt"] = config.start_dt.date().isoformat() + # else: + # params["startDt"] = "1900-01-01" + + # if config.end_date: + # params["endDt"] = config.end_dt.date().isoformat() + + while not reached_end: + response = self._execute_json_request( + url=obs_url, + params=params, + headers={"X-API-Key": KEY} + ) + + data: list[dict] = response.get("features", []) + if data: + for feature in data: + record = { + "site_id": feature["properties"]["monitoring_location_id"], + "source_parameter_name": "Water level, depth LSD", + "value": feature["properties"]["value"], + "datetime_measured": feature["properties"]["time"], + "source_parameter_units": feature["properties"]["unit_of_measure"] + } + records.append(record) + + found_next_link: bool = False + for link in response["links"]: + if link["rel"] == "next": + obs_url = link["href"] + params = None # next link already has params encoded + found_next_link = True + break + + if not found_next_link : + reached_end = True + self.log(f"Retrieved {len(records)} records") + + + return records def _extract_site_records(self, records, site_record): return [ri for ri in records if ri["site_id"] == site_record.id] diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index 379b8bd..4060f9f 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -19,35 +19,27 @@ class NWISSiteTransformer(SiteTransformer): def _transform(self, record): - elevation = record["alt_va"] + elevation = record["properties"]["altitude"] try: elevation = float(elevation) except (ValueError, TypeError): elevation = None - lng = record["dec_long_va"] - lat = record["dec_lat_va"] - datum = record["coord_datum_cd"] - - # if not self.contained(lng, lat): - # return - - agency = record["agency_cd"] - site_no = record["site_no"] - site_id = f"{agency}-{site_no}" + # this data comes from OGC API, which requires the use of WGS84 for the horizontal datum + datum = "WGS84" rec = { - "source": "USGS-NWIS", - "id": site_id, - "name": record["station_nm"], - "latitude": lat, - "longitude": lng, + "source": "USGS", + "id": record["properties"]["id"], + "name": record["properties"]["monitoring_location_name"], + "latitude": record["geometry"]["coordinates"][1], + "longitude": record["geometry"]["coordinates"][0], "elevation": elevation, "elevation_units": "ft", - "horizontal_datum": datum, - "vertical_datum": record["alt_datum_cd"], - "aquifer": record["nat_aqfr_cd"], - "well_depth": record["well_depth_va"], + "horizontal_datum": "WGS84", + "vertical_datum": record["properties"]["vertical_datum"], + "aquifer": record["properties"]["national_aquifer_code"], + "well_depth": record["properties"]["well_constructed_depth"], "well_depth_units": "ft", } return rec diff --git a/backend/transformer.py b/backend/transformer.py index a42ca89..4c8cbb8 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -259,6 +259,7 @@ def standardize_datetime(dt, record_id): "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S+00:00", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S+00:00", "%Y-%m-%d %H:%M", From 036e9e7b1bb1c87df957a9f0d5e6adeadd4e91b3 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 28 Apr 2026 17:10:46 -0600 Subject: [PATCH 022/126] fix(usgs): update transformer for combined-metadata endpoint --- backend/connectors/usgs/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index 4060f9f..f0344f2 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -30,7 +30,7 @@ def _transform(self, record): rec = { "source": "USGS", - "id": record["properties"]["id"], + "id": record["properties"]["monitoring_location_id"], "name": record["properties"]["monitoring_location_name"], "latitude": record["geometry"]["coordinates"][1], "longitude": record["geometry"]["coordinates"][0], From 2425094f599436447c7e53d8de59a31dac0a0f02 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 28 Apr 2026 17:10:50 -0600 Subject: [PATCH 023/126] fix(usgs): update USGS water level retrieval for new APIs Use the new USGS APIs to retrieve water level data. --- backend/connectors/usgs/source.py | 270 ++++++++++++++++++------------ 1 file changed, 159 insertions(+), 111 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 675d546..049c6f2 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -43,9 +43,10 @@ """ -- sites -- -https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items? +https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items? state_code=35 site_type_code=GW +parameter_code=72019 -- water levels -- https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items? @@ -56,19 +57,117 @@ KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" -def parse_waterlevels_json(data): - """ - Parses JSON responses for USGS field measurements (water levels) into a list of records with standardized keys. +def transform_usgs_waterlevels_record(record: dict) -> dict: + return { + "site_id": record["properties"]["monitoring_location_id"], + "source_parameter_name": "Water level, depth LSD", + "value": record["properties"]["value"], + "datetime_measured": record["properties"]["time"], + "source_parameter_units": record["properties"]["unit_of_measure"] + } + +def retrieve_usgs_data( + url: str, + json_data: dict, + headers: dict = None, + params: dict = None, + timeout: int = None, + transformation_hook=None +) -> list: """ - records = [] + Start with a POST request to retrieve the initial batch of data using complex queries, then + follow the "next" links in the response to retrieve all paginated data with GET requests. + The transformation_hook can be used to transform each batch of records as they are retrieved + """ + records: list = [] + + response = httpx.post( + url=url, + json=json_data, + headers=headers, + params=params, + timeout=timeout, + ) + data: dict = response.json() + features: list[dict] = data.get("features", []) + + if transformation_hook: + transformed_features = [transformation_hook(feature) for feature in features] + records.extend(transformed_features) + else: + records.extend(features) + # print(f"Retrieved {len(records)} records") + + found_next_link: bool = False + links: list = data.get("links", []) + for link in links: + if link["rel"] == "next": + next_link_url = link["href"] + found_next_link = True + break + + # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids + # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more + while found_next_link: + # print(f"Following next link: {next_link_url}") + response = httpx.get( + url=next_link_url, + headers=headers, + timeout=timeout, + ) + data: dict = response.json() + features = data.get("features", []) + if transformation_hook: + transformed_features = [transformation_hook(feature) for feature in features] + records.extend(transformed_features) + else: + records.extend(features) + + # print(f"Retrieved {len(records)} records") + + found_next_link: bool = False + links: list = data.get("links", []) + for link in links: + if link["rel"] == "next": + next_link_url = link["href"] + found_next_link = True + break + + return records class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer chunk_size = 500 bounding_polygon = NM_STATE_BOUNDING_POLYGON + json_data: dict = { + "op": "and", + "args": [ + { + "op": "in", + "args": [ + {"property": "state_code"}, + ["35"] + ] + }, + { + "op": "in", + "args": [ + {"property": "site_type_code"}, + ["GW"] + ] + }, + { + "op": "in", + "args": [ + {"property": "parameter_code"}, + ["72019"] + ] + } + ] + } def __repr__(self): return "NWISSiteSource" @@ -79,145 +178,94 @@ def tag(self): def health(self): try: - params = { - "state_code": "35", - "site_type_code": "GW" - } - self._execute_json_request( - url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items", - params=params + httpx.post( + url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items", + data=self.json_data, + headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + timeout=None ) return True except httpx.HTTPStatusError: pass def get_records(self): - params = { - "site_type_code": "GW", - "limit": self.chunk_size, - } - config = self.config + # TODO: handle date filters + # config = self.config - if config.has_bounds(): - bbox = config.bbox_bounding_points() - params["bbox"] = ",".join([str(b) for b in bbox]) - else: - params["state_code"] = "35" + # if config.has_bounds(): + # bbox = config.bbox_bounding_points() + # params["bbox"] = ",".join([str(b) for b in bbox]) + # else: + # params["state_code"] = "35" # if config.start_date: # params["startDt"] = config.start_dt.date().isoformat() # if config.end_date: # params["endDt"] = config.end_dt.date().isoformat() + sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items" - reached_end: bool = False - records: list = [] - sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/monitoring-locations/items" - - """ - TODO - - update the site transformer to transform into standardized format - """ - - while not reached_end: - response = self._execute_json_request( - url=sites_url, - params=params, - headers={"X-API-Key": KEY} - ) - - records.extend(response.get("features", [])) + data = self._execute_json_request( + url=sites_url, + params={"limit": 50000, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + timeout=None, + headers={"X-API-Key": KEY}, + ) - found_next_link: bool = False - for link in response["links"]: - if link["rel"] == "next": - sites_url = link["href"] - params = None # next link already has params encoded - found_next_link = True - break - - if not found_next_link : - reached_end = True - + records: list = data.get("features", []) return records -# TODO: IMPLEMENT! and transform as necessary. keep in mind "next" links for pagination - class NWISWaterLevelSource(BaseWaterLevelSource): transformer_klass = NWISWaterLevelTransformer - # chunk_size=5 to avoid URI length and httpx read timed out issue - chunk_size = 5 + # USGS complex queries allow up to 250 sites to be queried at once + # https://api.waterdata.usgs.gov/docs/ogcapi/complex-queries + num_sites = 250 def __repr__(self): return "NWISWaterLevelSource" def get_records(self, site_record): - records: list = [] - - # if more than 5 sites are provided the URI is too long - sites: list = make_site_list(site_record) + # TODO: handle date filters + # config = self.config + # if config.start_date: + # params["startDt"] = config.start_dt.date().isoformat() + # else: + # params["startDt"] = "1900-01-01" - # chunk the sites into groups of 5 to avoid URI length issues - chunks_of_sites: list = [] - for i in range(0, len(sites), self.chunk_size): - chunks_of_sites.append(sites[i:i + self.chunk_size]) + # if config.end_date: + # params["endDt"] = config.end_dt.date().isoformat() + records: list = [] + sites: list = make_site_list(site_record) - for chunked_sites in chunks_of_sites: - delineated_sites: str = ",".join(chunked_sites) - - obs_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items" + # group sites into batches of num_sites to pass to the API + # since USGS APIs allow up to 250 sites to be queried at once with complex queries + list_of_lists_of_sites: list = [] + for i in range(0, len(sites), self.num_sites): + list_of_lists_of_sites.append(sites[i:i + self.num_sites]) - reached_end: bool = False - params: dict = { - "parameter_code": "72019", - "monitoring_location_id": delineated_sites, - "limit": 500, + for list_of_sites in list_of_lists_of_sites: + json_data: dict = { + "op": "in", + "args": [ + {"property": "monitoring_location_id"}, + list_of_sites + ] } - config = self.config - # if config.start_date: - # params["startDt"] = config.start_dt.date().isoformat() - # else: - # params["startDt"] = "1900-01-01" - - # if config.end_date: - # params["endDt"] = config.end_dt.date().isoformat() - - while not reached_end: - response = self._execute_json_request( - url=obs_url, - params=params, - headers={"X-API-Key": KEY} - ) - - data: list[dict] = response.get("features", []) - if data: - for feature in data: - record = { - "site_id": feature["properties"]["monitoring_location_id"], - "source_parameter_name": "Water level, depth LSD", - "value": feature["properties"]["value"], - "datetime_measured": feature["properties"]["time"], - "source_parameter_units": feature["properties"]["unit_of_measure"] - } - records.append(record) - - found_next_link: bool = False - for link in response["links"]: - if link["rel"] == "next": - obs_url = link["href"] - params = None # next link already has params encoded - found_next_link = True - break - - if not found_next_link : - reached_end = True - self.log(f"Retrieved {len(records)} records") + records_batch: list = retrieve_usgs_data( + url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", + json_data=json_data, + headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + params={"limit": 50000, "parameter_code": "72019"}, + timeout=None, + transformation_hook=transform_usgs_waterlevels_record + ) + records.extend(records_batch) + self.log(f"Retrieved {len(records)} records") return records From 500a0b136dd3b6fa0994d59925cb1d2a05c41b82 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 10:41:45 -0600 Subject: [PATCH 024/126] fix(usgs): set limit to 50000 to avoid pagination/rate limits --- backend/connectors/usgs/source.py | 183 +++++++++--------------------- 1 file changed, 56 insertions(+), 127 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 049c6f2..da28158 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -41,133 +41,14 @@ get_terminal_record, ) -""" --- sites -- -https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items? -state_code=35 -site_type_code=GW -parameter_code=72019 - --- water levels -- -https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items? -monitoring_location_id=<>&monitoring_location_id=<>... - -parameter_code=72019 -""" KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" - -def transform_usgs_waterlevels_record(record: dict) -> dict: - return { - "site_id": record["properties"]["monitoring_location_id"], - "source_parameter_name": "Water level, depth LSD", - "value": record["properties"]["value"], - "datetime_measured": record["properties"]["time"], - "source_parameter_units": record["properties"]["unit_of_measure"] - } - -def retrieve_usgs_data( - url: str, - json_data: dict, - headers: dict = None, - params: dict = None, - timeout: int = None, - transformation_hook=None -) -> list: - """ - Start with a POST request to retrieve the initial batch of data using complex queries, then - follow the "next" links in the response to retrieve all paginated data with GET requests. - - The transformation_hook can be used to transform each batch of records as they are retrieved - """ - records: list = [] - - response = httpx.post( - url=url, - json=json_data, - headers=headers, - params=params, - timeout=timeout, - ) - data: dict = response.json() - features: list[dict] = data.get("features", []) - - if transformation_hook: - transformed_features = [transformation_hook(feature) for feature in features] - records.extend(transformed_features) - else: - records.extend(features) - - # print(f"Retrieved {len(records)} records") - - found_next_link: bool = False - links: list = data.get("links", []) - for link in links: - if link["rel"] == "next": - next_link_url = link["href"] - found_next_link = True - break - - # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids - # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more - while found_next_link: - # print(f"Following next link: {next_link_url}") - response = httpx.get( - url=next_link_url, - headers=headers, - timeout=timeout, - ) - data: dict = response.json() - features = data.get("features", []) - if transformation_hook: - transformed_features = [transformation_hook(feature) for feature in features] - records.extend(transformed_features) - else: - records.extend(features) - - # print(f"Retrieved {len(records)} records") - - found_next_link: bool = False - links: list = data.get("links", []) - for link in links: - if link["rel"] == "next": - next_link_url = link["href"] - found_next_link = True - break - - return records - +LIMIT = 50000 class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer chunk_size = 500 bounding_polygon = NM_STATE_BOUNDING_POLYGON - json_data: dict = { - "op": "and", - "args": [ - { - "op": "in", - "args": [ - {"property": "state_code"}, - ["35"] - ] - }, - { - "op": "in", - "args": [ - {"property": "site_type_code"}, - ["GW"] - ] - }, - { - "op": "in", - "args": [ - {"property": "parameter_code"}, - ["72019"] - ] - } - ] - } def __repr__(self): return "NWISSiteSource" @@ -206,7 +87,7 @@ def get_records(self): data = self._execute_json_request( url=sites_url, - params={"limit": 50000, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=None, headers={"X-API-Key": KEY}, ) @@ -240,7 +121,7 @@ def get_records(self, site_record): sites: list = make_site_list(site_record) # group sites into batches of num_sites to pass to the API - # since USGS APIs allow up to 250 sites to be queried at once with complex queries + # USGS APIs allow up to 250 sites to be queried at once with complex queries list_of_lists_of_sites: list = [] for i in range(0, len(sites), self.num_sites): list_of_lists_of_sites.append(sites[i:i + self.num_sites]) @@ -255,19 +136,67 @@ def get_records(self, site_record): ] } - records_batch: list = retrieve_usgs_data( + response = httpx.post( url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", - json_data=json_data, + json=json_data, headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, - params={"limit": 50000, "parameter_code": "72019"}, + params={"limit": LIMIT, "parameter_code": "72019"}, timeout=None, - transformation_hook=transform_usgs_waterlevels_record ) - records.extend(records_batch) + data: dict = response.json() + features: list[dict] = data.get("features", []) + + standard_features: list[dict] = [self._standardize_record(feature) for feature in features] + records.extend(standard_features) + + """ + The following commented-out code handles pagination for cases where there are more than LIMIT records for a given batch of sites. + However, in testing, I have not encountered any cases where this is necessary. Furthermore, cursor-based pagination is broken as + of 4/29/26 when the limit query parameter is used, and it can't be used in combination with other parameters via complex queries. + If we do encounter cases where there are more than LIMIT records, we can use the following code to handle pagination (when it is fixed). + + found_next_link: bool = False + links: list[dict] = data.get("links", []) + for link in links: + if link["rel"] == "next": + next_link_url = link["href"] + found_next_link = True + break + + # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids + # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more + while found_next_link: + response = httpx.get( + url=next_link_url, + headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + timeout=None, + ) + data: dict = response.json() + features: list[dict] = data.get("features", []) + standard_features: list[dict] = [self._standardize_record(feature) for feature in features] + records.extend(standard_features) + + found_next_link: bool = False + links: list = data.get("links", []) + for link in links: + if link["rel"] == "next": + next_link_url = link["href"] + found_next_link = True + break + """ self.log(f"Retrieved {len(records)} records") return records + + def _standardize_record(self, record: dict) -> dict: + return { + "site_id": record["properties"]["monitoring_location_id"], + "source_parameter_name": "Water level, depth LSD", + "value": record["properties"]["value"], + "datetime_measured": record["properties"]["time"], + "source_parameter_units": record["properties"]["unit_of_measure"] + } def _extract_site_records(self, records, site_record): return [ri for ri in records if ri["site_id"] == site_record.id] From d93869d6584778e6ca7588cb43dbd79e16341ed7 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 11:09:12 -0600 Subject: [PATCH 025/126] refactor(usgs): check usgs health with get request --- backend/connectors/usgs/source.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index da28158..9f2f6ed 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -49,6 +49,7 @@ class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer chunk_size = 500 bounding_polygon = NM_STATE_BOUNDING_POLYGON + sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items" def __repr__(self): return "NWISSiteSource" @@ -59,12 +60,12 @@ def tag(self): def health(self): try: - httpx.post( - url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items", - data=self.json_data, - headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, - timeout=None - ) + data = self._execute_json_request( + url=self.sites_url, + params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + timeout=None, + headers={"X-API-Key": KEY}, + ) return True except httpx.HTTPStatusError: pass @@ -83,10 +84,9 @@ def get_records(self): # params["startDt"] = config.start_dt.date().isoformat() # if config.end_date: # params["endDt"] = config.end_dt.date().isoformat() - sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items" data = self._execute_json_request( - url=sites_url, + url=self.sites_url, params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=None, headers={"X-API-Key": KEY}, From 12247f52e69f1da48bba081a6cfdb02ab525346d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 11:14:02 -0600 Subject: [PATCH 026/126] fix(config): set agencies for bicarbonate --- backend/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/config.py b/backend/config.py index 17a7bee..d45e95d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -294,7 +294,6 @@ def get_config_and_false_agencies(self): "pvacd", ] elif self.parameter in [ - BICARBONATE, CALCIUM, CHLORIDE, FLUORIDE, From 4eaac1c86064f418880f13be1fb954a57881fdb2 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:20:02 -0600 Subject: [PATCH 027/126] fix(nmbgmr): set timeout to 30 minutes and retry failures --- backend/connectors/nmbgmr/source.py | 66 +++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 45e0a16..3bcbe75 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -44,6 +44,11 @@ make_site_list, ) +""" +Set timeout to 30 minutes for analyte and water level requests since some sites have a large number of records and the NMBGMR API can be slow to respond. +Don't use timeout=None since that can cause the request to hang indefinitely if there are issues with the API. Instead, catch timeout exceptions and retry the request until it succeeds or a different exception is raised. +""" +TIMEOUT=1800 def _make_url(endpoint): if os.getenv("DEBUG") == "1": @@ -83,9 +88,16 @@ def get_records(self): params["parameter"] = "Manual groundwater levels" # tags="features" because the response object is a GeoJSON - sites = self._execute_json_request( - _make_url("locations"), params, tag="features", timeout=30 - ) + request_finished: bool = False + while not request_finished: + try: + sites = self._execute_json_request( + _make_url("locations"), params, tag="features", timeout=TIMEOUT + ) + request_finished = True + except Exception as e: + self.warning(f"Error retrieving site data: {e}. Retrying...") + if not config.sites_only: for site in sites: if get_bool_env_variable("IS_TESTING_ENV"): @@ -119,14 +131,23 @@ def get_records(self, site_record): analyte = get_analyte_search_param( self.config.parameter, NMBGMR_ANALYTE_MAPPING ) - records = self._execute_json_request( - _make_url("waterchemistry"), - params={ - "pointid": ",".join(make_site_list(site_record)), - "analyte": analyte, - }, - tag="", - ) + + request_finished: bool = False + + while not request_finished: + try: + records = self._execute_json_request( + _make_url("waterchemistry"), + params={ + "pointid": ",".join(make_site_list(site_record)), + "analyte": analyte, + }, + tag="", + timeout=TIMEOUT + ) + request_finished = True + except Exception as e: + self.warning(f"Error retrieving analyte data: {e}. Retrying...") records_sorted_by_pointid = {} for pointid in records.keys(): records_sorted_by_pointid[pointid] = records[pointid][analyte] @@ -221,10 +242,17 @@ def get_records(self, site_record): # url = _make_url("waterlevels/latest") # else: params = {"pointid": ",".join(make_site_list(site_record))} + print(make_site_list(site_record)) # just use manual waterlevels temporarily url = _make_url("waterlevels/manual") - paginated_records = self._execute_json_request(url, params, tag="") + request_finished: bool = False + while not request_finished: + try: + paginated_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) + request_finished = True + except Exception as e: + self.warning(f"Error retrieving water level data: {e}. Retrying...") items = paginated_records["items"] page = paginated_records["page"] pages = paginated_records["pages"] @@ -232,7 +260,19 @@ def get_records(self, site_record): while page < pages: page += 1 params["page"] = page - new_records = self._execute_json_request(url, params, tag="") + + request_finished: bool = False + while not request_finished: + try: + new_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) + # status_code != 200 makes _execute_json_request return None, so check for that and retry if it happens + if new_records is None: + self.warning("Retrying...") + continue + request_finished = True + except Exception as e: + self.warning(f"Error retrieving page {page} of water level data: {e}. Retrying...") + items.extend(new_records["items"]) pages = new_records["pages"] From b34292207f123aaf68143f849435ff1a8f25d9cb Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:23:58 -0600 Subject: [PATCH 028/126] fix(usgs): add timeout to usgs api calls and retry logic if errors --- backend/connectors/usgs/source.py | 46 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 9f2f6ed..ecb0224 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -44,6 +44,7 @@ KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" LIMIT = 50000 +TIMEOUT=1800 class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer @@ -85,12 +86,22 @@ def get_records(self): # if config.end_date: # params["endDt"] = config.end_dt.date().isoformat() - data = self._execute_json_request( - url=self.sites_url, - params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, - timeout=None, - headers={"X-API-Key": KEY}, - ) + finished_request: bool = False + while not finished_request: + try: + data = self._execute_json_request( + url=self.sites_url, + params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + timeout=TIMEOUT, + headers={"X-API-Key": KEY}, + ) + # _execute_json_request returns None for non-200 responses, so we need to check for that as well + if data is None: + self.warning("Retrying...") + else: + finished_request = True + except Exception as e: + self.warning(f"Error retrieving site records: {e}. Retrying...") records: list = data.get("features", []) @@ -135,14 +146,23 @@ def get_records(self, site_record): list_of_sites ] } + finished_request: bool = False + while not finished_request: + try: + response = httpx.post( + url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", + json=json_data, + headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + params={"limit": LIMIT, "parameter_code": "72019"}, + timeout=None, + ) + if response.status_code != 200: + self.warning(f"Received status code {response.status_code} for sites {list_of_sites}. Retrying...") + else: + finished_request = True + except Exception as e: + self.warning(f"Error retrieving water level records: {e}. Retrying...") - response = httpx.post( - url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", - json=json_data, - headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, - params={"limit": LIMIT, "parameter_code": "72019"}, - timeout=None, - ) data: dict = response.json() features: list[dict] = data.get("features", []) From 3f2d104cb35cf154f91f986b38a7b62537e729f3 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:25:32 -0600 Subject: [PATCH 029/126] fix(usgs): add retry logic to pagination retrieval --- backend/connectors/usgs/source.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index ecb0224..6b850ae 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -154,7 +154,7 @@ def get_records(self, site_record): json=json_data, headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, params={"limit": LIMIT, "parameter_code": "72019"}, - timeout=None, + timeout=TIMEOUT, ) if response.status_code != 200: self.warning(f"Received status code {response.status_code} for sites {list_of_sites}. Retrying...") @@ -186,11 +186,21 @@ def get_records(self, site_record): # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more while found_next_link: - response = httpx.get( - url=next_link_url, - headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, - timeout=None, - ) + finished_request: bool = False + while not finished_request: + try: + response = httpx.get( + url=next_link_url, + headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + timeout=TIMEOUT, + ) + if response.status_code != 200: + self.warning(f"Received status code {response.status_code} for paginated request. Retrying...") + else: + finished_request = True + except Exception as e: + self.warning(f"Error retrieving paginated water level records: {e}. Retrying... + data: dict = response.json() features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] From 6005f70de6c95db366f0e2f220bee0d5ea0b62ef Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:26:34 -0600 Subject: [PATCH 030/126] refactor(usgs): set datum in site transformer --- backend/connectors/usgs/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index f0344f2..bfffd4c 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -36,7 +36,7 @@ def _transform(self, record): "longitude": record["geometry"]["coordinates"][0], "elevation": elevation, "elevation_units": "ft", - "horizontal_datum": "WGS84", + "horizontal_datum": datum, "vertical_datum": record["properties"]["vertical_datum"], "aquifer": record["properties"]["national_aquifer_code"], "well_depth": record["properties"]["well_constructed_depth"], From dba8213abbb41ec1b515a30ccce46b07668398d6 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:29:33 -0600 Subject: [PATCH 031/126] fix(usgs): return False if health check fails --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 6b850ae..7cf5e72 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -69,7 +69,7 @@ def health(self): ) return True except httpx.HTTPStatusError: - pass + return False def get_records(self): # TODO: handle date filters From b17bbde11585d3ea976cb098c4646098b43f661f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:31:37 -0600 Subject: [PATCH 032/126] fix(usgs): shorten error message --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 7cf5e72..957afd0 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -157,7 +157,7 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code != 200: - self.warning(f"Received status code {response.status_code} for sites {list_of_sites}. Retrying...") + self.warning(f"Received status code {response.status_code}. Retrying...") else: finished_request = True except Exception as e: From 5570c36e9a1a8694485baea6f4a7fa3417ea11df Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:34:22 -0600 Subject: [PATCH 033/126] fix(usgs): set limit of 1 for health check --- backend/connectors/usgs/source.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 957afd0..1084e33 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -61,12 +61,12 @@ def tag(self): def health(self): try: - data = self._execute_json_request( - url=self.sites_url, - params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, - timeout=None, - headers={"X-API-Key": KEY}, - ) + self._execute_json_request( + url=self.sites_url, + params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + timeout=TIMEOUT, + headers={"X-API-Key": KEY}, + ) return True except httpx.HTTPStatusError: return False From 836243e109e19896dfade0225fab10281d9feb40 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:35:12 -0600 Subject: [PATCH 034/126] fix(usgs): remove unused imports --- backend/connectors/usgs/source.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 1084e33..9863e92 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -17,24 +17,19 @@ from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( - FEET, DTW, - DTW_UNITS, DT_MEASURED, PARAMETER_NAME, PARAMETER_VALUE, PARAMETER_UNITS, SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, - EARLIEST, - LATEST, ) from backend.connectors.usgs.transformer import ( NWISSiteTransformer, NWISWaterLevelTransformer, ) from backend.source import ( - BaseSource, BaseWaterLevelSource, BaseSiteSource, make_site_list, From 4fd3ac67c2bf1f43e0124ab9dfd2519f29718fa7 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:42:11 -0600 Subject: [PATCH 035/126] fix(nmbgmr): retry when status_code!=200 --- backend/connectors/nmbgmr/source.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 3bcbe75..42ef9d4 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -94,6 +94,9 @@ def get_records(self): sites = self._execute_json_request( _make_url("locations"), params, tag="features", timeout=TIMEOUT ) + if sites is None: + self.warning("Retrying...") + continue request_finished = True except Exception as e: self.warning(f"Error retrieving site data: {e}. Retrying...") @@ -145,6 +148,9 @@ def get_records(self, site_record): tag="", timeout=TIMEOUT ) + if records is None: + self.warning("Retrying...") + continue request_finished = True except Exception as e: self.warning(f"Error retrieving analyte data: {e}. Retrying...") @@ -250,6 +256,9 @@ def get_records(self, site_record): while not request_finished: try: paginated_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) + if paginated_records is None: + self.warning("Retrying...") + continue request_finished = True except Exception as e: self.warning(f"Error retrieving water level data: {e}. Retrying...") From 1fcede39cad4116ed2adac4e409be4bc371a1a52 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 12:58:19 -0600 Subject: [PATCH 036/126] fix(nmbgmr): remove print debugging statement --- backend/connectors/nmbgmr/source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 42ef9d4..8dbe702 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -248,7 +248,6 @@ def get_records(self, site_record): # url = _make_url("waterlevels/latest") # else: params = {"pointid": ",".join(make_site_list(site_record))} - print(make_site_list(site_record)) # just use manual waterlevels temporarily url = _make_url("waterlevels/manual") From c1771d7b8afc3a1a9e2d335b26880498e56fc5a3 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 13:22:12 -0600 Subject: [PATCH 037/126] fix(nmbgmr): call self.warn not warning --- backend/connectors/nmbgmr/source.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 8dbe702..ab4c1fc 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -95,11 +95,11 @@ def get_records(self): _make_url("locations"), params, tag="features", timeout=TIMEOUT ) if sites is None: - self.warning("Retrying...") + self.warn("Retrying...") continue request_finished = True except Exception as e: - self.warning(f"Error retrieving site data: {e}. Retrying...") + self.warn(f"Error retrieving site data: {e}. Retrying...") if not config.sites_only: for site in sites: @@ -149,11 +149,11 @@ def get_records(self, site_record): timeout=TIMEOUT ) if records is None: - self.warning("Retrying...") + self.warn("Retrying...") continue request_finished = True except Exception as e: - self.warning(f"Error retrieving analyte data: {e}. Retrying...") + self.warn(f"Error retrieving analyte data: {e}. Retrying...") records_sorted_by_pointid = {} for pointid in records.keys(): records_sorted_by_pointid[pointid] = records[pointid][analyte] @@ -256,11 +256,11 @@ def get_records(self, site_record): try: paginated_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) if paginated_records is None: - self.warning("Retrying...") + self.warn("Retrying...") continue request_finished = True except Exception as e: - self.warning(f"Error retrieving water level data: {e}. Retrying...") + self.warn(f"Error retrieving water level data: {e}. Retrying...") items = paginated_records["items"] page = paginated_records["page"] pages = paginated_records["pages"] @@ -275,11 +275,11 @@ def get_records(self, site_record): new_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) # status_code != 200 makes _execute_json_request return None, so check for that and retry if it happens if new_records is None: - self.warning("Retrying...") + self.warn("Retrying...") continue request_finished = True except Exception as e: - self.warning(f"Error retrieving page {page} of water level data: {e}. Retrying...") + self.warn(f"Error retrieving page {page} of water level data: {e}. Retrying...") items.extend(new_records["items"]) pages = new_records["pages"] From a51b13e1c900214d783ec9114654fd5ffe55cfa9 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 13:22:57 -0600 Subject: [PATCH 038/126] fix(usgs): call self.warn not warning --- backend/connectors/usgs/source.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 9863e92..7963f69 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -92,11 +92,11 @@ def get_records(self): ) # _execute_json_request returns None for non-200 responses, so we need to check for that as well if data is None: - self.warning("Retrying...") + self.warn("Retrying...") else: finished_request = True except Exception as e: - self.warning(f"Error retrieving site records: {e}. Retrying...") + self.warn(f"Error retrieving site records: {e}. Retrying...") records: list = data.get("features", []) @@ -152,11 +152,11 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code != 200: - self.warning(f"Received status code {response.status_code}. Retrying...") + self.warn(f"Received status code {response.status_code}. Retrying...") else: finished_request = True except Exception as e: - self.warning(f"Error retrieving water level records: {e}. Retrying...") + self.warn(f"Error retrieving water level records: {e}. Retrying...") data: dict = response.json() features: list[dict] = data.get("features", []) @@ -190,11 +190,11 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code != 200: - self.warning(f"Received status code {response.status_code} for paginated request. Retrying...") + self.warn(f"Received status code {response.status_code} for paginated request. Retrying...") else: finished_request = True except Exception as e: - self.warning(f"Error retrieving paginated water level records: {e}. Retrying... + self.warn(f"Error retrieving paginated water level records: {e}. Retrying... data: dict = response.json() features: list[dict] = data.get("features", []) From 43e24571fa55ed46960e6dc88abf57d18ea7ed21 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 15:28:45 -0600 Subject: [PATCH 039/126] refactor(nmbgmr): change note style at top of doc --- backend/connectors/nmbgmr/source.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index ab4c1fc..d30d3a2 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -44,10 +44,11 @@ make_site_list, ) -""" -Set timeout to 30 minutes for analyte and water level requests since some sites have a large number of records and the NMBGMR API can be slow to respond. -Don't use timeout=None since that can cause the request to hang indefinitely if there are issues with the API. Instead, catch timeout exceptions and retry the request until it succeeds or a different exception is raised. -""" + +# Set timeout to 30 minutes for analyte and water level requests since some sites have a large number of records and the NMBGMR API can be slow to respond. +# Don't use timeout=None since that can cause the request to hang indefinitely if there are issues with the API. +# Instead, catch timeout exceptions and retry the request until it succeeds or a different exception is raised. + TIMEOUT=1800 def _make_url(endpoint): From c17e806ff91778729a6a2740ece1c53b5d0e1082 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 16:00:43 -0600 Subject: [PATCH 040/126] refactor(retries): move rety logic to BaseSource this prevents code duplication and allows all sources to benefit from retry logic when making requests to external APIs. --- backend/connectors/nmbgmr/source.py | 69 ++++++------------------ backend/source.py | 82 ++++++++++++++++++++--------- 2 files changed, 72 insertions(+), 79 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index d30d3a2..765a893 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -88,19 +88,9 @@ def get_records(self): else: params["parameter"] = "Manual groundwater levels" - # tags="features" because the response object is a GeoJSON - request_finished: bool = False - while not request_finished: - try: - sites = self._execute_json_request( - _make_url("locations"), params, tag="features", timeout=TIMEOUT - ) - if sites is None: - self.warn("Retrying...") - continue - request_finished = True - except Exception as e: - self.warn(f"Error retrieving site data: {e}. Retrying...") + sites = self._execute_json_request( + _make_url("locations"), params, tag="features", timeout=TIMEOUT + ) if not config.sites_only: for site in sites: @@ -136,25 +126,16 @@ def get_records(self, site_record): self.config.parameter, NMBGMR_ANALYTE_MAPPING ) - request_finished: bool = False - - while not request_finished: - try: - records = self._execute_json_request( - _make_url("waterchemistry"), - params={ - "pointid": ",".join(make_site_list(site_record)), - "analyte": analyte, - }, - tag="", - timeout=TIMEOUT - ) - if records is None: - self.warn("Retrying...") - continue - request_finished = True - except Exception as e: - self.warn(f"Error retrieving analyte data: {e}. Retrying...") + records = self._execute_json_request( + _make_url("waterchemistry"), + params={ + "pointid": ",".join(make_site_list(site_record)), + "analyte": analyte, + }, + tag="", + timeout=TIMEOUT + ) + records_sorted_by_pointid = {} for pointid in records.keys(): records_sorted_by_pointid[pointid] = records[pointid][analyte] @@ -252,16 +233,8 @@ def get_records(self, site_record): # just use manual waterlevels temporarily url = _make_url("waterlevels/manual") - request_finished: bool = False - while not request_finished: - try: - paginated_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) - if paginated_records is None: - self.warn("Retrying...") - continue - request_finished = True - except Exception as e: - self.warn(f"Error retrieving water level data: {e}. Retrying...") + paginated_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) + items = paginated_records["items"] page = paginated_records["page"] pages = paginated_records["pages"] @@ -270,17 +243,7 @@ def get_records(self, site_record): page += 1 params["page"] = page - request_finished: bool = False - while not request_finished: - try: - new_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) - # status_code != 200 makes _execute_json_request return None, so check for that and retry if it happens - if new_records is None: - self.warn("Retrying...") - continue - request_finished = True - except Exception as e: - self.warn(f"Error retrieving page {page} of water level data: {e}. Retrying...") + new_records = self._execute_json_request(url, params, tag="", timeout=TIMEOUT) items.extend(new_records["items"]) pages = new_records["pages"] diff --git a/backend/source.py b/backend/source.py index 5189258..cafb447 100644 --- a/backend/source.py +++ b/backend/source.py @@ -19,6 +19,7 @@ import shapely.wkt from shapely import MultiPoint from typing import Union, List, Callable, Dict +import time from backend.constants import ( FEET, @@ -200,7 +201,7 @@ def discover(self, *args, **kw): # Methods Already Implemented # ========================================================================== - def _execute_text_request(self, url: str, params: dict | None = None, **kw) -> str: + def _execute_text_request(self, url: str, params: dict | None = None, max_tries: int = 7, **kw) -> str: """ Executes a get request to the provided url and returns the text response. @@ -212,6 +213,9 @@ def _execute_text_request(self, url: str, params: dict | None = None, **kw) -> s params : dict key-value query parameters to pass to the get request + max_tries : int + the maximum number of times to retry the request if it fails + Returns ------- str @@ -220,17 +224,32 @@ def _execute_text_request(self, url: str, params: dict | None = None, **kw) -> s if "timeout" not in kw: kw["timeout"] = 10 - resp = httpx.get(url, params=params, **kw) - if resp.status_code == 200: - return resp.text - else: - self.warn(f"service url {resp.url}") - self.warn(f"service responded with status {resp.status_code}") - self.warn(f"service responded with text {resp.text}") + tries: int = 0 + + while tries < max_tries: + try: + resp = httpx.get(url, params=params, **kw) + if resp.status_code == 200: + return resp.text + else: + self.warn(f"service responded with status {resp.status_code}") + self.warn(f"service responded with text {resp.text}") + self.warn(f"Retrying... {tries+1}/{max_tries}") + except Exception as e: + self.warn(f"Error during request: {e}") + self.warn(f"Retrying... {tries+1}/{max_tries}") + tries += 1 + time.sleep(tries) + return "" def _execute_json_request( - self, url: str, params: dict | None = None, tag: str | None = None, **kw + self, + url: str, + params: dict | None = None, + tag: str | None = None, + max_retries: int = 7, + **kw ) -> dict | None: """ Executes a get request to the provided url and returns the json response. @@ -245,30 +264,41 @@ def _execute_json_request( tag : str the key to extract from the json response if required + + max_retries : int + the maximum number of times to retry the request if it fails Returns ------- dict the json response """ - resp = httpx.get(url, params=params, **kw) - if tag is None: - tag = "data" - - if resp.status_code == 200: + tries: int = 0 + while tries < max_retries: try: - obj = resp.json() - if tag and isinstance(obj, dict): - return obj[tag] - return obj - except JSONDecodeError: - self.warn(f"service responded but with no data. \n{resp.text}") - return None - else: - self.warn(f"service responded with status {resp.status_code}") - self.warn(f"service responded with text {resp.text}") - self.warn(f"service at url: {resp.url}") - return None + resp = httpx.get(url, params=params, **kw) + if tag is None: + tag = "data" + + if resp.status_code == 200: + try: + obj = resp.json() + if tag and isinstance(obj, dict): + return obj[tag] + return obj + except JSONDecodeError: + self.warn(f"service responded but with no data. \n{resp.text}") + return None + else: + self.warn(f"service responded with status {resp.status_code}") + self.warn(f"service responded with text {resp.text}") + self.warn(f"Retrying... {tries+1}/{max_retries}") + except Exception as e: + self.warn(f"Error during request: {e}") + self.warn(f"Retrying... {tries+1}/{max_retries}") + tries += 1 + time.sleep(tries) + return None # ========================================================================== # Methods Implemented in BaseSiteSource and BaseParameterSource From 4a64c6ffd395fc6328d8db5e74d220a4fe691811 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 29 Apr 2026 16:01:27 -0600 Subject: [PATCH 041/126] refactor(nmbgmr): change timeout to 15 minutes --- backend/connectors/nmbgmr/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 765a893..023aea7 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -49,7 +49,7 @@ # Don't use timeout=None since that can cause the request to hang indefinitely if there are issues with the API. # Instead, catch timeout exceptions and retry the request until it succeeds or a different exception is raised. -TIMEOUT=1800 +TIMEOUT=15*60 def _make_url(endpoint): if os.getenv("DEBUG") == "1": From 0e961a4fa8831066b63a9dfdd5dce8ce5e43d2f5 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 08:29:21 -0600 Subject: [PATCH 042/126] fix(nmbgmr): update developers note --- backend/connectors/nmbgmr/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 023aea7..1050d36 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -45,9 +45,9 @@ ) -# Set timeout to 30 minutes for analyte and water level requests since some sites have a large number of records and the NMBGMR API can be slow to respond. +# Set timeout to 15 minutes for analyte and water level requests since some sites have a large number of records and the NMBGMR API can be slow to respond. # Don't use timeout=None since that can cause the request to hang indefinitely if there are issues with the API. -# Instead, catch timeout exceptions and retry the request until it succeeds or a different exception is raised. +# Instead, catch timeout and other exceptions and retry the request up to 7 times with a delay between retries. TIMEOUT=15*60 From 8dd54d71f21f7878e57418882e363a00ffcb6910 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 08:30:46 -0600 Subject: [PATCH 043/126] fix(source): return null string outside of while loop --- backend/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/source.py b/backend/source.py index cafb447..dd89c19 100644 --- a/backend/source.py +++ b/backend/source.py @@ -241,7 +241,7 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: tries += 1 time.sleep(tries) - return "" + return "" def _execute_json_request( self, From 32b6b2542f0a8a0344bf885db19aff2faa2a2dda Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 08:32:30 -0600 Subject: [PATCH 044/126] fix(source): add URL to error message and log --- backend/source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/source.py b/backend/source.py index dd89c19..1309343 100644 --- a/backend/source.py +++ b/backend/source.py @@ -234,6 +234,7 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: else: self.warn(f"service responded with status {resp.status_code}") self.warn(f"service responded with text {resp.text}") + self.warn(f"URL: {url}") self.warn(f"Retrying... {tries+1}/{max_tries}") except Exception as e: self.warn(f"Error during request: {e}") @@ -288,10 +289,12 @@ def _execute_json_request( return obj except JSONDecodeError: self.warn(f"service responded but with no data. \n{resp.text}") + self.warn(f"URL: {url}") return None else: self.warn(f"service responded with status {resp.status_code}") - self.warn(f"service responded with text {resp.text}") + self.warn(f"service responded with text {resp.text} for url {resp.url}") + self.warn(f"URL: {url}") self.warn(f"Retrying... {tries+1}/{max_retries}") except Exception as e: self.warn(f"Error during request: {e}") From 31271c670c64bd010a1d85e14884b8ce0e192242 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 10:27:57 -0600 Subject: [PATCH 045/126] fix(usgs): remove hardcoded API key --- backend/connectors/usgs/source.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 7963f69..c5eedb5 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -37,9 +37,9 @@ ) -KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" +# KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" LIMIT = 50000 -TIMEOUT=1800 +TIMEOUT=15*60 # 15 minutes, to allow for retries and large requests class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer @@ -60,7 +60,6 @@ def health(self): url=self.sites_url, params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, - headers={"X-API-Key": KEY}, ) return True except httpx.HTTPStatusError: @@ -88,7 +87,6 @@ def get_records(self): url=self.sites_url, params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, - headers={"X-API-Key": KEY}, ) # _execute_json_request returns None for non-200 responses, so we need to check for that as well if data is None: @@ -147,7 +145,7 @@ def get_records(self, site_record): response = httpx.post( url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", json=json_data, - headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + headers={"Content-Type": "application/query-cql-json"}, params={"limit": LIMIT, "parameter_code": "72019"}, timeout=TIMEOUT, ) @@ -186,7 +184,7 @@ def get_records(self, site_record): try: response = httpx.get( url=next_link_url, - headers={"X-API-Key": KEY, "Content-Type": "application/query-cql-json"}, + headers={"Content-Type": "application/query-cql-json"}, timeout=TIMEOUT, ) if response.status_code != 200: From deed95f063c5320c946e66f4b8c0b5404ff80bcc Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 10:47:10 -0600 Subject: [PATCH 046/126] fix(usgs): have users pass USGS API keys --- README.md | 11 ++++++++++- backend/connectors/usgs/source.py | 21 +++++++++++++++++---- frontend/cli.py | 21 ++++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bb05d17..1ae7243 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ Data comes from the following sources. We are continuously adding new sources as - Available data: `water levels` - [Pecos Valley Artesian Conservancy District (PVACD)](https://st2.newmexicowaterdata.org/FROST-Server/v1.1/Locations?$filter=properties/agency%20eq%20%27PVACD%27) - Available data: `water levels` -- [USGS (NWIS)](https://waterdata.usgs.gov/nwis) +- [USGS (NWIS)](https://api.waterdata.usgs.gov/docs/) - Available data: `water levels` + - **IMPORTANT** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. - [Water Quality Portal (WQP)](https://www.waterqualitydata.us/) - Available data: `water levels`, `water quality` @@ -189,6 +190,14 @@ The Data Integration Engine enables the user to obtain groundwater level and gro - `--no-pvacd` to exclude Pecos Valley Artesian Convservancy District (PVACD) data - `--no-wqp` to exclude Water Quality Portal (WQP) data +### USGS API Keys + +The USGS now uses [API keys](https://api.waterdata.usgs.gov/signup/) to increase the query rate limit to their APIs. If you intend to include USGS water level data in your output please acquire an API key, save it somewhere, and provide it via the `--usgs-api-key` flag. For example: + +``` +die weave waterlevels --output-type timeseries_unified --usgs-api-key FAKE_API_KEY +``` + ### Geographic Filters [In Development] The following flags can be used to geographically filter data: diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index c5eedb5..4a4dcbf 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== import httpx +import os from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( @@ -36,8 +37,6 @@ get_terminal_record, ) - -# KEY = "55MILtQrayXw1NgufxcqRfkkRrg4Rg6KNCyJZ004" LIMIT = 50000 TIMEOUT=15*60 # 15 minutes, to allow for retries and large requests @@ -56,10 +55,15 @@ def tag(self): def health(self): try: + if os.environ.get("USGS_API_KEY") is not None: + headers = {"X-API-Key": os.environ["USGS_API_KEY"]} + else: + headers = {} self._execute_json_request( url=self.sites_url, params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, + headers=headers ) return True except httpx.HTTPStatusError: @@ -83,10 +87,15 @@ def get_records(self): finished_request: bool = False while not finished_request: try: + if os.environ.get("USGS_API_KEY") is not None: + headers = {"X-API-Key": os.environ["USGS_API_KEY"]} + else: + headers = {} data = self._execute_json_request( url=self.sites_url, params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, + headers=headers ) # _execute_json_request returns None for non-200 responses, so we need to check for that as well if data is None: @@ -142,10 +151,14 @@ def get_records(self, site_record): finished_request: bool = False while not finished_request: try: + if os.environ.get("USGS_API_KEY") is not None: + headers = {"X-API-Key": os.environ["USGS_API_KEY"], "Content-Type": "application/query-cql-json"} + else: + headers = {"Content-Type": "application/query-cql-json"} response = httpx.post( url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", json=json_data, - headers={"Content-Type": "application/query-cql-json"}, + headers=headers, params={"limit": LIMIT, "parameter_code": "72019"}, timeout=TIMEOUT, ) @@ -184,7 +197,7 @@ def get_records(self, site_record): try: response = httpx.get( url=next_link_url, - headers={"Content-Type": "application/query-cql-json"}, + headers=headers, timeout=TIMEOUT, ) if response.status_code != 200: diff --git a/frontend/cli.py b/frontend/cli.py index 3efb46b..34a3a34 100644 --- a/frontend/cli.py +++ b/frontend/cli.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import sys +import os import click @@ -206,6 +206,13 @@ def cli(): ), ] +USGS_API_KEY_OPTION = [ + click.option( + "--usgs-api-key", + default=None, + help="USGS API key. Can also be set via USGS_API_KEY environment variable", + ) +] def add_options(options): def _add_options(func): @@ -230,6 +237,7 @@ def _add_options(func): @add_options(ALL_SOURCE_OPTIONS) @add_options(DEBUG_OPTIONS) @add_options(OUTPUT_FORMAT_OPTIONS) +@add_options(USGS_API_KEY_OPTION) def weave( parameter, config_path, @@ -256,10 +264,15 @@ def weave( dry, yes, output_format, + usgs_api_key, ): """ Get parameter timeseries or summary data """ + # set USGS_API_KEY environment variable if usgs_api_key is provided + if usgs_api_key is not None: + os.environ["USGS_API_KEY"] = usgs_api_key + # instantiate config and set up parameter config = setup_config( tag=parameter, @@ -334,6 +347,7 @@ def weave( @add_options(ALL_SOURCE_OPTIONS) @add_options(DEBUG_OPTIONS) @add_options(OUTPUT_FORMAT_OPTIONS) +@add_options(USGS_API_KEY_OPTION) def sites( config_path, bbox, @@ -356,10 +370,15 @@ def sites( dry, yes, output_format, + usgs_api_key, ): """ Get sites """ + # set USGS_API_KEY environment variable if usgs_api_key is provided + if usgs_api_key is not None: + os.environ["USGS_API_KEY"] = usgs_api_key + config = setup_config( "sites", config_path, bbox, county, wkt, site_limit, dry, output_format ) From 539fb13fd705b138c4cea4ff2c05052537bd6f97 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 10:49:21 -0600 Subject: [PATCH 047/126] fix(usgs): update README documentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ae7243..3f0f0a7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Data comes from the following sources. We are continuously adding new sources as - Available data: `water levels` - [USGS (NWIS)](https://api.waterdata.usgs.gov/docs/) - Available data: `water levels` - - **IMPORTANT** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. + - **IMPORTANT** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key, save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. - [Water Quality Portal (WQP)](https://www.waterqualitydata.us/) - Available data: `water levels`, `water quality` From 6897f28a7feb35363f66284ae9f100e8f89a4b4f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 10:51:17 -0600 Subject: [PATCH 048/126] fix(documentation): make README more readable --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f0f0a7..16317e8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Data comes from the following sources. We are continuously adding new sources as - Available data: `water levels` - [USGS (NWIS)](https://api.waterdata.usgs.gov/docs/) - Available data: `water levels` - - **IMPORTANT** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key, save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. + - **IMPORTANT:** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key, save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. - [Water Quality Portal (WQP)](https://www.waterqualitydata.us/) - Available data: `water levels`, `water quality` From 1d2bac9729430c9164eedc781d222e555961621e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 10:52:11 -0600 Subject: [PATCH 049/126] fix(documentation): link to USGS API key acquisition --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16317e8..e8db69e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Data comes from the following sources. We are continuously adding new sources as - Available data: `water levels` - [USGS (NWIS)](https://api.waterdata.usgs.gov/docs/) - Available data: `water levels` - - **IMPORTANT:** The USGS now uses API keys. To prevent yourself from hitting the rate limit please acquire an API key, save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. + - **IMPORTANT:** The USGS now uses API keys. To prevent yourself from hitting the rate limit please [acquire an API key](https://api.waterdata.usgs.gov/signup/), save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. - [Water Quality Portal (WQP)](https://www.waterqualitydata.us/) - Available data: `water levels`, `water quality` From 5f886bb455f430140cb5b86cf3111d76ba6250be Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 11:28:45 -0600 Subject: [PATCH 050/126] fix(usgs): exit if rate limit exceeded --- backend/connectors/usgs/source.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 4a4dcbf..cdc6689 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -15,6 +15,7 @@ # =============================================================================== import httpx import os +import sys from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( @@ -91,13 +92,21 @@ def get_records(self): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} - data = self._execute_json_request( + response = httpx.get( url=self.sites_url, params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, headers=headers ) - # _execute_json_request returns None for non-200 responses, so we need to check for that as well + + if response.status_code == 429: + self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") + sys.exit(1) + elif response.status_code != 200: + self.warn(f"Received status code {response.status_code}. Retrying...") + continue + else: + data = response.json() if data is None: self.warn("Retrying...") else: @@ -162,7 +171,10 @@ def get_records(self, site_record): params={"limit": LIMIT, "parameter_code": "72019"}, timeout=TIMEOUT, ) - if response.status_code != 200: + if response.status_code == 429: + self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") + sys.exit(1) + elif response.status_code != 200: self.warn(f"Received status code {response.status_code}. Retrying...") else: finished_request = True From f9908fdc4b562176644b90840966f3f44945c633 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 12:38:54 -0600 Subject: [PATCH 051/126] fix(usgs): disallow partial records if a rate limit error is hit the tool will inform the user if a rate limit error is hit and will not save any records for USGS if a rate limit error is hit, so there won't be partial records for USGS. The tool will still save records for othe sources if a rate limit error is hit for USGS since the user may want to save those records and they won't be affected by the rate limit error for USGS. --- backend/connectors/usgs/source.py | 17 +++++++++++----- backend/unifier.py | 34 ++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index cdc6689..90ab60c 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -15,7 +15,6 @@ # =============================================================================== import httpx import os -import sys from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( @@ -41,6 +40,10 @@ LIMIT = 50000 TIMEOUT=15*60 # 15 minutes, to allow for retries and large requests + +class USGSRateLimitError(Exception): + pass + class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer chunk_size = 500 @@ -100,8 +103,7 @@ def get_records(self): ) if response.status_code == 429: - self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") - sys.exit(1) + raise USGSRateLimitError() elif response.status_code != 200: self.warn(f"Received status code {response.status_code}. Retrying...") continue @@ -111,6 +113,9 @@ def get_records(self): self.warn("Retrying...") else: finished_request = True + except USGSRateLimitError: + self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") + raise USGSRateLimitError("Rate limit exceeded") except Exception as e: self.warn(f"Error retrieving site records: {e}. Retrying...") @@ -172,12 +177,14 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code == 429: - self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") - sys.exit(1) + raise USGSRateLimitError("Rate limit exceeded") elif response.status_code != 200: self.warn(f"Received status code {response.status_code}. Retrying...") else: finished_request = True + except USGSRateLimitError: + self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") + raise USGSRateLimitError("Rate limit exceeded") except Exception as e: self.warn(f"Error retrieving water level records: {e}. Retrying...") diff --git a/backend/unifier.py b/backend/unifier.py index b070631..130cae9 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -21,6 +21,7 @@ from backend.persister import BasePersister from backend.persisters.geoserver import GeoServerPersister from backend.source import BaseSiteSource +from backend.connectors.usgs.source import USGSRateLimitError def health_check(source: BaseSiteSource) -> bool | None: @@ -131,10 +132,17 @@ def _site_wrapper(site_source, parameter_source, persister, config): # in the future make discover required # return + # used to revert back to initial state if a rate limit error is hit, so there aren't partial records + initial_sites_len = len(persister.sites) + initial_timeseries_len = len(persister.timeseries) + use_summarize = config.output_summary site_limit = config.site_limit - sites = site_source.read() + try: + sites = site_source.read() + except USGSRateLimitError: + sites = [] if not sites: return @@ -158,18 +166,30 @@ def _site_wrapper(site_source, parameter_source, persister, config): end_ind += n if use_summarize: - summary_records = parameter_source.read( - site_records, use_summarize, start_ind, end_ind - ) + try: + summary_records = parameter_source.read( + site_records, use_summarize, start_ind, end_ind + ) + except USGSRateLimitError: + # if a rate limit error is hit we want to remove USGS sites so there aren't partial records + persister.sites = persister.sites[:initial_sites_len] + persister.timeseries = persister.timeseries[:initial_timeseries_len] + break if summary_records: persister.records.extend(summary_records) sites_with_records_count += len(summary_records) else: continue else: - results = parameter_source.read( - site_records, use_summarize, start_ind, end_ind - ) + try: + results = parameter_source.read( + site_records, use_summarize, start_ind, end_ind + ) + except USGSRateLimitError: + # if a rate limit error is hit we want to remove USGS sites so there aren't partial records + persister.sites = persister.sites[:initial_sites_len] + persister.timeseries = persister.timeseries[:initial_timeseries_len] + break # no records are returned if there is no site record for parameter # or if the record isn't clean (doesn't have the correct fields) # don't count these sites to apply to site_limit From 7082957cc8e473a53498b35f4f98487a9b3ec5e3 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 12:57:56 -0600 Subject: [PATCH 052/126] fix(usgs): implement retries with exponential backoff --- backend/connectors/usgs/source.py | 71 +++++++++++++++++++------------ 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 90ab60c..b1214d4 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -15,6 +15,7 @@ # =============================================================================== import httpx import os +import time from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( @@ -39,7 +40,7 @@ LIMIT = 50000 TIMEOUT=15*60 # 15 minutes, to allow for retries and large requests - +MAX_RETRIES = 7 class USGSRateLimitError(Exception): pass @@ -88,8 +89,9 @@ def get_records(self): # if config.end_date: # params["endDt"] = config.end_dt.date().isoformat() - finished_request: bool = False - while not finished_request: + tries: int = 0 + + while tries < MAX_RETRIES: try: if os.environ.get("USGS_API_KEY") is not None: headers = {"X-API-Key": os.environ["USGS_API_KEY"]} @@ -102,23 +104,23 @@ def get_records(self): headers=headers ) - if response.status_code == 429: + if response.status_code == 200: + break + elif response.status_code == 429: raise USGSRateLimitError() - elif response.status_code != 200: - self.warn(f"Received status code {response.status_code}. Retrying...") - continue - else: - data = response.json() - if data is None: - self.warn("Retrying...") else: - finished_request = True + self.warn(f"Received status code {response.status_code}. Retrying... {tries + 1}/{MAX_RETRIES}") + except USGSRateLimitError: self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") raise USGSRateLimitError("Rate limit exceeded") except Exception as e: - self.warn(f"Error retrieving site records: {e}. Retrying...") + self.warn(f"Error retrieving site records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") + + tries += 1 + time.sleep(tries) + data: dict = response.json() records: list = data.get("features", []) return records @@ -162,8 +164,9 @@ def get_records(self, site_record): list_of_sites ] } - finished_request: bool = False - while not finished_request: + tries: int = 0 + + while tries < MAX_RETRIES: try: if os.environ.get("USGS_API_KEY") is not None: headers = {"X-API-Key": os.environ["USGS_API_KEY"], "Content-Type": "application/query-cql-json"} @@ -176,17 +179,21 @@ def get_records(self, site_record): params={"limit": LIMIT, "parameter_code": "72019"}, timeout=TIMEOUT, ) - if response.status_code == 429: - raise USGSRateLimitError("Rate limit exceeded") - elif response.status_code != 200: - self.warn(f"Received status code {response.status_code}. Retrying...") + if response.status_code == 200: + break + elif response.status_code == 429: + raise USGSRateLimitError() else: - finished_request = True + self.warn(f"Received status code {response.status_code}. Retrying... {tries + 1}/{MAX_RETRIES}") + except USGSRateLimitError: self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") raise USGSRateLimitError("Rate limit exceeded") except Exception as e: - self.warn(f"Error retrieving water level records: {e}. Retrying...") + self.warn(f"Error retrieving water level records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") + + tries += 1 + time.sleep(tries) data: dict = response.json() features: list[dict] = data.get("features", []) @@ -211,21 +218,29 @@ def get_records(self, site_record): # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more while found_next_link: - finished_request: bool = False - while not finished_request: + tries: int = 0 + while tries < MAX_RETRIES: try: response = httpx.get( url=next_link_url, headers=headers, timeout=TIMEOUT, ) - if response.status_code != 200: - self.warn(f"Received status code {response.status_code} for paginated request. Retrying...") + if response.status_code == 200: + break + elif response.status_code == 429: + raise USGSRateLimitError() else: - finished_request = True + self.warn(f"Received status code {response.status_code} for paginated request. Retrying... {tries + 1}/{MAX_RETRIES}") + except USGSRateLimitError: + self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") + raise USGSRateLimitError("Rate limit exceeded") except Exception as e: - self.warn(f"Error retrieving paginated water level records: {e}. Retrying... - + self.warn(f"Error retrieving paginated water level records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") + + tries += 1 + time.sleep(tries) + data: dict = response.json() features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] From 148208b46a9a07ba48bdce6993848babe17b3e2a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:00:14 -0600 Subject: [PATCH 053/126] refactor(exceptions): put custom exceptions in own script --- backend/connectors/usgs/source.py | 4 +--- backend/exceptions.py | 2 ++ backend/unifier.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 backend/exceptions.py diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index b1214d4..7a385a2 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -31,6 +31,7 @@ NWISSiteTransformer, NWISWaterLevelTransformer, ) +from backend.exceptions import USGSRateLimitError from backend.source import ( BaseWaterLevelSource, BaseSiteSource, @@ -42,9 +43,6 @@ TIMEOUT=15*60 # 15 minutes, to allow for retries and large requests MAX_RETRIES = 7 -class USGSRateLimitError(Exception): - pass - class NWISSiteSource(BaseSiteSource): transformer_klass = NWISSiteTransformer chunk_size = 500 diff --git a/backend/exceptions.py b/backend/exceptions.py new file mode 100644 index 0000000..1a1d0eb --- /dev/null +++ b/backend/exceptions.py @@ -0,0 +1,2 @@ +class USGSRateLimitError(Exception): + pass \ No newline at end of file diff --git a/backend/unifier.py b/backend/unifier.py index 130cae9..1dd3bed 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -21,7 +21,7 @@ from backend.persister import BasePersister from backend.persisters.geoserver import GeoServerPersister from backend.source import BaseSiteSource -from backend.connectors.usgs.source import USGSRateLimitError +from backend.exceptions import USGSRateLimitError def health_check(source: BaseSiteSource) -> bool | None: From f760bbb7f225783810025fc966c4af1ea427946a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:02:52 -0600 Subject: [PATCH 054/126] fix(usgs): catch JSONDecodeError and retry --- backend/connectors/usgs/source.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 7a385a2..58f4841 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -16,6 +16,7 @@ import httpx import os import time +import json from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.constants import ( @@ -103,6 +104,7 @@ def get_records(self): ) if response.status_code == 200: + data: dict = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -112,13 +114,14 @@ def get_records(self): except USGSRateLimitError: self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") raise USGSRateLimitError("Rate limit exceeded") + except json.JSONDecodeError as e: + self.warn(f"Failed to decode JSON response: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") except Exception as e: self.warn(f"Error retrieving site records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") tries += 1 time.sleep(tries) - data: dict = response.json() records: list = data.get("features", []) return records @@ -178,6 +181,7 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code == 200: + data: dict = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -187,13 +191,14 @@ def get_records(self, site_record): except USGSRateLimitError: self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") raise USGSRateLimitError("Rate limit exceeded") + except json.JSONDecodeError as e: + self.warn(f"Failed to decode JSON response: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") except Exception as e: self.warn(f"Error retrieving water level records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") tries += 1 time.sleep(tries) - data: dict = response.json() features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] @@ -225,6 +230,7 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code == 200: + data: dict = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -233,13 +239,14 @@ def get_records(self, site_record): except USGSRateLimitError: self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") raise USGSRateLimitError("Rate limit exceeded") + except json.JSONDecodeError as e: + self.warn(f"Failed to decode JSON response: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") except Exception as e: self.warn(f"Error retrieving paginated water level records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") tries += 1 time.sleep(tries) - data: dict = response.json() features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] records.extend(standard_features) From 1aff1108d07007fcd9534cf2b2e7aa7ff7a2858b Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:04:48 -0600 Subject: [PATCH 055/126] fix(usgs): check status_code for health check --- backend/connectors/usgs/source.py | 7 +++++-- backend/source.py | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 58f4841..2a12d2e 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -63,13 +63,16 @@ def health(self): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} - self._execute_json_request( + response = httpx.get( url=self.sites_url, params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=TIMEOUT, headers=headers ) - return True + if response.status_code == 200: + return True + else: + return False except httpx.HTTPStatusError: return False diff --git a/backend/source.py b/backend/source.py index ff6160c..be4208d 100644 --- a/backend/source.py +++ b/backend/source.py @@ -252,7 +252,6 @@ def _execute_json_request( the json response """ resp = httpx.get(url, params=params, **kw) - if resp.status_code == 200: try: obj = resp.json() From 0af670eacfb40cc85809a2c1b51f72993b1b1bf8 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:06:25 -0600 Subject: [PATCH 056/126] fix(usgs): revert persister.records on partial completion --- backend/unifier.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/unifier.py b/backend/unifier.py index 1dd3bed..774014e 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -135,6 +135,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): # used to revert back to initial state if a rate limit error is hit, so there aren't partial records initial_sites_len = len(persister.sites) initial_timeseries_len = len(persister.timeseries) + initial_records_len = len(persister.records) use_summarize = config.output_summary site_limit = config.site_limit @@ -174,6 +175,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): # if a rate limit error is hit we want to remove USGS sites so there aren't partial records persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] + persister.records = persister.records[:initial_records_len] break if summary_records: persister.records.extend(summary_records) From 690ba4190b5896812e3e342ce80c76cbbd48aae1 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:26:38 -0600 Subject: [PATCH 057/126] fix(partial records): don't persist partial records --- backend/connectors/usgs/source.py | 24 ++++++++++++++++++++---- backend/exceptions.py | 3 +++ backend/unifier.py | 10 +++++----- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 2a12d2e..540a5f5 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -32,7 +32,7 @@ NWISSiteTransformer, NWISWaterLevelTransformer, ) -from backend.exceptions import USGSRateLimitError +from backend.exceptions import USGSRateLimitError, PartialOrNoDataError from backend.source import ( BaseWaterLevelSource, BaseSiteSource, @@ -91,6 +91,7 @@ def get_records(self): # if config.end_date: # params["endDt"] = config.end_dt.date().isoformat() + data: dict = {} tries: int = 0 while tries < MAX_RETRIES: @@ -107,7 +108,7 @@ def get_records(self): ) if response.status_code == 200: - data: dict = response.json() + data = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -125,6 +126,10 @@ def get_records(self): tries += 1 time.sleep(tries) + if data == {}: + self.warn("Failed to retrieve site records after multiple attempts.") + raise PartialOrNoDataError("Failed to retrieve site records after multiple attempts.") + records: list = data.get("features", []) return records @@ -168,6 +173,8 @@ def get_records(self, site_record): list_of_sites ] } + + data: dict = {} tries: int = 0 while tries < MAX_RETRIES: @@ -184,7 +191,7 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code == 200: - data: dict = response.json() + data = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -202,6 +209,10 @@ def get_records(self, site_record): tries += 1 time.sleep(tries) + if data == {}: + self.warn("Failed to retrieve water level records after multiple attempts.") + raise PartialOrNoDataError("Failed to retrieve water level records after multiple attempts.") + features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] @@ -225,6 +236,7 @@ def get_records(self, site_record): # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more while found_next_link: tries: int = 0 + data: dict = {} while tries < MAX_RETRIES: try: response = httpx.get( @@ -233,7 +245,7 @@ def get_records(self, site_record): timeout=TIMEOUT, ) if response.status_code == 200: - data: dict = response.json() + data = response.json() break elif response.status_code == 429: raise USGSRateLimitError() @@ -250,6 +262,10 @@ def get_records(self, site_record): tries += 1 time.sleep(tries) + if data == {}: + self.warn("Failed to retrieve paginated water level records after multiple attempts.") + raise PartialOrNoDataError("Failed to retrieve paginated water level records after multiple attempts + features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] records.extend(standard_features) diff --git a/backend/exceptions.py b/backend/exceptions.py index 1a1d0eb..509e44d 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -1,2 +1,5 @@ class USGSRateLimitError(Exception): + pass + +class PartialOrNoDataError(Exception): pass \ No newline at end of file diff --git a/backend/unifier.py b/backend/unifier.py index 774014e..d9aede1 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -21,7 +21,7 @@ from backend.persister import BasePersister from backend.persisters.geoserver import GeoServerPersister from backend.source import BaseSiteSource -from backend.exceptions import USGSRateLimitError +from backend.exceptions import USGSRateLimitError, PartialOrNoDataError def health_check(source: BaseSiteSource) -> bool | None: @@ -171,8 +171,8 @@ def _site_wrapper(site_source, parameter_source, persister, config): summary_records = parameter_source.read( site_records, use_summarize, start_ind, end_ind ) - except USGSRateLimitError: - # if a rate limit error is hit we want to remove USGS sites so there aren't partial records + except (USGSRateLimitError, PartialOrNoDataError): + # remove partial records to prevent incomplete data from being saved persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] persister.records = persister.records[:initial_records_len] @@ -187,8 +187,8 @@ def _site_wrapper(site_source, parameter_source, persister, config): results = parameter_source.read( site_records, use_summarize, start_ind, end_ind ) - except USGSRateLimitError: - # if a rate limit error is hit we want to remove USGS sites so there aren't partial records + except (USGSRateLimitError, PartialOrNoDataError): + # remove partial records to prevent incomplete data from being saved persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] break From 74241771af86874d507896e1fd990c70cd8cfc89 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:40:52 -0600 Subject: [PATCH 058/126] fix(usgs): make site source USGS-NWIS to correspond with field measurements --- backend/connectors/usgs/transformer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index bfffd4c..fc95212 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -29,7 +29,7 @@ def _transform(self, record): datum = "WGS84" rec = { - "source": "USGS", + "source": "USGS-NWIS", "id": record["properties"]["monitoring_location_id"], "name": record["properties"]["monitoring_location_name"], "latitude": record["geometry"]["coordinates"][1], From 013406dc0df8c5812a9498d7e692dd3f1f0b3d4d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:54:37 -0600 Subject: [PATCH 059/126] feature(usgs): add dt params --- backend/connectors/usgs/source.py | 65 ++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 540a5f5..014b1d5 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -77,19 +77,26 @@ def health(self): return False def get_records(self): - # TODO: handle date filters - # config = self.config - - # if config.has_bounds(): - # bbox = config.bbox_bounding_points() - # params["bbox"] = ",".join([str(b) for b in bbox]) - # else: - # params["state_code"] = "35" + params = { + "limit": LIMIT, + "parameter_code": "72019", + "site_type_code": "GW", + } - # if config.start_date: - # params["startDt"] = config.start_dt.date().isoformat() - # if config.end_date: - # params["endDt"] = config.end_dt.date().isoformat() + if self.config.has_bounds(): + bbox = self.config.bbox_bounding_points() + params["bbox"] = ",".join([str(b) for b in bbox]) + else: + params["state_code"] = "35" + + if self.config.start_date: + begin: str = self.config.start_dt.date().isoformat() + begin = f"{begin}T00:00:00Z" + params["begin"] = begin + if self.config.end_date: + end: str = self.config.end_dt.date().isoformat() + end = f"{end}T23:59:59Z" + params["end"] = end data: dict = {} tries: int = 0 @@ -102,7 +109,7 @@ def get_records(self): headers = {} response = httpx.get( url=self.sites_url, - params={"limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, + params=params, timeout=TIMEOUT, headers=headers ) @@ -145,15 +152,27 @@ def __repr__(self): return "NWISWaterLevelSource" def get_records(self, site_record): - # TODO: handle date filters - # config = self.config - # if config.start_date: - # params["startDt"] = config.start_dt.date().isoformat() - # else: - # params["startDt"] = "1900-01-01" + params = { + "limit": LIMIT, + "parameter_code": "72019", + } + + begin: str = "" + end: str = "" + + if self.config.start_date: + begin: str = self.config.start_dt.date().isoformat() + begin = f"{begin}T00:00:00Z" + if self.config.end_date: + end: str = self.config.end_dt.date().isoformat() + end = f"{end}T23:59:59Z" - # if config.end_date: - # params["endDt"] = config.end_dt.date().isoformat() + if begin and end: + params["datetime"] = f"{begin}/{end}" + elif begin: + params["datetime"] = f"{begin}/.." + elif end: + params["datetime"] = f"../{end}" records: list = [] sites: list = make_site_list(site_record) @@ -187,7 +206,7 @@ def get_records(self, site_record): url="https://api.waterdata.usgs.gov/ogcapi/v0/collections/field-measurements/items", json=json_data, headers=headers, - params={"limit": LIMIT, "parameter_code": "72019"}, + params=params, timeout=TIMEOUT, ) if response.status_code == 200: @@ -264,7 +283,7 @@ def get_records(self, site_record): if data == {}: self.warn("Failed to retrieve paginated water level records after multiple attempts.") - raise PartialOrNoDataError("Failed to retrieve paginated water level records after multiple attempts + raise PartialOrNoDataError("Failed to retrieve paginated water level records after multiple attempts") features: list[dict] = data.get("features", []) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] From 7d38c37bc337180543ae9e34b4b734d5a9c1032e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 13:56:15 -0600 Subject: [PATCH 060/126] note(usgs): type hint variables --- backend/connectors/usgs/source.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 014b1d5..36a3736 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -77,14 +77,14 @@ def health(self): return False def get_records(self): - params = { + params: dict = { "limit": LIMIT, "parameter_code": "72019", "site_type_code": "GW", } if self.config.has_bounds(): - bbox = self.config.bbox_bounding_points() + bbox: tuple = self.config.bbox_bounding_points() params["bbox"] = ",".join([str(b) for b in bbox]) else: params["state_code"] = "35" @@ -152,7 +152,7 @@ def __repr__(self): return "NWISWaterLevelSource" def get_records(self, site_record): - params = { + params: dict = { "limit": LIMIT, "parameter_code": "72019", } @@ -161,10 +161,10 @@ def get_records(self, site_record): end: str = "" if self.config.start_date: - begin: str = self.config.start_dt.date().isoformat() + begin = self.config.start_dt.date().isoformat() begin = f"{begin}T00:00:00Z" if self.config.end_date: - end: str = self.config.end_dt.date().isoformat() + end = self.config.end_dt.date().isoformat() end = f"{end}T23:59:59Z" if begin and end: From 818330004ace4bc2747996a72771f629ae51285d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:04:39 -0600 Subject: [PATCH 061/126] test(usgs): use .env for api key for tests --- requirements.txt | 1 + setup.py | 1 + tests/test_sources/test_nwis.py | 14 ++++++++++++++ 3 files changed, 16 insertions(+) diff --git a/requirements.txt b/requirements.txt index 15193eb..ba9ce76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ click==8.2.1 +dotenv flask frost_sta_client Geoalchemy2 diff --git a/setup.py b/setup.py index fb987e1..923b472 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ ], install_requires=[ "click==8.2.1", + "dotenv", "flask", "frost_sta_client", "Geoalchemy2", diff --git a/tests/test_sources/test_nwis.py b/tests/test_sources/test_nwis.py index b7bf272..e67e367 100644 --- a/tests/test_sources/test_nwis.py +++ b/tests/test_sources/test_nwis.py @@ -1,6 +1,20 @@ +import os +from dotenv import load_dotenv +import pytest + from backend.constants import WATERLEVELS, FEET from tests.test_sources import BaseSourceTestClass +@pytest.fixture(autouse=True) +def setup(): + # SETUP CODE ----------------------------------------------------------- + load_dotenv() + + # RUN TESTS ------------------------------------------------------------ + yield + + # TEARDOWN CODE --------------------------------------------------------- + os.environ["USGS_API_KEY"] = "" class TestNWISWaterlevels(BaseSourceTestClass): From 9bf3fc2f3379dcaa491dd19765674f8b99703f43 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:04:46 -0600 Subject: [PATCH 062/126] fix(usgs): Handle USGS rate limit and partial/no data errors --- backend/unifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/unifier.py b/backend/unifier.py index d9aede1..fef4ce2 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -142,7 +142,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): try: sites = site_source.read() - except USGSRateLimitError: + except (USGSRateLimitError, PartialOrNoDataError): sites = [] if not sites: From 43409f3dcfa43a1f03e9a18573d6a11a159c81b7 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:11:35 -0600 Subject: [PATCH 063/126] fix(test): don't override parent setup fixture --- tests/test_sources/test_nmbgmr_amp.py | 2 +- tests/test_sources/test_nwis.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sources/test_nmbgmr_amp.py b/tests/test_sources/test_nmbgmr_amp.py index b56fd5b..e15b717 100644 --- a/tests/test_sources/test_nmbgmr_amp.py +++ b/tests/test_sources/test_nmbgmr_amp.py @@ -8,7 +8,7 @@ @pytest.fixture(autouse=True) -def setup(): +def setup_nmbgmr_amp(): # SETUP CODE ----------------------------------------------------------- os.environ["IS_TESTING_ENV"] = "True" diff --git a/tests/test_sources/test_nwis.py b/tests/test_sources/test_nwis.py index e67e367..02a716a 100644 --- a/tests/test_sources/test_nwis.py +++ b/tests/test_sources/test_nwis.py @@ -6,7 +6,7 @@ from tests.test_sources import BaseSourceTestClass @pytest.fixture(autouse=True) -def setup(): +def setup_nwis(): # SETUP CODE ----------------------------------------------------------- load_dotenv() From 441ff81d073cb6d6d0fe9dec5cafe33346fd2ed1 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:12:00 -0600 Subject: [PATCH 064/126] fix(test): remove env var outside of fixture --- tests/test_sources/test_nmbgmr_amp.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_sources/test_nmbgmr_amp.py b/tests/test_sources/test_nmbgmr_amp.py index e15b717..be75957 100644 --- a/tests/test_sources/test_nmbgmr_amp.py +++ b/tests/test_sources/test_nmbgmr_amp.py @@ -4,9 +4,6 @@ from backend.constants import WATERLEVELS, CALCIUM, MILLIGRAMS_PER_LITER, FEET from tests.test_sources import BaseSourceTestClass -os.environ["IS_TESTING_ENV"] = "True" - - @pytest.fixture(autouse=True) def setup_nmbgmr_amp(): # SETUP CODE ----------------------------------------------------------- From e524ec74b0f89c3405ffaa782bc736a73ed0e411 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:35:54 -0600 Subject: [PATCH 065/126] fix(requirements): download python-dotenv --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ba9ce76..bd7f502 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ click==8.2.1 -dotenv +python-dotenv flask frost_sta_client Geoalchemy2 diff --git a/setup.py b/setup.py index 923b472..81bc3c1 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ ], install_requires=[ "click==8.2.1", - "dotenv", + "python-dotenv", "flask", "frost_sta_client", "Geoalchemy2", From 7af57d6444e531adbf60f2c6687383da77b3a821 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:36:10 -0600 Subject: [PATCH 066/126] fix(test): load usgs api key from .env for every test --- tests/test_sources/test_nwis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sources/test_nwis.py b/tests/test_sources/test_nwis.py index 02a716a..0c45618 100644 --- a/tests/test_sources/test_nwis.py +++ b/tests/test_sources/test_nwis.py @@ -8,7 +8,7 @@ @pytest.fixture(autouse=True) def setup_nwis(): # SETUP CODE ----------------------------------------------------------- - load_dotenv() + load_dotenv(override=True) # RUN TESTS ------------------------------------------------------------ yield From d7382bd566f63636e78d934703327ac6c81231b4 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:39:03 -0600 Subject: [PATCH 067/126] fix(unifier): remove partial persister.records on failure --- backend/unifier.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/unifier.py b/backend/unifier.py index fef4ce2..d044d71 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -191,6 +191,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): # remove partial records to prevent incomplete data from being saved persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] + persister.records = persister.records[:initial_records_len] break # no records are returned if there is no site record for parameter # or if the record isn't clean (doesn't have the correct fields) From 245b01fb288cf388749d2992316062d273d234a9 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:39:20 -0600 Subject: [PATCH 068/126] fix(usgs): use truthiness check for usgs api key header --- backend/connectors/usgs/source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 36a3736..e4e056d 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -59,7 +59,7 @@ def tag(self): def health(self): try: - if os.environ.get("USGS_API_KEY") is not None: + if os.environ.get("USGS_API_KEY"): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} @@ -103,7 +103,7 @@ def get_records(self): while tries < MAX_RETRIES: try: - if os.environ.get("USGS_API_KEY") is not None: + if os.environ.get("USGS_API_KEY"): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} @@ -198,7 +198,7 @@ def get_records(self, site_record): while tries < MAX_RETRIES: try: - if os.environ.get("USGS_API_KEY") is not None: + if os.environ.get("USGS_API_KEY"): headers = {"X-API-Key": os.environ["USGS_API_KEY"], "Content-Type": "application/query-cql-json"} else: headers = {"Content-Type": "application/query-cql-json"} From f5b2e2fa56eb8ff5470de9d259744b66b46e6d63 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 14:39:42 -0600 Subject: [PATCH 069/126] fix(style): two blank lines between classes --- backend/exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/exceptions.py b/backend/exceptions.py index 509e44d..80b7b84 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -1,5 +1,6 @@ class USGSRateLimitError(Exception): pass + class PartialOrNoDataError(Exception): pass \ No newline at end of file From 85ab811a0f3930bf50d886115e0c6aa6cf518837 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 15:39:39 -0600 Subject: [PATCH 070/126] fix(usgs): fail health check on non 2xx status codes --- backend/connectors/usgs/source.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index e4e056d..3da177f 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -69,10 +69,8 @@ def health(self): timeout=TIMEOUT, headers=headers ) - if response.status_code == 200: - return True - else: - return False + response.raise_for_status() + return True except httpx.HTTPStatusError: return False From 5cf9904cbfbfc5907a51ba0ca75442f384888458 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 30 Apr 2026 15:47:14 -0600 Subject: [PATCH 071/126] test(usgs api key): test usgs api key is env var when set via --usgs-api-key flag --- tests/test_cli/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py index 2029c38..8d5b596 100644 --- a/tests/test_cli/__init__.py +++ b/tests/test_cli/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest from typing import List +import os from backend.config import SOURCE_KEYS from backend.constants import ( @@ -57,6 +58,7 @@ def _test_weave( bbox: str | None = None, county: str | None = None, wkt: str | None = None, + usgs_api_key: str | None = None, ): # Arrange # turn off all sources except for the one being tested @@ -100,6 +102,9 @@ def _test_weave( arguments.extend(no_agencies) + if usgs_api_key: + arguments.extend(["--usgs-api-key", usgs_api_key]) + # Act result = self.runner.invoke(weave, arguments, standalone_mode=False) @@ -177,6 +182,10 @@ def _test_weave( # 9 assert getattr(config, "output_format") == output_format + + # 10 + if usgs_api_key: + assert os.getenv("USGS_API_KEY") == usgs_api_key except Exception as e: print(result) assert False @@ -217,6 +226,13 @@ def test_weave_wkt(self): wkt="POLYGON((-106.0 32.0, -102.0 32.0, -102.0 36.0, -106.0 36.0, -106.0 32.0))", ) + def test_weave_usgs_api_key(self): + self._test_weave( + parameter=WATERLEVELS, + output_type="summary", + usgs_api_key="TEST_API_KEY", + ) + def test_weave_waterlevels(self): self._test_weave(parameter=WATERLEVELS, output_type="summary") From 4f3b913c9cf7f380ccf00e402b41638dd03e24e8 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Fri, 1 May 2026 09:05:58 -0600 Subject: [PATCH 072/126] fix(test): reset usgs api key after each test --- tests/test_cli/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py index 8d5b596..7e3f21a 100644 --- a/tests/test_cli/__init__.py +++ b/tests/test_cli/__init__.py @@ -102,6 +102,8 @@ def _test_weave( arguments.extend(no_agencies) + # save original USGS_API_KEY to reset after test, and set to test value if provided + original_usgs_api_key = os.getenv("USGS_API_KEY", None) if usgs_api_key: arguments.extend(["--usgs-api-key", usgs_api_key]) @@ -186,6 +188,11 @@ def _test_weave( # 10 if usgs_api_key: assert os.getenv("USGS_API_KEY") == usgs_api_key + + if original_usgs_api_key is not None: + os.environ["USGS_API_KEY"] = original_usgs_api_key + else: + del os.environ["USGS_API_KEY"] except Exception as e: print(result) assert False From 8bd3ab4aa00ce9e93fa2783c8366407eb31d9d59 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 11:31:53 -0600 Subject: [PATCH 073/126] fix(source): add retry logic to _execute_json_request and _execute_text_request Raise an error if all retries fail to retrieve data, instead of returning None or an empty string. This ensures that downstream processes are aware of the failure and can handle it appropriately, rather than silently proceeding with missing data. --- backend/source.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/source.py b/backend/source.py index 4971d46..842b561 100644 --- a/backend/source.py +++ b/backend/source.py @@ -39,6 +39,7 @@ SiteRecord, ) from backend.transformer import BaseTransformer, convert_units +from backend.exceptions import PartialOrNoDataError def make_site_list(site_record: list[SiteRecord] | SiteRecord) -> list | str: @@ -242,7 +243,8 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: tries += 1 time.sleep(tries) - return "" + self.warn("Failed to retrieve records after multiple attempts") + raise PartialOrNoDataError("Failed to retrieve records after multiple attempts") def _execute_json_request( self, @@ -292,14 +294,15 @@ def _execute_json_request( else: self.warn(f"service responded with status {resp.status_code}") self.warn(f"service responded with text {resp.text} for url {resp.url}") - self.warn(f"URL: {url}") self.warn(f"Retrying... {tries+1}/{max_retries}") except Exception as e: self.warn(f"Error during request: {e}") self.warn(f"Retrying... {tries+1}/{max_retries}") tries += 1 time.sleep(tries) - return None + + self.warn("Failed to retrieve records after multiple attempts") + raise PartialOrNoDataError("Failed to retrieve records after multiple attempts") # ========================================================================== # Methods Implemented in BaseSiteSource and BaseParameterSource From 16201ba5a6e63d7adc72022ff9c0ec892120c105 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 12:07:21 -0600 Subject: [PATCH 074/126] fix(source): add retries and error handling for http requests If invalid data is returned, log the error and retry the request up to a maximum number of retries. If all retries fail, raise a PartialOrNoDataError. --- backend/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/source.py b/backend/source.py index 842b561..ee9bed1 100644 --- a/backend/source.py +++ b/backend/source.py @@ -288,9 +288,9 @@ def _execute_json_request( return obj[tag] return obj except JSONDecodeError: - self.warn(f"service responded but with no data. \n{resp.text}") + self.warn(f"service responded but with invalid or no JSON data. \n{resp.text}") self.warn(f"URL: {url}") - return None + self.warn(f"Retrying... {tries+1}/{max_retries}") else: self.warn(f"service responded with status {resp.status_code}") self.warn(f"service responded with text {resp.text} for url {resp.url}") From 46c15f177b1f5126b1673f5c9cba728874c7cc2a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 12:10:01 -0600 Subject: [PATCH 075/126] fix(usgs): check for HTTPError instead of HTTPStatusError to for health check This catches all errors, including connection errors, timeouts, etc. which is more appropriate for a health check. --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 3da177f..c0b2ac1 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -71,7 +71,7 @@ def health(self): ) response.raise_for_status() return True - except httpx.HTTPStatusError: + except httpx.HTTPError: return False def get_records(self): From 3db9946f7d54bfc58ceba188dae91c4f201e4aa5 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 12:37:33 -0600 Subject: [PATCH 076/126] fix(unifier): Warn when unable to retrieve complete records after multiple attempts This commit adds a warning message to the unifier when it fails to retrieve complete records after multiple attempts for a site source. If the unifier is unable to retrieve complete records, it will now log a warning indicating that no records will be saved for that source. This helps to inform users of potential issues with data retrieval and ensures that they are aware of any incomplete data that may be present in the output. --- backend/unifier.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/unifier.py b/backend/unifier.py index d044d71..40745e8 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -176,6 +176,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] persister.records = persister.records[:initial_records_len] + config.warn(f"Failed to retrieve complete records after multiple attempts for {site_source}. No records will be saved for this source.") break if summary_records: persister.records.extend(summary_records) @@ -192,11 +193,13 @@ def _site_wrapper(site_source, parameter_source, persister, config): persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] persister.records = persister.records[:initial_records_len] + config.warn(f"Failed to retrieve complete records after multiple attempts for {site_source}. No records will be saved for this source.") break # no records are returned if there is no site record for parameter # or if the record isn't clean (doesn't have the correct fields) # don't count these sites to apply to site_limit if results is None or len(results) == 0: + config.warn(f"No valid records retrieved for {site_source}. No records will be saved for this source.") continue else: sites_with_records_count += len(results) From 7f3cfa7c9d0f244cd313e2299e82e35295de7d1d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 13:19:45 -0600 Subject: [PATCH 077/126] feat(unifier): Add more specific warning messages for incomplete records Distinguish between incomplete site records and incomplete parameter records when logging warnings. This provides clearer information about which type of records failed to be retrieved for a given site source. --- backend/unifier.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/unifier.py b/backend/unifier.py index 40745e8..a3a5830 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -137,12 +137,16 @@ def _site_wrapper(site_source, parameter_source, persister, config): initial_timeseries_len = len(persister.timeseries) initial_records_len = len(persister.records) + incomplete_sites_record_msg = f"Failed to retrieve complete site records for {site_source}. No records will be saved for this source." + incomplete_parameter_record_msg = f"Failed to retrieve complete parameter records for {site_source}. No records will be saved for this source." + use_summarize = config.output_summary site_limit = config.site_limit try: sites = site_source.read() except (USGSRateLimitError, PartialOrNoDataError): + config.warn(incomplete_sites_record_msg) sites = [] if not sites: @@ -176,7 +180,7 @@ def _site_wrapper(site_source, parameter_source, persister, config): persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] persister.records = persister.records[:initial_records_len] - config.warn(f"Failed to retrieve complete records after multiple attempts for {site_source}. No records will be saved for this source.") + config.warn(incomplete_parameter_record_msg) break if summary_records: persister.records.extend(summary_records) @@ -193,13 +197,12 @@ def _site_wrapper(site_source, parameter_source, persister, config): persister.sites = persister.sites[:initial_sites_len] persister.timeseries = persister.timeseries[:initial_timeseries_len] persister.records = persister.records[:initial_records_len] - config.warn(f"Failed to retrieve complete records after multiple attempts for {site_source}. No records will be saved for this source.") + config.warn(incomplete_parameter_record_msg) break # no records are returned if there is no site record for parameter # or if the record isn't clean (doesn't have the correct fields) # don't count these sites to apply to site_limit if results is None or len(results) == 0: - config.warn(f"No valid records retrieved for {site_source}. No records will be saved for this source.") continue else: sites_with_records_count += len(results) From 60adf96a28e3ad23dc053705d7ec72c6c30aacd9 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 13:27:54 -0600 Subject: [PATCH 078/126] feat(usgs): add pagination check for water-level queries If a second page exists despite the large page size, raise an error instead of silently truncating the dataset. This is a stopgap measure until pagination is implemented for this connector. --- backend/connectors/usgs/source.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index c0b2ac1..3a83155 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -137,6 +137,16 @@ def get_records(self): records: list = data.get("features", []) + links: list[dict] = data.get("links", []) + has_next_link: bool = any(link.get("rel") == "next" for link in links) + if has_next_link: + self.warn( + "USGS water-level response indicates additional pages of data are available, but pagination is not currently supported for this query. Refusing to return a silently truncated dataset." + ) + raise PartialOrNoDataError( + "USGS water-level response was truncated; additional pages are available." + ) + return records @@ -231,6 +241,15 @@ def get_records(self, site_record): raise PartialOrNoDataError("Failed to retrieve water level records after multiple attempts.") features: list[dict] = data.get("features", []) + links: list[dict] = data.get("links", []) + has_next_link: bool = any(link.get("rel") == "next" for link in links) + if has_next_link: + self.warn( + "USGS water-level response indicates additional pages of data are available, but pagination is not currently supported for this query. Refusing to return a silently truncated dataset." + ) + raise PartialOrNoDataError( + "USGS water-level response was truncated; additional pages are available." + ) standard_features: list[dict] = [self._standardize_record(feature) for feature in features] records.extend(standard_features) From 22b0c33f3c3bb07f15650b259af2c9e46ab5e634 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 13:41:19 -0600 Subject: [PATCH 079/126] fix(test): fix USGS API key handling in CLI tests This tears down the USGS_API_KEY environment variable after each test, restoring it to its original value (or unsetting it if it was not originally set). This prevents side effects between tests and ensures that the presence or absence of the USGS_API_KEY does not affect other tests. --- tests/test_cli/__init__.py | 10 ++++++---- tests/test_sources/test_nwis.py | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py index 7e3f21a..f573475 100644 --- a/tests/test_cli/__init__.py +++ b/tests/test_cli/__init__.py @@ -189,13 +189,15 @@ def _test_weave( if usgs_api_key: assert os.getenv("USGS_API_KEY") == usgs_api_key - if original_usgs_api_key is not None: - os.environ["USGS_API_KEY"] = original_usgs_api_key - else: - del os.environ["USGS_API_KEY"] except Exception as e: print(result) assert False + finally: + # reset USGS_API_KEY to original value after test + if original_usgs_api_key is not None: + os.environ["USGS_API_KEY"] = original_usgs_api_key + else: + os.environ.pop("USGS_API_KEY", None) def test_weave_summary(self): self._test_weave(parameter=WATERLEVELS, output_type="summary") diff --git a/tests/test_sources/test_nwis.py b/tests/test_sources/test_nwis.py index 0c45618..8026956 100644 --- a/tests/test_sources/test_nwis.py +++ b/tests/test_sources/test_nwis.py @@ -8,13 +8,18 @@ @pytest.fixture(autouse=True) def setup_nwis(): # SETUP CODE ----------------------------------------------------------- + had_usgs_api_key = "USGS_API_KEY" in os.environ + original_usgs_api_key = os.environ.get("USGS_API_KEY") load_dotenv(override=True) # RUN TESTS ------------------------------------------------------------ yield # TEARDOWN CODE --------------------------------------------------------- - os.environ["USGS_API_KEY"] = "" + if had_usgs_api_key: + os.environ["USGS_API_KEY"] = original_usgs_api_key + else: + os.environ.pop("USGS_API_KEY", None) class TestNWISWaterlevels(BaseSourceTestClass): From 869e058f24991dfdf3352faf8b50af70049df8e3 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 14:18:19 -0600 Subject: [PATCH 080/126] fix(logger): Handle potential file descriptor leaks by removing existing handlers before adding new ones --- backend/logger.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/logger.py b/backend/logger.py index 2175854..74a81ad 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -50,6 +50,11 @@ def setup_logging(level=None, log_format=None, path=None): root = logging.getLogger() root.setLevel(level) + # Remove any existing handlers to prevent file descriptor leaks + for handler in root.handlers[:]: + root.removeHandler(handler) + handler.close() + if path is None: path = "die.log" else: From 3a3f3453ef87b0c1b9090b515a77c59e1f1cff99 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 14:44:40 -0600 Subject: [PATCH 081/126] feat(error): log URL on error --- backend/source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/source.py b/backend/source.py index ee9bed1..28f3d31 100644 --- a/backend/source.py +++ b/backend/source.py @@ -239,6 +239,7 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: self.warn(f"Retrying... {tries+1}/{max_tries}") except Exception as e: self.warn(f"Error during request: {e}") + self.warn(f"URL: {url}") self.warn(f"Retrying... {tries+1}/{max_tries}") tries += 1 time.sleep(tries) @@ -297,6 +298,7 @@ def _execute_json_request( self.warn(f"Retrying... {tries+1}/{max_retries}") except Exception as e: self.warn(f"Error during request: {e}") + self.warn(f"URL: {url}") self.warn(f"Retrying... {tries+1}/{max_retries}") tries += 1 time.sleep(tries) From ade753aee0d234a68e804b9918d40a8dbc400f65 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:04:17 -0600 Subject: [PATCH 082/126] fix(tests): reset env var in USGS tests previously only the usgs api key was reset. now all are reset because of load_dotenv() --- tests/test_sources/test_nwis.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_sources/test_nwis.py b/tests/test_sources/test_nwis.py index 8026956..20e575e 100644 --- a/tests/test_sources/test_nwis.py +++ b/tests/test_sources/test_nwis.py @@ -8,18 +8,15 @@ @pytest.fixture(autouse=True) def setup_nwis(): # SETUP CODE ----------------------------------------------------------- - had_usgs_api_key = "USGS_API_KEY" in os.environ - original_usgs_api_key = os.environ.get("USGS_API_KEY") + original_environ = os.environ.copy() load_dotenv(override=True) # RUN TESTS ------------------------------------------------------------ yield # TEARDOWN CODE --------------------------------------------------------- - if had_usgs_api_key: - os.environ["USGS_API_KEY"] = original_usgs_api_key - else: - os.environ.pop("USGS_API_KEY", None) + os.environ.clear() + os.environ.update(original_environ) class TestNWISWaterlevels(BaseSourceTestClass): From e75be0a465c321670360f1bf050c3838bcb90bf9 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:12:15 -0600 Subject: [PATCH 083/126] fix(health): return False if exception is raised in health check Now that errors in calling _execute_json_request are raised if nothing is returned the health checkers need to be amended --- backend/connectors/nmbgmr/source.py | 11 +++++++---- backend/connectors/nmenv/source.py | 8 ++++++-- backend/connectors/st_connector.py | 8 ++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 1050d36..19a7a45 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -68,10 +68,13 @@ def __repr__(self): return "NMBGMRSiteSource" def health(self): - resp = self._execute_json_request( - _make_url("locations"), tag="features", params={"limit": 1} - ) - return bool(resp) + try: + resp = self._execute_json_request( + _make_url("locations"), tag="features", params={"limit": 1} + ) + return True + except Exception: + return False def get_records(self): config = self.config diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index 50a828d..012f8b4 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -45,8 +45,12 @@ def __repr__(self): return "DWBSiteSource" def health(self): - return self.get_records(top=10, analyte=TDS) - + try: + self.get_records(top=10, analyte=TDS) + return True + except Exception: + return False + def get_records(self, *args, **kw): analyte = None diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index d596fe1..55d42e6 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -90,8 +90,12 @@ def make_dt_filter(tag, start, end): class STSiteSource(BaseSiteSource, STSource): def health(self): - return self.get_records(top=10) - + try: + self.get_records(top=10) + return True + except Exception: + return False + def get_records(self, *args, **kw): service = self.get_service() From 49abe130ada24a52bbfbe906636b0f221ae6499d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:17:34 -0600 Subject: [PATCH 084/126] fix(usgs): correct site collection error message It previously called out water levels when it should have called out sites --- backend/connectors/usgs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 3a83155..183a809 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -141,10 +141,10 @@ def get_records(self): has_next_link: bool = any(link.get("rel") == "next" for link in links) if has_next_link: self.warn( - "USGS water-level response indicates additional pages of data are available, but pagination is not currently supported for this query. Refusing to return a silently truncated dataset." + "USGS site response indicates additional pages of data are available, but pagination is not currently supported for this query. Refusing to return a silently truncated dataset." ) raise PartialOrNoDataError( - "USGS water-level response was truncated; additional pages are available." + "USGS site response was truncated; additional pages are available." ) return records From bab52525e902bb57865e5e514bd8c81d44032795 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:20:34 -0600 Subject: [PATCH 085/126] fix(logger): Ensure only managed handlers are closed Don't close handlers that may be shared with other loggers. Track handlers created by this module and only close those. --- backend/logger.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/logger.py b/backend/logger.py index 74a81ad..d1b8e23 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -20,6 +20,10 @@ import click +# Track handlers created by this module to avoid closing unrelated handlers +_managed_handlers = [] + + class Loggable: def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) @@ -39,6 +43,7 @@ def debug(self, msg): def setup_logging(level=None, log_format=None, path=None): + global _managed_handlers if level is None: level = logging.DEBUG @@ -50,10 +55,11 @@ def setup_logging(level=None, log_format=None, path=None): root = logging.getLogger() root.setLevel(level) - # Remove any existing handlers to prevent file descriptor leaks - for handler in root.handlers[:]: + # Remove only the RotatingFileHandler instances we created + for handler in _managed_handlers: root.removeHandler(handler) handler.close() + _managed_handlers.clear() if path is None: path = "die.log" @@ -62,6 +68,7 @@ def setup_logging(level=None, log_format=None, path=None): # shandler = logging.StreamHandler() rhandler = RotatingFileHandler(path, maxBytes=1e8, backupCount=50) + _managed_handlers.append(rhandler) handlers = [rhandler] From eafa40f7e59b3ebff15ece6f3163229eb22bf0b5 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:28:58 -0600 Subject: [PATCH 086/126] fix(usgs): Add parameter code filter to USGS data retrieval if not sites_only If sites_only is True then do not filter by parameter code, otherwise filter by parameter code 72019 (Groundwater level) --- backend/connectors/usgs/source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 183a809..85a2f38 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -77,7 +77,6 @@ def health(self): def get_records(self): params: dict = { "limit": LIMIT, - "parameter_code": "72019", "site_type_code": "GW", } @@ -96,6 +95,9 @@ def get_records(self): end = f"{end}T23:59:59Z" params["end"] = end + if not self.config.sites_only: + params["parameter_code"] = "72019" + data: dict = {} tries: int = 0 From 0c29a8acc79d3fe5616719fd1517a8ef4d4ff847 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:33:44 -0600 Subject: [PATCH 087/126] feat(version): Update version to 0.10.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 81bc3c1..1f8952a 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( name="nmuwd", - version="0.10.0", + version="0.10.1", author="Jake Ross", description="New Mexico Water Data Integration Engine", long_description=long_description, From 3d6d41ac980b53281fbab3d13f1f4a650fff3f38 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:38:08 -0600 Subject: [PATCH 088/126] fix(usgs): update usgs parameter reader to enable None and number values Convert to string so that the strip method when cleaning the data works as expected. This allows for None values to be properly handled and preventserrors when trying to clean numeric values. --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 85a2f38..dd1bc34 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -325,7 +325,7 @@ def _standardize_record(self, record: dict) -> dict: return { "site_id": record["properties"]["monitoring_location_id"], "source_parameter_name": "Water level, depth LSD", - "value": record["properties"]["value"], + "value": None if record["properties"]["value"] is None else str(record["properties"]["value"]), "datetime_measured": record["properties"]["time"], "source_parameter_units": record["properties"]["unit_of_measure"] } From 5a7c01fae83489aa3957c805e72398782f32b488 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:53:57 -0600 Subject: [PATCH 089/126] fix(nmbgmr): Check for features in response to determine if API is working --- backend/connectors/nmbgmr/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 19a7a45..862bca6 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -72,7 +72,7 @@ def health(self): resp = self._execute_json_request( _make_url("locations"), tag="features", params={"limit": 1} ) - return True + return bool(resp) except Exception: return False From 982b72a587029457ccacee56b0c93163e2132e06 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 15:56:08 -0600 Subject: [PATCH 090/126] fix(usgs): make sure sites is always list sometimes make_site_list returns a single site id as a string, which causes the batch processing logic to break since it expects a list of site ids. This change ensures that if make_site_list returns a string, it is converted to a list with one element for consistency with the batch processing logic. --- backend/connectors/usgs/source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index dd1bc34..3f115c7 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -187,6 +187,10 @@ def get_records(self, site_record): records: list = [] sites: list = make_site_list(site_record) + # if make_site_list returns a site site id as a string, convert to list for consistency with the batch processing logic below + if isinstance(sites, str): + sites = [sites] + # group sites into batches of num_sites to pass to the API # USGS APIs allow up to 250 sites to be queried at once with complex queries list_of_lists_of_sites: list = [] From b9ac4925d16cd7905dad5f444b1658cb9da1f8cc Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:11:03 -0600 Subject: [PATCH 091/126] fix(health): update health checks to return false if get_records returns empty response --- backend/connectors/bor/source.py | 4 ++-- backend/connectors/isc_seven_rivers/source.py | 4 ++-- backend/connectors/nmenv/source.py | 4 ++-- backend/connectors/st_connector.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/connectors/bor/source.py b/backend/connectors/bor/source.py index 4a28ee2..050a9bf 100644 --- a/backend/connectors/bor/source.py +++ b/backend/connectors/bor/source.py @@ -48,8 +48,8 @@ def __repr__(self): def health(self): try: - self.get_records() - return True + resp = self.get_records() + return bool(resp) except Exception: return False diff --git a/backend/connectors/isc_seven_rivers/source.py b/backend/connectors/isc_seven_rivers/source.py index 691bbac..a796e1b 100644 --- a/backend/connectors/isc_seven_rivers/source.py +++ b/backend/connectors/isc_seven_rivers/source.py @@ -76,8 +76,8 @@ def __repr__(self): def health(self): try: - self.get_records() - return True + resp = self.get_records() + return bool(resp) except Exception as e: print("Failed to get records", e) return False diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index 012f8b4..50141d0 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -46,8 +46,8 @@ def __repr__(self): def health(self): try: - self.get_records(top=10, analyte=TDS) - return True + resp = self.get_records(top=10, analyte=TDS) + return bool(resp) except Exception: return False diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index 55d42e6..4cd3101 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -91,8 +91,8 @@ def make_dt_filter(tag, start, end): class STSiteSource(BaseSiteSource, STSource): def health(self): try: - self.get_records(top=10) - return True + resp = self.get_records(top=10) + return bool(resp) except Exception: return False From 9560e0b3d5ff372e94c1382af50004e8e2b1e038 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:26:56 -0600 Subject: [PATCH 092/126] fix(st2 health): Update health checks to probe service directly instead of relying on get_records, which may not work if config is not fully initialized. This should prevent false negatives in health checks while still providing a meaningful check of service availability. --- backend/connectors/nmenv/source.py | 4 +++- backend/connectors/st_connector.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index 50141d0..d70b150 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -46,7 +46,9 @@ def __repr__(self): def health(self): try: - resp = self.get_records(top=10, analyte=TDS) + # Health checks may run before config is initialized; probe the service directly. + service = self.get_service() + resp = list(service.locations().query().top(1).list()) return bool(resp) except Exception: return False diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index 4cd3101..dd9550c 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -91,7 +91,9 @@ def make_dt_filter(tag, start, end): class STSiteSource(BaseSiteSource, STSource): def health(self): try: - resp = self.get_records(top=10) + # Health checks may run before config is initialized; probe the service directly. + service = self.get_service() + resp = list(service.locations().query().top(1).list()) return bool(resp) except Exception: return False From e40aa64db091a03d462b34f280fb241cfcbd93db Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:27:40 -0600 Subject: [PATCH 093/126] fix(usgs health check): decrease timeout for health check to avoid long waits when USGS is unresponsive --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 3f115c7..ab069fb 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -66,7 +66,7 @@ def health(self): response = httpx.get( url=self.sites_url, params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, - timeout=TIMEOUT, + timeout=30, headers=headers ) response.raise_for_status() From 1d45571b066749a75638e83001bc9c27ca8e68c0 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:29:26 -0600 Subject: [PATCH 094/126] fix(usgs): remove dead code for pagination if pagination is required then an error will be raised and the tool can be updated. no need to maintain dead code for a feature that is not currently used and may never be used. --- backend/connectors/usgs/source.py | 61 ------------------------------- 1 file changed, 61 deletions(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index ab069fb..071e233 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -259,67 +259,6 @@ def get_records(self, site_record): standard_features: list[dict] = [self._standardize_record(feature) for feature in features] records.extend(standard_features) - - """ - The following commented-out code handles pagination for cases where there are more than LIMIT records for a given batch of sites. - However, in testing, I have not encountered any cases where this is necessary. Furthermore, cursor-based pagination is broken as - of 4/29/26 when the limit query parameter is used, and it can't be used in combination with other parameters via complex queries. - If we do encounter cases where there are more than LIMIT records, we can use the following code to handle pagination (when it is fixed). - - found_next_link: bool = False - links: list[dict] = data.get("links", []) - for link in links: - if link["rel"] == "next": - next_link_url = link["href"] - found_next_link = True - break - - # use GET requests for the paginated responses after the initial POST to avoid issues with httpx and long URLs with many site ids - # USGS APIs use cursor pagination, so we can just follow the "next" links until there are no more - while found_next_link: - tries: int = 0 - data: dict = {} - while tries < MAX_RETRIES: - try: - response = httpx.get( - url=next_link_url, - headers=headers, - timeout=TIMEOUT, - ) - if response.status_code == 200: - data = response.json() - break - elif response.status_code == 429: - raise USGSRateLimitError() - else: - self.warn(f"Received status code {response.status_code} for paginated request. Retrying... {tries + 1}/{MAX_RETRIES}") - except USGSRateLimitError: - self.warn("Rate limit exceeded. Please provide a valid USGS API key via the --usgs-api-key flag to increase your rate limit and try again.") - raise USGSRateLimitError("Rate limit exceeded") - except json.JSONDecodeError as e: - self.warn(f"Failed to decode JSON response: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") - except Exception as e: - self.warn(f"Error retrieving paginated water level records: {e}. Retrying... {tries + 1}/{MAX_RETRIES}") - - tries += 1 - time.sleep(tries) - - if data == {}: - self.warn("Failed to retrieve paginated water level records after multiple attempts.") - raise PartialOrNoDataError("Failed to retrieve paginated water level records after multiple attempts") - - features: list[dict] = data.get("features", []) - standard_features: list[dict] = [self._standardize_record(feature) for feature in features] - records.extend(standard_features) - - found_next_link: bool = False - links: list = data.get("links", []) - for link in links: - if link["rel"] == "next": - next_link_url = link["href"] - found_next_link = True - break - """ self.log(f"Retrieved {len(records)} records") From 8b20e34894fc589701ff4b37b4184f5c2d092396 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:30:54 -0600 Subject: [PATCH 095/126] update(README): added note about USGS API key for die sites --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e8db69e..e5990ed 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,12 @@ The USGS now uses [API keys](https://api.waterdata.usgs.gov/signup/) to increase die weave waterlevels --output-type timeseries_unified --usgs-api-key FAKE_API_KEY ``` +or + +``` +die sites --usgs-api-key FAKE_API_KEY +``` + ### Geographic Filters [In Development] The following flags can be used to geographically filter data: From 0c685e19a0d783d4011b290ca29551d707d6877f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:32:03 -0600 Subject: [PATCH 096/126] fix(doc): update source _execute_json_request return type to always be dict --- backend/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/source.py b/backend/source.py index 28f3d31..a7f9da8 100644 --- a/backend/source.py +++ b/backend/source.py @@ -254,7 +254,7 @@ def _execute_json_request( tag: str | None = None, max_retries: int = 7, **kw - ) -> dict | None: + ) -> dict: """ Executes a get request to the provided url and returns the json response. From 074c17fb19dfb27034201856c58317c03a442d3e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:34:52 -0600 Subject: [PATCH 097/126] fix(documentation): remove repeated word in comment --- backend/connectors/usgs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index 071e233..cd6d079 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -187,7 +187,7 @@ def get_records(self, site_record): records: list = [] sites: list = make_site_list(site_record) - # if make_site_list returns a site site id as a string, convert to list for consistency with the batch processing logic below + # if make_site_list returns a site id as a string, convert to list for consistency with the batch processing logic below if isinstance(sites, str): sites = [sites] From 45916b2e3f587f0ce34ee519e215e13e9697005f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 16:38:52 -0600 Subject: [PATCH 098/126] fix(README): use "for their APIs" instead of "to their APIs" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5990ed..b0a8030 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The Data Integration Engine enables the user to obtain groundwater level and gro ### USGS API Keys -The USGS now uses [API keys](https://api.waterdata.usgs.gov/signup/) to increase the query rate limit to their APIs. If you intend to include USGS water level data in your output please acquire an API key, save it somewhere, and provide it via the `--usgs-api-key` flag. For example: +The USGS now uses [API keys](https://api.waterdata.usgs.gov/signup/) to increase the query rate limit for their APIs. If you intend to include USGS water level data in your output please acquire an API key, save it somewhere, and provide it via the `--usgs-api-key` flag. For example: ``` die weave waterlevels --output-type timeseries_unified --usgs-api-key FAKE_API_KEY From de660d8976b31f580a5b5490ce6aaa4d7dffae50 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Mon, 18 May 2026 17:20:30 -0600 Subject: [PATCH 099/126] fix(nmenv): list method hangs up service in health check do a health check using get_records instead --- backend/connectors/nmenv/source.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index d70b150..50141d0 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -46,9 +46,7 @@ def __repr__(self): def health(self): try: - # Health checks may run before config is initialized; probe the service directly. - service = self.get_service() - resp = list(service.locations().query().top(1).list()) + resp = self.get_records(top=10, analyte=TDS) return bool(resp) except Exception: return False From 1e3d4189bdd4c40f3412e22e9b9b4a9d2073a90e Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 19 May 2026 11:45:17 -0600 Subject: [PATCH 100/126] fix(readme): indicate api key for site and water level data from usgs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0a8030..fa3e66a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Data comes from the following sources. We are continuously adding new sources as - Available data: `water levels` - [USGS (NWIS)](https://api.waterdata.usgs.gov/docs/) - Available data: `water levels` - - **IMPORTANT:** The USGS now uses API keys. To prevent yourself from hitting the rate limit please [acquire an API key](https://api.waterdata.usgs.gov/signup/), save it, and provide it via the `--usgs-api-key` flag when gathering water level data from the USGS. + - **IMPORTANT:** The USGS now uses API keys. To prevent yourself from hitting the rate limit please [acquire an API key](https://api.waterdata.usgs.gov/signup/), save it, and provide it via the `--usgs-api-key` flag when gathering water level or site data from the USGS. - [Water Quality Portal (WQP)](https://www.waterqualitydata.us/) - Available data: `water levels`, `water quality` From c25d97bd824ff83042728d98a4b973b2fd3d8f5d Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 19 May 2026 14:23:41 -0600 Subject: [PATCH 101/126] feat(doc): log params when an error is encountered --- backend/source.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/source.py b/backend/source.py index a7f9da8..8d61e59 100644 --- a/backend/source.py +++ b/backend/source.py @@ -236,10 +236,12 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: self.warn(f"service responded with status {resp.status_code}") self.warn(f"service responded with text {resp.text}") self.warn(f"URL: {url}") + self.warn(f"Parameters: {params}") self.warn(f"Retrying... {tries+1}/{max_tries}") except Exception as e: self.warn(f"Error during request: {e}") self.warn(f"URL: {url}") + self.warn(f"Parameters: {params}") self.warn(f"Retrying... {tries+1}/{max_tries}") tries += 1 time.sleep(tries) @@ -291,14 +293,17 @@ def _execute_json_request( except JSONDecodeError: self.warn(f"service responded but with invalid or no JSON data. \n{resp.text}") self.warn(f"URL: {url}") + self.warn(f"Parameters: {params}") self.warn(f"Retrying... {tries+1}/{max_retries}") else: self.warn(f"service responded with status {resp.status_code}") self.warn(f"service responded with text {resp.text} for url {resp.url}") + self.warn(f"Parameters: {params}") self.warn(f"Retrying... {tries+1}/{max_retries}") except Exception as e: self.warn(f"Error during request: {e}") self.warn(f"URL: {url}") + self.warn(f"Parameters: {params}") self.warn(f"Retrying... {tries+1}/{max_retries}") tries += 1 time.sleep(tries) From ad7196302afa60d8186b5d3a633a50d9b56e3636 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 19 May 2026 14:25:12 -0600 Subject: [PATCH 102/126] feat(setup): bump version to 0.10.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1f8952a..72734eb 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( name="nmuwd", - version="0.10.1", + version="0.10.2", author="Jake Ross", description="New Mexico Water Data Integration Engine", long_description=long_description, From fde585bc0fb5eba651094afcd585261ee46ed763 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 11:32:51 -0600 Subject: [PATCH 103/126] fix(persister): fix encoding issue when writing to file Use UTF-8 encoding when writing to file in persister.py to fix encoding issues that occur when the default encoding is not UTF-8. This ensures that all characters are properly encoded and prevents errors when writing to the file. --- backend/persister.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index 05d860b..282ba5d 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -42,7 +42,7 @@ def dump_timeseries(path, timeseries: list[list]): of timeseries separated, the inner list will contain the records for a single site and this function will be called multiple times, once for each site. """ - with open(path, "w", newline="") as f: + with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) headers_have_not_been_written = True for i, records in enumerate(timeseries): @@ -55,7 +55,7 @@ def dump_timeseries(path, timeseries: list[list]): def dump_sites_summary(path, records, output_format: OutputFormat): if output_format == OutputFormat.CSV: - with open(path, "w", newline="") as f: + with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) for i, site in enumerate(records): if i == 0: @@ -83,7 +83,7 @@ def dump_sites_summary(path, records, output_format: OutputFormat): ] feature_collection = {"type": "FeatureCollection", "features": features} - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(feature_collection, f, indent=4) From 98403bef3438aff6d4fe5d54590f4782f88ed69f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 11:33:40 -0600 Subject: [PATCH 104/126] feat(version): bump to 0.10.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 72734eb..da41f4c 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( name="nmuwd", - version="0.10.2", + version="0.10.3", author="Jake Ross", description="New Mexico Water Data Integration Engine", long_description=long_description, From 07f28c5cafa466b944d4e384609fdd1d00f0cb5b Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 13:40:13 -0600 Subject: [PATCH 105/126] fix(persister): log errors when dumping files --- backend/persister.py | 84 +++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index 282ba5d..e348cf3 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -109,48 +109,60 @@ def finalize(self, output_name: str): pass def dump_sites(self, path: str): - if self.sites: - path = os.path.join(path, "sites") - path = self.add_extension(path, self.config.output_format) - self.log(f"dumping sites to {os.path.abspath(path)}") - self._dump_sites_summary(path, self.sites, self.config.output_format) - else: - self.log("no sites to dump", fg="red") + try: + if self.sites: + path = os.path.join(path, "sites") + path = self.add_extension(path, self.config.output_format) + self.log(f"dumping sites to {os.path.abspath(path)}") + self._dump_sites_summary(path, self.sites, self.config.output_format) + else: + self.log("no sites to dump", fg="red") + except Exception as e: + self.warn(f"failed to dump sites: {e}") def dump_summary(self, path: str): - if self.records: - path = os.path.join(path, "summary") - path = self.add_extension(path, self.config.output_format) - self.log(f"dumping summary to {os.path.abspath(path)}") - self._dump_sites_summary(path, self.records, self.config.output_format) - else: - self.log("no records to dump", fg="red") + try: + if self.records: + path = os.path.join(path, "summary") + path = self.add_extension(path, self.config.output_format) + self.log(f"dumping summary to {os.path.abspath(path)}") + self._dump_sites_summary(path, self.records, self.config.output_format) + else: + self.log("no records to dump", fg="red") + except Exception as e: + self.warn(f"failed to dump summary: {e}") def dump_timeseries_unified(self, path: str): - if self.timeseries: - path = os.path.join(path, "timeseries_unified") - path = self.add_extension(path, OutputFormat.CSV.value) - self.log(f"dumping unified timeseries to {os.path.abspath(path)}") - self._dump_timeseries(path, self.timeseries) - else: - self.log("no timeseries records to dump", fg="red") - - def dump_timeseries_separated(self, path: str): - if self.timeseries: - # make timeseries path inside of config.output_path to which - # the individual site timeseries will be dumped - timeseries_path = os.path.join(path, "timeseries") - self._make_output_directory(timeseries_path) - for records in self.timeseries: - site_id = records[0].id - path = os.path.join(timeseries_path, str(site_id).replace(" ", "_")) + try: + if self.timeseries: + path = os.path.join(path, "timeseries_unified") path = self.add_extension(path, OutputFormat.CSV.value) - self.log(f"dumping {site_id} to {os.path.abspath(path)}") + self.log(f"dumping unified timeseries to {os.path.abspath(path)}") + self._dump_timeseries(path, self.timeseries) + else: + self.log("no timeseries records to dump", fg="red") + except Exception as e: + self.warn(f"failed to dump unified timeseries: {e}") - list_of_records = [records] - self._dump_timeseries(path, list_of_records) - else: - self.log("no timeseries records to dump", fg="red") + def dump_timeseries_separated(self, path: str): + try: + if self.timeseries: + # make timeseries path inside of config.output_path to which + # the individual site timeseries will be dumped + timeseries_path = os.path.join(path, "timeseries") + self._make_output_directory(timeseries_path) + for records in self.timeseries: + site_id = records[0].id + path = os.path.join(timeseries_path, str(site_id).replace(" ", "_")) + path = self.add_extension(path, OutputFormat.CSV.value) + self.log(f"dumping {site_id} to {os.path.abspath(path)}") + + list_of_records = [records] + self._dump_timeseries(path, list_of_records) + else: + self.log("no timeseries records to dump", fg="red") + except Exception as e: + self.warn(f"failed to dump separated timeseries: {e}") def add_extension(self, path: str, extension: OutputFormat): if not extension: From e29d8ad417fee26aade5a0dac82f684650936de4 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 13:40:41 -0600 Subject: [PATCH 106/126] fix(persister): use utf-8 instead of utf-8-sig --- backend/persister.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index e348cf3..0d176f6 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -42,7 +42,7 @@ def dump_timeseries(path, timeseries: list[list]): of timeseries separated, the inner list will contain the records for a single site and this function will be called multiple times, once for each site. """ - with open(path, "w", newline="", encoding="utf-8-sig") as f: + with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) headers_have_not_been_written = True for i, records in enumerate(timeseries): @@ -55,7 +55,7 @@ def dump_timeseries(path, timeseries: list[list]): def dump_sites_summary(path, records, output_format: OutputFormat): if output_format == OutputFormat.CSV: - with open(path, "w", newline="", encoding="utf-8-sig") as f: + with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) for i, site in enumerate(records): if i == 0: From 746a72203311c9eda0dc61875f8afc96f609c764 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 13:50:10 -0600 Subject: [PATCH 107/126] fix(encoding): add error handling to timeseries dumps --- backend/persister.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/persister.py b/backend/persister.py index 0d176f6..088d7ff 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -119,6 +119,7 @@ def dump_sites(self, path: str): self.log("no sites to dump", fg="red") except Exception as e: self.warn(f"failed to dump sites: {e}") + raise e def dump_summary(self, path: str): try: @@ -131,7 +132,8 @@ def dump_summary(self, path: str): self.log("no records to dump", fg="red") except Exception as e: self.warn(f"failed to dump summary: {e}") - + raise e + def dump_timeseries_unified(self, path: str): try: if self.timeseries: @@ -143,6 +145,7 @@ def dump_timeseries_unified(self, path: str): self.log("no timeseries records to dump", fg="red") except Exception as e: self.warn(f"failed to dump unified timeseries: {e}") + raise e def dump_timeseries_separated(self, path: str): try: @@ -163,6 +166,7 @@ def dump_timeseries_separated(self, path: str): self.log("no timeseries records to dump", fg="red") except Exception as e: self.warn(f"failed to dump separated timeseries: {e}") + raise e def add_extension(self, path: str, extension: OutputFormat): if not extension: From 183e8d848b0a32addbf6baca12ed5c0dc754b4fe Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 13:57:18 -0600 Subject: [PATCH 108/126] fix(persister): use plain raise in except block this preserves the original traceback, which is helpful for debugging. --- backend/persister.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index 088d7ff..88cda70 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -119,7 +119,7 @@ def dump_sites(self, path: str): self.log("no sites to dump", fg="red") except Exception as e: self.warn(f"failed to dump sites: {e}") - raise e + raise def dump_summary(self, path: str): try: @@ -132,7 +132,7 @@ def dump_summary(self, path: str): self.log("no records to dump", fg="red") except Exception as e: self.warn(f"failed to dump summary: {e}") - raise e + raise def dump_timeseries_unified(self, path: str): try: @@ -145,7 +145,7 @@ def dump_timeseries_unified(self, path: str): self.log("no timeseries records to dump", fg="red") except Exception as e: self.warn(f"failed to dump unified timeseries: {e}") - raise e + raise def dump_timeseries_separated(self, path: str): try: @@ -166,7 +166,7 @@ def dump_timeseries_separated(self, path: str): self.log("no timeseries records to dump", fg="red") except Exception as e: self.warn(f"failed to dump separated timeseries: {e}") - raise e + raise def add_extension(self, path: str, extension: OutputFormat): if not extension: From b06937a79ef5f897b658d6c590e91a9c308bfd35 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 13:58:30 -0600 Subject: [PATCH 109/126] fix(persister): remove trailing whitespace --- backend/persister.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/persister.py b/backend/persister.py index 88cda70..80731ec 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -133,7 +133,7 @@ def dump_summary(self, path: str): except Exception as e: self.warn(f"failed to dump summary: {e}") raise - + def dump_timeseries_unified(self, path: str): try: if self.timeseries: From 17a1beeac616ab71f88a1bb5d0cc0b54b2438a80 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 15:48:10 -0600 Subject: [PATCH 110/126] fix(persister): include traceback in persister failure --- backend/persister.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index 80731ec..b873f11 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -118,7 +118,7 @@ def dump_sites(self, path: str): else: self.log("no sites to dump", fg="red") except Exception as e: - self.warn(f"failed to dump sites: {e}") + self.warn(f"failed to dump sites: {e}", exc_info=True) raise def dump_summary(self, path: str): @@ -131,7 +131,7 @@ def dump_summary(self, path: str): else: self.log("no records to dump", fg="red") except Exception as e: - self.warn(f"failed to dump summary: {e}") + self.warn(f"failed to dump summary: {e}", exc_info=True) raise def dump_timeseries_unified(self, path: str): @@ -144,7 +144,7 @@ def dump_timeseries_unified(self, path: str): else: self.log("no timeseries records to dump", fg="red") except Exception as e: - self.warn(f"failed to dump unified timeseries: {e}") + self.warn(f"failed to dump unified timeseries: {e}", exc_info=True) raise def dump_timeseries_separated(self, path: str): @@ -165,7 +165,7 @@ def dump_timeseries_separated(self, path: str): else: self.log("no timeseries records to dump", fg="red") except Exception as e: - self.warn(f"failed to dump separated timeseries: {e}") + self.warn(f"failed to dump separated timeseries: {e}", exc_info=True) raise def add_extension(self, path: str, extension: OutputFormat): From ed4f37198e58702fd37d69c62493b34495876587 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 15:54:27 -0600 Subject: [PATCH 111/126] fix(logger): accept kwargs for log and warn --- backend/logger.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/logger.py b/backend/logger.py index d1b8e23..c567362 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -28,15 +28,15 @@ class Loggable: def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) - def log(self, msg, level=None, fg="yellow"): + def log(self, msg, level=None, fg="yellow", **kwargs): if level is None: level = logging.INFO click.secho(f"{self.__class__.__name__:40s}{msg}", fg=fg) - self.logger.log(level, msg) + self.logger.log(level, msg, **kwargs) - def warn(self, msg, fg="red"): - self.log(msg, fg=fg, level=logging.WARNING) + def warn(self, msg, fg="red", **kwargs): + self.log(msg, fg=fg, level=logging.WARNING, **kwargs) def debug(self, msg): self.log(msg, level=logging.DEBUG, fg="blue") From 2b1abe20ce7dfc074456930e0209dc1ae261412f Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Tue, 26 May 2026 15:56:36 -0600 Subject: [PATCH 112/126] fix(persister): don't reassign variable --- backend/persister.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/persister.py b/backend/persister.py index b873f11..8887828 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -156,12 +156,12 @@ def dump_timeseries_separated(self, path: str): self._make_output_directory(timeseries_path) for records in self.timeseries: site_id = records[0].id - path = os.path.join(timeseries_path, str(site_id).replace(" ", "_")) - path = self.add_extension(path, OutputFormat.CSV.value) - self.log(f"dumping {site_id} to {os.path.abspath(path)}") + site_path = os.path.join(timeseries_path, str(site_id).replace(" ", "_")) + site_path = self.add_extension(site_path, OutputFormat.CSV.value) + self.log(f"dumping {site_id} to {os.path.abspath(site_path)}") list_of_records = [records] - self._dump_timeseries(path, list_of_records) + self._dump_timeseries(site_path, list_of_records) else: self.log("no timeseries records to dump", fg="red") except Exception as e: From 65104d8497b7a883d8e6fffec61336ee28cc8a15 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 27 May 2026 11:10:52 -0600 Subject: [PATCH 113/126] feat(README): update README to include info on encoding --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index fa3e66a..cabddf1 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,21 @@ or die sites --usgs-api-key FAKE_API_KEY ``` +### Encoding + +The exported files are encoded with `utf-8`. When opening the files in Python or another programming language ensure that they are opened with the same `utf-8` encoding. + +#### Working in Excel + +Because the csv files are not encoded with `utf-8-sig`, when opened in Excel special characters may be displayed incorrectly (such as μ appearing garbled). `utf-8-sig` includes a Byte Order Mark (BOM) that tells Excel the file is `utf-8` encoded. To view the characters properly, follow these steps: + +1. Go to the `Data` tab and click on `Get Data` +2. Choose `Text/CSV` and select the file to open +3. Under `File origin` select `65001: Unicode (UTF-8)` +4. Under `Delimiter` select `Comma` +5. Load the data + + ### Geographic Filters [In Development] The following flags can be used to geographically filter data: From 8a7f434f5d7696a87eb909900c507a21924d289f Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 11:57:10 -0600 Subject: [PATCH 114/126] feat: Dagster+GCP orchestration, uv migration, OGC Features output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1: migrate to uv/hatchling — drop setup.py/requirements.txt/pytest.ini/mypy.ini, add pyproject.toml + uv.lock; fix --no-* CLI flag polarity bug (§B.1 §V-1) T2: add OGC Features persister (summary + flat timeseries) + 9 tests (§V-4 §V-5 §V-6 §V-7) T3: orchestration scaffold — pyproject.toml, products.yaml, DIEConfigResource (§V-2 §V-3) T4: Dagster assets for analytes + definitions.py (4 assets + 4 schedules) (§V-8) T5: water-level timeseries assets (flat one-feature-per-observation) (§V-5 §V-6) T6: GCS resource with atomic latest.geojson write (§V-9) T7: Cloud Run Job Dockerfile + cloudbuild.yaml + cloudrun.yaml + README (§V-3) T8: update CI/CD for uv; add orchestration-ci.yml workflow (§V-1) T9: pygeoapi — config.yml.j2, generate_config.py, Dockerfile, cloudbuild.yaml (§V-10) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/cicd.yml | 41 +- .github/workflows/orchestration-ci.yml | 49 + SPEC.md | 595 ++++++ backend/__init__.py | 2 + backend/persisters/ogc_features.py | 164 ++ backend/unifier.py | 10 +- frontend/cli.py | 28 +- mypy.ini | 4 - orchestration/Dockerfile | 40 + orchestration/README.md | 83 + orchestration/__init__.py | 0 orchestration/assets/__init__.py | 0 orchestration/assets/analytes.py | 45 + orchestration/assets/waterlevels.py | 90 + orchestration/assets/wells.py | 47 + orchestration/cloudbuild.yaml | 27 + orchestration/cloudrun.yaml | 38 + orchestration/config/products.yaml | 46 + orchestration/definitions.py | 68 + orchestration/pygeoapi/Dockerfile | 24 + orchestration/pygeoapi/cloudbuild.yaml | 42 + orchestration/pygeoapi/config.yml.j2 | 82 + orchestration/pygeoapi/generate_config.py | 52 + orchestration/pyproject.toml | 24 + orchestration/resources/__init__.py | 0 orchestration/resources/die_config.py | 41 + orchestration/resources/gcs.py | 76 + pyproject.toml | 48 + pytest.ini | 3 - requirements.txt | 16 - setup.py | 69 - tests/test_persisters/__init__.py | 0 tests/test_persisters/test_ogc_features.py | 173 ++ uv.lock | 2020 ++++++++++++++++++++ 34 files changed, 3911 insertions(+), 136 deletions(-) create mode 100644 .github/workflows/orchestration-ci.yml create mode 100644 SPEC.md create mode 100644 backend/persisters/ogc_features.py delete mode 100644 mypy.ini create mode 100644 orchestration/Dockerfile create mode 100644 orchestration/README.md create mode 100644 orchestration/__init__.py create mode 100644 orchestration/assets/__init__.py create mode 100644 orchestration/assets/analytes.py create mode 100644 orchestration/assets/waterlevels.py create mode 100644 orchestration/assets/wells.py create mode 100644 orchestration/cloudbuild.yaml create mode 100644 orchestration/cloudrun.yaml create mode 100644 orchestration/config/products.yaml create mode 100644 orchestration/definitions.py create mode 100644 orchestration/pygeoapi/Dockerfile create mode 100644 orchestration/pygeoapi/cloudbuild.yaml create mode 100644 orchestration/pygeoapi/config.yml.j2 create mode 100644 orchestration/pygeoapi/generate_config.py create mode 100644 orchestration/pyproject.toml create mode 100644 orchestration/resources/__init__.py create mode 100644 orchestration/resources/die_config.py create mode 100644 orchestration/resources/gcs.py create mode 100644 pyproject.toml delete mode 100644 pytest.ini delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/test_persisters/__init__.py create mode 100644 tests/test_persisters/test_ogc_features.py create mode 100644 uv.lock diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d88a5fc..978fe71 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,56 +1,39 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: CI/CD on: push: - branches: [ "pre-production", "feature/jir", "dev/jab"] + branches: [ "pre-production", "feature/jir", "dev/jab", "feature/orchestration-gcp-uv" ] pull_request: - branches: [ "pre-production"] + branches: [ "pre-production" ] permissions: contents: read jobs: build: - runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: "3.10" - cache: "pip" + version: "latest" - - name: Cache pip dependencies - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- + - name: Set up Python + run: uv python install 3.10 - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest mypy - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + run: uv sync --extra dev - name: Lint with flake8 run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + uv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Type check with mypy - run: | - mypy . --ignore-missing-imports + run: uv run mypy . --ignore-missing-imports - name: Test with pytest - run: | - pytest -s tests + run: uv run pytest -s tests diff --git a/.github/workflows/orchestration-ci.yml b/.github/workflows/orchestration-ci.yml new file mode 100644 index 0000000..8cff5de --- /dev/null +++ b/.github/workflows/orchestration-ci.yml @@ -0,0 +1,49 @@ +name: Orchestration CI + +on: + push: + paths: + - 'orchestration/**' + - 'backend/**' + pull_request: + branches: [ "pre-production" ] + paths: + - 'orchestration/**' + +permissions: + contents: read + +jobs: + lint-and-import-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.10 + + - name: Install root package deps + run: uv sync --extra dev + + - name: Install orchestration deps + run: uv pip install dagster dagster-gcp google-cloud-storage google-cloud-secret-manager Jinja2 + + - name: Lint orchestration with flake8 + run: | + uv run flake8 orchestration/ --count --select=E9,F63,F7,F82 --show-source --statistics + uv run flake8 orchestration/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Import check — definitions load without error + run: | + uv run python -c " + import sys; sys.path.insert(0, '.') + from orchestration.definitions import defs + assert len(list(defs.assets)) > 0, 'No assets defined' + print(f'OK: {len(list(defs.assets))} assets, {len(defs.schedules)} schedules') + " diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..3b908a2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,595 @@ +# DIE Orchestration + GCP Modernization Spec +## Branch: feature/orchestration-gcp-uv + +--- + +## §1 Context & Goals + +DIE is a data integration engine that unifies NM water data from 12 heterogeneous sources. +Currently: pip-installable CLI (`die weave`), partial Dagster code on `jir-dagster` (unmerged). + +**Goals for this branch:** +1. Migrate package management to **uv + pyproject.toml** +2. Add **Dagster orchestration layer** (internal, not shipped in pip package) +3. Deploy orchestration as **GCP Cloud Run** +4. Produce **OGC Feature Collections** as canonical data products +5. Generate **time-series data products** per well / parameter +6. Products defined in a **configurable YAML manifest** +7. Serve data products via **pygeoapi** (OGC API - Features standard) + +--- + +## §2 Non-Goals + +- No changes to public CLI (`die weave`, `die sites`, `die sources`) +- No changes to existing `backend/` integration logic (sources, transformers, unifier) +- No changes to existing `frontend/cli.py` +- Orchestration code NOT shipped to PyPI +- No database required for orchestration pipeline (GCS is the store) +- GeoServer/PostGIS persister remains available as optional CLI output — not used here + +--- + +## §3 Architecture + +### §3.1 Repository Structure (post-migration) + +``` +DataIntegrationEngine/ +├── pyproject.toml # uv project (replaces setup.py + requirements.txt) +├── uv.lock # pinned lockfile +├── backend/ # unchanged — core integration +├── frontend/ # unchanged — CLI + legacy API +├── orchestration/ # NEW — not in pip package +│ ├── pyproject.toml # orchestration-specific deps +│ ├── Dockerfile # Cloud Run Job image (Dagster) +│ ├── cloudbuild.yaml # Cloud Build CI/CD +│ ├── assets/ +│ │ ├── __init__.py +│ │ ├── wells.py # well site assets +│ │ ├── waterlevels.py # water level timeseries assets +│ │ └── analytes.py # analyte assets +│ ├── resources/ +│ │ ├── __init__.py +│ │ ├── die_config.py # DIE Config Dagster resource +│ │ └── gcs.py # GCS upload/download resource +│ ├── config/ +│ │ └── products.yaml # configurable product manifest +│ ├── definitions.py # Dagster Definitions (entry point) +│ └── pygeoapi/ # pygeoapi API server +│ ├── config.yml.j2 # Jinja2 template for pygeoapi config +│ ├── generate_config.py # renders config.yml from products.yaml +│ ├── Dockerfile # extends geopython/pygeoapi +│ └── cloudbuild.yaml # Cloud Build for pygeoapi image +├── tests/ # unchanged +└── SPEC.md +``` + +### §3.2 Dependency Separation + +``` +pyproject.toml (public CLI) + [project.dependencies] ← lean: click, httpx, geopandas, pyyaml, pandas, etc. + [project.optional-dependencies] + dev = [pytest, mypy, flake8] + geoserver = [psycopg2-binary, GeoAlchemy2, SQLAlchemy] # optional, existing feature + +orchestration/pyproject.toml (internal, never published) + [project.dependencies] ← dagster, dagster-gcp, google-cloud-storage, + google-cloud-secret-manager, Jinja2 +``` + +No database deps in orchestration core. GCS is the sole store. + +### §3.3 GCP Deployment + +``` +Cloud Scheduler (cron) + → Cloud Run Job ← Dagster orchestration (stateless) + └── DIE unifier (existing backend) + ├── fetch from 12 sources + ├── transform → OGC FC GeoJSON files + └── upload → gs://die-products/{product_id}/ + │ + GCS bucket + (public read) + │ + GDAL /vsigs/ virtual FS + │ + Cloud Run Service ← pygeoapi (always-on) + (OGR provider reads GeoJSON from GCS) + │ + HTTP clients + (OGC API - Features) +``` + +**Two Cloud Run deployments:** +- **Cloud Run Job** (Dagster, stateless): triggered by Cloud Scheduler, runs pipeline per product +- **Cloud Run Service** (pygeoapi, always-on): serves OGC API - Features via GDAL OGR + GCS + +No PostgreSQL required. GCS is authoritative store. pygeoapi reads GeoJSON directly from +GCS via GDAL's `/vsigs/` virtual filesystem — no proxy, no DB, no sync step. + +> GeoServer/PostGIS persister in `backend/persisters/geoserver.py` is unchanged and +> still usable via CLI `--output-format geoserver`. Not part of this pipeline. + +--- + +## §4 OGC Feature Collections + +### §4.1 Format + +OGC API - Features compliant GeoJSON written by `OGCFeaturesPersister`. + +**Collection envelope:** +```json +{ + "type": "FeatureCollection", + "id": "nm_waterlevels_summary", + "title": "NM Unified Water Levels Summary", + "description": "...", + "timeStamp": "2026-06-22T06:00:00Z", + "numberMatched": 1234, + "numberReturned": 1234, + "links": [ + {"href": "gs://die-products/nm_waterlevels_summary/latest.geojson", + "rel": "self", "type": "application/geo+json"} + ], + "features": [...] +} +``` + +Each Feature has a top-level `id` (OGC requirement): +```json +{ + "type": "Feature", + "id": "nmbgmr_amp:RA-1234", + "geometry": {"type": "Point", "coordinates": [-106.5, 35.2, 1650.0]}, + "properties": { ... } +} +``` + +### §4.2 Summary Features + +One feature per well site. Properties = existing `SummaryRecord` fields +(nrecords, min, max, mean, earliest_date, latest_date, latest_value, etc.). + +### §4.3 Timeseries Features (flat format) + +**One feature per observation** — not per well. This enables pygeoapi `time_field` +temporal filtering natively without custom code. + +```json +{ + "type": "Feature", + "id": "nmbgmr_amp:RA-1234:2024-04-20", + "geometry": {"type": "Point", "coordinates": [-106.5, 35.2, 1650.0]}, + "properties": { + "site_id": "RA-1234", + "site_name": "Roswell Basin Well", + "source": "nmbgmr_amp", + "parameter": "waterlevels", + "value": 218.1, + "units": "ft", + "datetime": "2024-04-20T00:00:00Z" + } +} +``` + +`datetime` is an ISO 8601 timestamp — pygeoapi maps it to `time_field` for +`?datetime=` query parameter support. + +### §4.4 New Persister + +`backend/persisters/ogc_features.py` → `OGCFeaturesPersister` +- `dump_summary_collection(path, records, meta)` — §4.2 format +- `dump_timeseries_collection(path, site_records, timeseries_records, meta)` — §4.3 format +- Writes local `.geojson` file; Dagster GCS resource handles upload + +--- + +## §5 Dagster Assets + +### §5.1 Asset Graph + +``` +products_config ← loads products.yaml at startup + │ + ▼ +[per product, per schedule] + source_data ← unify_waterlevels / unify_analytes (existing) + │ + ▼ + ogc_collection ← OGCFeaturesPersister → tmp .geojson + │ + ▼ + gcs_upload ← gs://die-products/{product_id}/{YYYY-MM-DD}.geojson + gs://die-products/{product_id}/latest.geojson (overwrite) +``` + +### §5.2 Configurable Products (`orchestration/config/products.yaml`) + +```yaml +gcs_bucket: die-products + +products: + - id: nm_waterlevels_summary + parameter: waterlevels + output_type: ogc_summary + title: "NM Unified Water Levels Summary" + description: "Summary stats for water levels, all NM sources" + schedule: "0 6 * * *" # UTC cron + spatial_filter: + state: NM + sources: + exclude: [] + + - id: nm_waterlevels_timeseries + parameter: waterlevels + output_type: ogc_timeseries + title: "NM Water Levels Time Series" + description: "Per-observation water level measurements, all NM sources" + schedule: "0 7 * * *" + spatial_filter: + state: NM + sources: + exclude: [] + + - id: bernco_waterlevels_timeseries + parameter: waterlevels + output_type: ogc_timeseries + title: "Bernalillo County Water Level Time Series" + description: "Bernalillo County water level timeseries per well" + schedule: "0 8 * * *" + spatial_filter: + county: Bernalillo + sources: + include: [bernco] + + - id: nm_arsenic_summary + parameter: arsenic + output_type: ogc_summary + title: "NM Arsenic Summary" + description: "Arsenic concentration summary stats, all NM sources" + schedule: "0 9 * * *" + spatial_filter: + state: NM + sources: + exclude: [] +``` + +Assets are dynamically generated from `products.yaml` at Dagster definition time. + +### §5.3 Schedule Strategy (MVP) + +One Cloud Run Job per schedule group. Cloud Scheduler triggers with `PRODUCT_ID` env var. +Single Dagster `definitions.py` handles all products; job selects by product id. +Structure supports later migration to persistent Dagster daemon (change Cloud Run Job → Service). + +--- + +## §6 pygeoapi + +### §6.1 Role + +pygeoapi serves the GCS-stored GeoJSON files as OGC API - Features collections. +No DB. pygeoapi uses the **OGR provider** backed by GDAL's `/vsigs/` virtual filesystem, +which reads GeoJSON directly from GCS using Application Default Credentials on Cloud Run. + +``` +GET /collections +GET /collections/{id}/items +GET /collections/{id}/items/{feature_id} +GET /collections/{id}/items?bbox=-107,32,-103,37 +GET /collections/{id}/items?datetime=2020-01-01/2024-12-31 ← timeseries only +``` + +### §6.2 pygeoapi Config Template (`orchestration/pygeoapi/config.yml.j2`) + +```yaml +server: + bind: + host: 0.0.0.0 + port: 80 + url: ${PYGEOAPI_SERVER_URL} + mimetype: application/json + encoding: utf-8 + language: en-US + cors: true + pretty_print: false + limit: 500 + +logging: + level: ERROR + +metadata: + identification: + title: NM Unified Water Data + description: OGC API - Features for New Mexico water data + keywords: [water, groundwater, "New Mexico", NMBGMR] + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: https://waterdata.nmt.edu + license: + name: CC-BY 4.0 + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: NM Bureau of Geology & Mineral Resources + url: https://geoinfo.nmt.edu + +resources: +{% for product in products %} + {{ product.id }}: + type: collection + title: {{ product.title }} + description: {{ product.description }} + keywords: [water, groundwater, "New Mexico"] + extent: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 +{% if product.output_type == 'ogc_timeseries' %} + temporal: + interval: [["1900-01-01T00:00:00Z", null]] +{% endif %} + providers: + - type: feature + name: OGR + data: + source_type: GeoJSON + source: /vsigs/{{ gcs_bucket }}/{{ product.id }}/latest.geojson + source_options: + GDAL_HTTP_UNSAFESSL: NO + gdal_ogr_options: + EMPTY_AS_NULL: NO + GDAL_CACHEMAX: 64 + id_field: id + layer: OGRGeoJSON +{% if product.output_type == 'ogc_timeseries' %} + time_field: datetime +{% endif %} + +{% endfor %} +``` + +### §6.3 Config Generation (`orchestration/pygeoapi/generate_config.py`) + +```python +import yaml +from jinja2 import Environment, FileSystemLoader +from pathlib import Path + +def generate(products_path: Path, template_path: Path, output_path: Path): + products = yaml.safe_load(products_path.read_text()) + env = Environment(loader=FileSystemLoader(str(template_path.parent))) + tmpl = env.get_template(template_path.name) + output_path.write_text(tmpl.render( + products=products["products"], + gcs_bucket=products["gcs_bucket"], + )) +``` + +Run at Docker build time in `cloudbuild.yaml` — baked into image, not runtime. + +### §6.4 GDAL + GCS Auth + +On Cloud Run, GDAL `/vsigs/` uses the service account's ADC automatically. +No credentials file needed. Require the pygeoapi Cloud Run Service account to have +`roles/storage.objectViewer` on the `die-products` bucket. + +For local dev: +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa-key.json +``` + +### §6.5 Dockerfile (`orchestration/pygeoapi/Dockerfile`) + +```dockerfile +FROM geopython/pygeoapi:latest + +# Generate config from products.yaml at build time +COPY ../config/products.yaml /tmp/products.yaml +COPY config.yml.j2 /tmp/config.yml.j2 +COPY generate_config.py /tmp/generate_config.py +RUN python /tmp/generate_config.py \ + --products /tmp/products.yaml \ + --template /tmp/config.yml.j2 \ + --output /pygeoapi/local.config.yml + +EXPOSE 80 +``` + +Cloud Run Service env vars: +- `PYGEOAPI_SERVER_URL` — public Cloud Run URL +- Port: 80 + +--- + +## §7 uv Migration + +### §7.1 Root `pyproject.toml` + +Replaces `setup.py`, `requirements.txt`, `pytest.ini`, `mypy.ini`: + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "nmuwd" +version = "0.10.3" +requires-python = ">=3.10" +dependencies = [ + "click>=8.2.1", + "python-dotenv", + "frost_sta_client", + "geopandas", + "httpx", + "pandas", + "pyyaml", + "types-pyyaml", + "urllib3>=2.2.0,<3.0.0", +] + +[project.optional-dependencies] +dev = ["pytest", "mypy", "flake8"] +geoserver = ["psycopg2-binary", "GeoAlchemy2", "SQLAlchemy"] +gcs = ["google-cloud-storage"] + +[project.scripts] +die = "frontend.cli:cli" + +[tool.hatch.build.targets.wheel] +packages = ["frontend", "backend"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = ["tests/archived"] + +[tool.mypy] +ignore_missing_imports = true +``` + +`flask`, `gunicorn` removed from core — belong in deployment layer. + +### §7.2 `orchestration/pyproject.toml` + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "die-orchestration" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "dagster>=1.8", + "dagster-gcp>=0.24", + "dagster-webserver>=1.8", + "google-cloud-storage", + "google-cloud-secret-manager", + "Jinja2", +] + +[tool.uv.sources] +nmuwd = { path = "..", editable = true } +``` + +No DB deps. pygeoapi runs in its own image — not a Python dep here. + +--- + +## §8 Dockerfile — Dagster Cloud Run Job + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +COPY orchestration/pyproject.toml ./orchestration/ +COPY pyproject.toml ./ +RUN uv sync --frozen --project orchestration + +COPY backend/ ./backend/ +COPY frontend/ ./frontend/ +COPY orchestration/ ./orchestration/ + +ENV DAGSTER_HOME=/app/.dagster +ENV PYTHONPATH=/app + +CMD ["uv", "run", "--project", "orchestration", \ + "dagster", "job", "execute", \ + "-f", "orchestration/definitions.py", \ + "-j", "${PRODUCT_ID}"] +``` + +Cloud Run Job env vars (from Secret Manager): +`PRODUCT_ID`, `GCS_BUCKET`, `USGS_API_KEY` + +--- + +## §9 Tasks + +### §T.1 [~] uv migration +- Delete `setup.py`, `requirements.txt`, `pytest.ini`, `mypy.ini` +- Write root `pyproject.toml` (§7.1) +- Run `uv lock` +- Update `.github/workflows/cicd.yml`: `uv run pytest`, `uv run mypy`, `uv run flake8` +- Verify: `uv pip install -e ".[dev]"` + all existing tests pass + +### §T.2 [x] OGC Features persister +- Add `backend/persisters/ogc_features.py` → `OGCFeaturesPersister` + - `dump_summary_collection(path, records, meta)` — §4.2 + - `dump_timeseries_collection(path, site_records, timeseries_records, meta)` — §4.3 flat format +- Add `ogc_summary`, `ogc_timeseries` to `OutputFormat` enum in `backend/__init__.py` +- Tests: `tests/test_persisters/test_ogc_features.py` + +### §T.3 [x] Orchestration scaffolding +- Create `orchestration/` directory (§3.1) +- Write `orchestration/pyproject.toml` (§7.2) +- Write `orchestration/config/products.yaml` (§5.2) +- Write `orchestration/resources/die_config.py` — Dagster resource wrapping `Config` +- Write `orchestration/resources/gcs.py` — upload/overwrite GCS objects + +### §T.4 [x] Dagster assets +- Port `jir-dagster` branch assets into `orchestration/assets/` +- Rewrite to: load products.yaml → dynamically define one asset per product +- Each asset: build `Config` from product spec → call unifier → `OGCFeaturesPersister` → GCS upload +- Write `orchestration/definitions.py` +- Local test: `uv run dagster asset materialize -f orchestration/definitions.py --select nm_waterlevels_summary` + +### §T.5 [x] Time-series well assets +- `orchestration/assets/waterlevels.py` — `ogc_timeseries` product type +- Flat observation-per-feature output (§4.3) with `datetime` field +- Reuses existing `unify_waterlevels` — no backend changes + +### §T.6 [x] GCS output resource +- `orchestration/resources/gcs.py` +- Upload: `gs://{bucket}/products/{product_id}/{YYYY-MM-DD}.geojson` +- Overwrite: `gs://{bucket}/products/{product_id}/latest.geojson` +- Emit Dagster `AssetMaterialization` metadata: feature count, bbox, file size, timestamp + +### §T.7 [x] Cloud Run + Dockerfile +- Write `orchestration/Dockerfile` (§8) +- Write `orchestration/cloudbuild.yaml` +- Write `orchestration/cloudrun.yaml` — Cloud Run Job definition +- Write `orchestration/README.md` — env vars, Secret Manager bindings, deploy commands + +### §T.8 [x] CI update +- Update `cicd.yml`: use `uv run` for all checks +- Add `orchestration-ci.yml`: lint + import-check for orchestration code +- Orchestration CI never triggers PyPI publish + +### §T.9 [x] pygeoapi +- Write `orchestration/pygeoapi/config.yml.j2` (§6.2) +- Write `orchestration/pygeoapi/generate_config.py` (§6.3) +- Write `orchestration/pygeoapi/Dockerfile` (§6.5) +- Write `orchestration/pygeoapi/cloudbuild.yaml` +- Verify GDAL `/vsigs/` reads from GCS with ADC in local Docker test +- Smoke test: `GET /collections` returns one entry per product in `products.yaml` + +--- + +## §V Invariants + +- Orchestration code MUST NOT appear in `[tool.hatch.build.targets.wheel].packages` +- OGC FC output MUST include top-level `id`, `type`, `numberReturned`, `timeStamp` +- Each Feature MUST have top-level `id` (not only in properties) +- `ogc_timeseries` features MUST be flat (one per observation) with ISO 8601 `datetime` property +- `die` CLI behavior unchanged after uv migration +- All existing tests pass under `uv run pytest` +- pygeoapi config MUST be generated from `products.yaml` — never hand-edited +- pygeoapi OGR provider MUST use `/vsigs/` path (GCS), never local filesystem path +- No database introduced in orchestration pipeline — GCS is sole store +- `latest.geojson` MUST be overwritten atomically (upload to tmp key, then copy/rename) + +## §B Bug Log + +### §B.1 CLI `--no-*` flags non-functional (fixed in §T.1) +**Cause:** `ALL_SOURCE_OPTIONS` used `is_flag=True, default=True` — Click flag presence also sets `True`, so both states gave `True`. Assignment `use_source_X = no_X` further confused the polarity. +**Fix:** `default=False` on all `--no-*` options + `not lcs.get(f"no_{agency}", False)` in `weave` and `sites` commands. +**Invariant added:** `--no-*` flags MUST have `default=False`; assignment MUST negate the flag value. diff --git a/backend/__init__.py b/backend/__init__.py index 804491c..d6f1b8f 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -6,6 +6,8 @@ class OutputFormat(str, Enum): GEOJSON = "geojson" CSV = "csv" GEOSERVER = "geoserver" + OGC_SUMMARY = "ogc_summary" + OGC_TIMESERIES = "ogc_timeseries" def get_bool_env_variable(var: str) -> bool: diff --git a/backend/persisters/ogc_features.py b/backend/persisters/ogc_features.py new file mode 100644 index 0000000..fa46c32 --- /dev/null +++ b/backend/persisters/ogc_features.py @@ -0,0 +1,164 @@ +# =============================================================================== +# Copyright 2024 Jake Ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# =============================================================================== +import json +from datetime import datetime, timezone +from typing import Optional + + +def _timestamp_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _make_feature(record, collection_id: str) -> dict: + """Build one OGC-compliant Feature from a SummaryRecord or SiteRecord.""" + source = getattr(record, "source", "") + rid = getattr(record, "id", "") + feature_id = f"{source}:{rid}" if source and rid else str(rid) + + props = {k: getattr(record, k) for k in record.keys + if k not in ("latitude", "longitude", "elevation")} + + lat = getattr(record, "latitude", None) + lon = getattr(record, "longitude", None) + elev = getattr(record, "elevation", None) + + coords = [lon, lat] if elev is None else [lon, lat, elev] + + return { + "type": "Feature", + "id": feature_id, + "geometry": {"type": "Point", "coordinates": coords}, + "properties": props, + } + + +def dump_summary_collection(path: str, records: list, meta: dict) -> dict: + """ + Write an OGC FeatureCollection of summary/site records to *path*. + + meta keys (all optional): + id, title, description — collection metadata + + Returns the collection dict (for testing). + §V: MUST include top-level id, type, numberReturned, timeStamp. + §V: Each Feature MUST have top-level id. + """ + collection_id = meta.get("id", "collection") + features = [_make_feature(r, collection_id) for r in records] + + collection = { + "type": "FeatureCollection", + "id": collection_id, + "title": meta.get("title", collection_id), + "description": meta.get("description", ""), + "timeStamp": _timestamp_now(), + "numberMatched": len(features), + "numberReturned": len(features), + "links": [ + { + "href": meta.get("href", ""), + "rel": "self", + "type": "application/geo+json", + } + ], + "features": features, + } + + with open(path, "w", encoding="utf-8") as f: + json.dump(collection, f, indent=2, default=str) + + return collection + + +def dump_timeseries_collection( + path: str, + site_records: list, + timeseries_records: list, + meta: dict, + site_lookup: Optional[dict] = None, +) -> dict: + """ + Write an OGC FeatureCollection of flat timeseries observations to *path*. + + Each feature = one observation (not one well). + §V: ogc_timeseries features MUST be flat, one per observation. + §V: MUST have ISO 8601 `datetime` property. + §V: Each Feature MUST have top-level id. + + site_lookup: {site_id -> SiteRecord} for geometry lookup. + Built from site_records if not provided. + """ + collection_id = meta.get("id", "collection") + + if site_lookup is None: + site_lookup = {} + for sr in site_records: + key = getattr(sr, "id", None) + if key: + site_lookup[key] = sr + + features = [] + for obs in timeseries_records: + site_id = getattr(obs, "id", None) + site = site_lookup.get(site_id) + + if site: + lat = getattr(site, "latitude", None) + lon = getattr(site, "longitude", None) + elev = getattr(site, "elevation", None) + coords = [lon, lat] if elev is None else [lon, lat, elev] + geometry = {"type": "Point", "coordinates": coords} + else: + geometry = None + + date = getattr(obs, "date_measured", None) + time = getattr(obs, "time_measured", None) + if date and time: + dt = f"{date}T{time}Z" + elif date: + dt = f"{date}T00:00:00Z" + else: + dt = None + + source = getattr(obs, "source", "") + feature_id = f"{source}:{site_id}:{date}" if date else f"{source}:{site_id}" + + props = {k: getattr(obs, k) for k in obs.keys} + props["datetime"] = dt + + features.append({ + "type": "Feature", + "id": feature_id, + "geometry": geometry, + "properties": props, + }) + + collection = { + "type": "FeatureCollection", + "id": collection_id, + "title": meta.get("title", collection_id), + "description": meta.get("description", ""), + "timeStamp": _timestamp_now(), + "numberMatched": len(features), + "numberReturned": len(features), + "links": [ + { + "href": meta.get("href", ""), + "rel": "self", + "type": "application/geo+json", + } + ], + "features": features, + } + + with open(path, "w", encoding="utf-8") as f: + json.dump(collection, f, indent=2, default=str) + + return collection diff --git a/backend/unifier.py b/backend/unifier.py index a3a5830..1f7a39b 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -19,7 +19,10 @@ from backend.logger import setup_logging from backend.constants import WATERLEVELS from backend.persister import BasePersister -from backend.persisters.geoserver import GeoServerPersister +try: + from backend.persisters.geoserver import GeoServerPersister +except ImportError: + GeoServerPersister = None from backend.source import BaseSiteSource from backend.exceptions import USGSRateLimitError, PartialOrNoDataError @@ -247,6 +250,11 @@ def _unify_parameter( ): if config.output_format == OutputFormat.GEOSERVER: + if GeoServerPersister is None: + raise ImportError( + "GeoServer output requires 'geoserver' extras: " + "pip install nmuwd[geoserver]" + ) persister = GeoServerPersister(config) else: persister = BasePersister(config) diff --git a/frontend/cli.py b/frontend/cli.py index 34a3a34..be226cf 100644 --- a/frontend/cli.py +++ b/frontend/cli.py @@ -37,84 +37,84 @@ def cli(): click.option( "--no-bernco", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude Bernalillo County Water Authority data. Default is to include", ), click.option( "--no-bor", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude BoR data. Default is to include", ), click.option( "--no-cabq", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude CABQ data. Default is to include", ), click.option( "--no-ebid", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude EBID data. Default is to include", ), click.option( "--no-nmbgmr-amp", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NMBGMR AMP data. Default is to include", ), click.option( "--no-nmed-dwb", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NMED DWB data. Default is to include", ), click.option( "--no-nmose-isc-seven-rivers", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NMOSE ISC Seven Rivers data. Default is to include", ), click.option( "--no-nmose-pod", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NMOSE POD data. Default is to include", ), click.option( "--no-nmose-roswell", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NMOSE Roswell data. Default is to include", ), click.option( "--no-nwis", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude NWIS data. Default is to include", ), click.option( "--no-pvacd", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude PVACD data. Default is to include", ), click.option( "--no-wqp", is_flag=True, - default=True, + default=False, show_default=True, help="Exclude WQP data. Default is to include", ), @@ -317,7 +317,7 @@ def weave( lcs = locals() if config_agencies: for agency in config_agencies: - setattr(config, f"use_source_{agency}", lcs.get(f"no_{agency}", False)) + setattr(config, f"use_source_{agency}", not lcs.get(f"no_{agency}", False)) # dates config.start_date = start_date config.end_date = end_date @@ -400,7 +400,7 @@ def sites( if config_path is None: lcs = locals() for agency in config_agencies: - setattr(config, f"use_source_{agency}", lcs.get(f"no_{agency}", False)) + setattr(config, f"use_source_{agency}", not lcs.get(f"no_{agency}", False)) config.output_dir = output_dir config.sites_only = True diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 4904098..0000000 --- a/mypy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[mypy] -ignore_missing_imports = True -exclude = ^(venv|.github|.mypy_cache|.pytest_cache|nmuwd.egg-info|__pycache__|build|tests/archived) -plugins = sqlalchemy.ext.mypy.plugin diff --git a/orchestration/Dockerfile b/orchestration/Dockerfile new file mode 100644 index 0000000..47e54f7 --- /dev/null +++ b/orchestration/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Install system deps needed by geopandas / shapely +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgdal-dev \ + libgeos-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy lockfiles and project manifests first (layer cache) +COPY pyproject.toml uv.lock ./ +COPY orchestration/pyproject.toml ./orchestration/ + +# Install all deps (root package + orchestration) +RUN uv sync --frozen --extra gcs && \ + uv pip install \ + dagster>=1.8 \ + dagster-gcp>=0.24 \ + google-cloud-storage \ + google-cloud-secret-manager \ + Jinja2 + +# Copy source code +COPY backend/ ./backend/ +COPY frontend/ ./frontend/ +COPY orchestration/ ./orchestration/ + +ENV PYTHONPATH=/app +ENV DAGSTER_HOME=/app/.dagster + +# Cloud Run Job: PRODUCT_ID env var selects which asset to materialize. +# Cloud Scheduler sets PRODUCT_ID in the job invocation. +CMD ["sh", "-c", \ + "uv run dagster asset materialize \ + -f orchestration/definitions.py \ + --select ${PRODUCT_ID}"] diff --git a/orchestration/README.md b/orchestration/README.md new file mode 100644 index 0000000..2577fd1 --- /dev/null +++ b/orchestration/README.md @@ -0,0 +1,83 @@ +# DIE Orchestration + +Dagster-based pipeline that runs on a schedule, fetches data from 12 NM water sources, +and writes OGC Feature Collections to GCS for serving via pygeoapi. + +## Architecture + +``` +Cloud Scheduler → Cloud Run Job (this image) + └── Dagster asset materialize + ├── DIE unifier (backend/) + ├── OGCFeaturesPersister → .geojson + └── GCSResource → gs://die-products/ +``` + +## Products + +Configured in `config/products.yaml`. Each product becomes a Dagster asset + Cloud Scheduler job. + +## Local Development + +```bash +# Install all deps +uv sync --extra gcs +uv pip install dagster dagster-gcp + +# Run a specific product locally (uses local filesystem, no GCS) +PRODUCT_ID=nm_waterlevels_summary \ +uv run dagster asset materialize -f orchestration/definitions.py --select nm_waterlevels_summary + +# View asset graph in Dagster UI +uv run dagster dev -f orchestration/definitions.py +``` + +## Environment Variables + +| Variable | Source | Description | +|----------|--------|-------------| +| `PRODUCT_ID` | Cloud Scheduler | Which asset to materialize | +| `GCS_BUCKET` | Cloud Run env | GCS bucket for output (default: `die-products`) | +| `USGS_API_KEY` | Secret Manager | USGS rate-limit key | + +## GCP Setup + +### Service Account + +```bash +gcloud iam service-accounts create die-orchestration-sa \ + --display-name="DIE Orchestration" + +# GCS write access +gcloud storage buckets add-iam-policy-binding gs://die-products \ + --member="serviceAccount:die-orchestration-sa@PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/storage.objectAdmin" + +# Secret Manager read +gcloud projects add-iam-policy-binding PROJECT_ID \ + --member="serviceAccount:die-orchestration-sa@PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" +``` + +### Build & Deploy + +```bash +# Build image +gcloud builds submit --config orchestration/cloudbuild.yaml . + +# Deploy job +sed -i 's/PROJECT_ID/your-project-id/g' orchestration/cloudrun.yaml +gcloud run jobs replace orchestration/cloudrun.yaml --region us-central1 + +# Create Cloud Scheduler trigger per product +gcloud scheduler jobs create http die-nm_waterlevels_summary \ + --schedule="0 6 * * *" \ + --time-zone="UTC" \ + --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT_ID/jobs/die-orchestration:run" \ + --message-body='{"overrides": {"containerOverrides": [{"env": [{"name": "PRODUCT_ID", "value": "nm_waterlevels_summary"}]}]}}' \ + --oauth-service-account-email=die-orchestration-sa@PROJECT_ID.iam.gserviceaccount.com +``` + +## pygeoapi + +See `pygeoapi/README.md` for serving the GCS-stored products via OGC API - Features. diff --git a/orchestration/__init__.py b/orchestration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestration/assets/__init__.py b/orchestration/assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestration/assets/analytes.py b/orchestration/assets/analytes.py new file mode 100644 index 0000000..da6c1f9 --- /dev/null +++ b/orchestration/assets/analytes.py @@ -0,0 +1,45 @@ +import tempfile +from pathlib import Path + +import dagster as dg + +from backend.unifier import unify_analytes +from backend.persisters.ogc_features import dump_summary_collection +from orchestration.resources.die_config import DIEConfigResource +from orchestration.resources.gcs import GCSResource + + +def build_analyte_summary_asset(product: dict): + @dg.asset(name=product["id"], group_name="analytes") + def _analyte_asset( + die_config: DIEConfigResource, + gcs: GCSResource, + ) -> dg.MaterializeResult: + config = die_config.get_config(product) + config.output_summary = True + + with tempfile.TemporaryDirectory() as tmpdir: + unify_analytes(config) + + persister = getattr(config, "_persister", None) + records = persister.records if persister else [] + + out = Path(tmpdir) / "collection.geojson" + meta = { + "id": product["id"], + "title": product.get("title", product["id"]), + "description": product.get("description", ""), + } + dump_summary_collection(str(out), records, meta) + + info = gcs.upload_product(str(out), product["id"]) + + return dg.MaterializeResult( + metadata={ + "feature_count": dg.MetadataValue.int(info["feature_count"]), + "dated_uri": dg.MetadataValue.url(info["dated_uri"]), + "latest_uri": dg.MetadataValue.url(info["latest_uri"]), + } + ) + + return _analyte_asset diff --git a/orchestration/assets/waterlevels.py b/orchestration/assets/waterlevels.py new file mode 100644 index 0000000..51ac2a4 --- /dev/null +++ b/orchestration/assets/waterlevels.py @@ -0,0 +1,90 @@ +import tempfile +from pathlib import Path + +import dagster as dg + +from backend.unifier import unify_waterlevels +from backend.persisters.ogc_features import dump_summary_collection, dump_timeseries_collection +from orchestration.resources.die_config import DIEConfigResource +from orchestration.resources.gcs import GCSResource + + +def build_waterlevels_summary_asset(product: dict): + @dg.asset(name=product["id"], group_name="waterlevels") + def _wl_summary_asset( + die_config: DIEConfigResource, + gcs: GCSResource, + ) -> dg.MaterializeResult: + config = die_config.get_config(product) + config.output_summary = True + + with tempfile.TemporaryDirectory() as tmpdir: + unify_waterlevels(config) + + persister = getattr(config, "_persister", None) + records = persister.records if persister else [] + + out = Path(tmpdir) / "collection.geojson" + meta = { + "id": product["id"], + "title": product.get("title", product["id"]), + "description": product.get("description", ""), + } + dump_summary_collection(str(out), records, meta) + info = gcs.upload_product(str(out), product["id"]) + + return dg.MaterializeResult( + metadata={ + "feature_count": dg.MetadataValue.int(info["feature_count"]), + "dated_uri": dg.MetadataValue.url(info["dated_uri"]), + "latest_uri": dg.MetadataValue.url(info["latest_uri"]), + } + ) + + return _wl_summary_asset + + +def build_waterlevels_timeseries_asset(product: dict): + """ + §V: ogc_timeseries features MUST be flat (one per observation). + §V: MUST have ISO 8601 `datetime` property. + §V: Each Feature MUST have top-level id. + """ + + @dg.asset(name=product["id"], group_name="waterlevels") + def _wl_ts_asset( + die_config: DIEConfigResource, + gcs: GCSResource, + ) -> dg.MaterializeResult: + config = die_config.get_config(product) + config.output_summary = False + config.output_timeseries_unified = True + + with tempfile.TemporaryDirectory() as tmpdir: + unify_waterlevels(config) + + persister = getattr(config, "_persister", None) + site_records = persister.sites if persister else [] + timeseries = persister.timeseries if persister else [] + + # timeseries is list-of-lists (per site); flatten to list of records + flat_timeseries = [obs for site_ts in timeseries for obs in site_ts] + + out = Path(tmpdir) / "collection.geojson" + meta = { + "id": product["id"], + "title": product.get("title", product["id"]), + "description": product.get("description", ""), + } + dump_timeseries_collection(str(out), site_records, flat_timeseries, meta) + info = gcs.upload_product(str(out), product["id"]) + + return dg.MaterializeResult( + metadata={ + "feature_count": dg.MetadataValue.int(info["feature_count"]), + "dated_uri": dg.MetadataValue.url(info["dated_uri"]), + "latest_uri": dg.MetadataValue.url(info["latest_uri"]), + } + ) + + return _wl_ts_asset diff --git a/orchestration/assets/wells.py b/orchestration/assets/wells.py new file mode 100644 index 0000000..e62acca --- /dev/null +++ b/orchestration/assets/wells.py @@ -0,0 +1,47 @@ +import tempfile +from pathlib import Path + +import dagster as dg + +from backend.unifier import unify_sites +from backend.persisters.ogc_features import dump_summary_collection +from orchestration.resources.die_config import DIEConfigResource +from orchestration.resources.gcs import GCSResource + + +def build_wells_asset(product: dict): + @dg.asset(name=product["id"], group_name="wells") + def _wells_asset( + die_config: DIEConfigResource, + gcs: GCSResource, + ) -> dg.MaterializeResult: + config = die_config.get_config(product) + config.sites_only = True + + with tempfile.TemporaryDirectory() as tmpdir: + unify_sites(config) + + # Collect sites from persister (set by unify_sites via _unify_parameter) + from backend.persister import BasePersister + sites = config._persister.sites if hasattr(config, "_persister") else [] + + out = Path(tmpdir) / "collection.geojson" + meta = { + "id": product["id"], + "title": product.get("title", product["id"]), + "description": product.get("description", ""), + } + dump_summary_collection(str(out), sites, meta) + + info = gcs.upload_product(str(out), product["id"]) + + return dg.MaterializeResult( + metadata={ + "feature_count": dg.MetadataValue.int(info["feature_count"]), + "dated_uri": dg.MetadataValue.url(info["dated_uri"]), + "latest_uri": dg.MetadataValue.url(info["latest_uri"]), + "file_size_bytes": dg.MetadataValue.int(info["file_size_bytes"]), + } + ) + + return _wells_asset diff --git a/orchestration/cloudbuild.yaml b/orchestration/cloudbuild.yaml new file mode 100644 index 0000000..35d75e5 --- /dev/null +++ b/orchestration/cloudbuild.yaml @@ -0,0 +1,27 @@ +steps: + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-orchestration:$COMMIT_SHA' + - '-t' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-orchestration:latest' + - '-f' + - 'orchestration/Dockerfile' + - '.' + + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - '--all-tags' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-orchestration' + +substitutions: + _REGION: us-central1 + +images: + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-orchestration:$COMMIT_SHA' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-orchestration:latest' + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/orchestration/cloudrun.yaml b/orchestration/cloudrun.yaml new file mode 100644 index 0000000..04db69d --- /dev/null +++ b/orchestration/cloudrun.yaml @@ -0,0 +1,38 @@ +# Cloud Run Job definition for DIE orchestration. +# Deploy with: +# gcloud run jobs replace orchestration/cloudrun.yaml +# +# Each product in products.yaml gets its own Cloud Scheduler trigger +# pointing at this job with PRODUCT_ID set to the product id. + +apiVersion: run.googleapis.com/v1 +kind: Job +metadata: + name: die-orchestration + labels: + app: die +spec: + template: + spec: + taskCount: 1 + timeoutSeconds: 3600 + template: + spec: + containers: + - image: us-central1-docker.pkg.dev/PROJECT_ID/die/die-orchestration:latest + env: + - name: PRODUCT_ID + value: nm_waterlevels_summary # overridden per Cloud Scheduler job + - name: GCS_BUCKET + value: die-products + - name: USGS_API_KEY + valueFrom: + secretKeyRef: + name: usgs-api-key + key: latest + resources: + limits: + cpu: "2" + memory: "4Gi" + serviceAccountName: die-orchestration-sa@PROJECT_ID.iam.gserviceaccount.com + maxRetries: 1 diff --git a/orchestration/config/products.yaml b/orchestration/config/products.yaml new file mode 100644 index 0000000..f0abfcd --- /dev/null +++ b/orchestration/config/products.yaml @@ -0,0 +1,46 @@ +gcs_bucket: die-products + +products: + - id: nm_waterlevels_summary + parameter: waterlevels + output_type: ogc_summary + title: "NM Unified Water Levels Summary" + description: "Summary stats for water levels, all NM sources" + schedule: "0 6 * * *" + spatial_filter: + state: NM + sources: + exclude: [] + + - id: nm_waterlevels_timeseries + parameter: waterlevels + output_type: ogc_timeseries + title: "NM Water Levels Time Series" + description: "Per-observation water level measurements, all NM sources" + schedule: "0 7 * * *" + spatial_filter: + state: NM + sources: + exclude: [] + + - id: bernco_waterlevels_timeseries + parameter: waterlevels + output_type: ogc_timeseries + title: "Bernalillo County Water Level Time Series" + description: "Bernalillo County water level timeseries per well" + schedule: "0 8 * * *" + spatial_filter: + county: Bernalillo + sources: + include: [bernco] + + - id: nm_arsenic_summary + parameter: arsenic + output_type: ogc_summary + title: "NM Arsenic Summary" + description: "Arsenic concentration summary stats, all NM sources" + schedule: "0 9 * * *" + spatial_filter: + state: NM + sources: + exclude: [] diff --git a/orchestration/definitions.py b/orchestration/definitions.py new file mode 100644 index 0000000..0af0de2 --- /dev/null +++ b/orchestration/definitions.py @@ -0,0 +1,68 @@ +from pathlib import Path + +import dagster as dg +import yaml + +from orchestration.resources.die_config import DIEConfigResource +from orchestration.resources.gcs import GCSResource +from orchestration.assets.waterlevels import ( + build_waterlevels_summary_asset, + build_waterlevels_timeseries_asset, +) +from orchestration.assets.analytes import build_analyte_summary_asset + +_PRODUCTS_PATH = Path(__file__).parent / "config" / "products.yaml" + + +def _load_products() -> dict: + return yaml.safe_load(_PRODUCTS_PATH.read_text()) + + +def _build_assets(products_config: dict) -> list: + assets = [] + for product in products_config["products"]: + param = product["parameter"] + output_type = product["output_type"] + + if param == "waterlevels" and output_type == "ogc_summary": + assets.append(build_waterlevels_summary_asset(product)) + elif param == "waterlevels" and output_type == "ogc_timeseries": + assets.append(build_waterlevels_timeseries_asset(product)) + elif output_type == "ogc_summary": + assets.append(build_analyte_summary_asset(product)) + + return assets + + +def _build_schedules(products_config: dict, assets: list) -> list: + asset_names = {a.key.path[-1] for a in assets} + schedules = [] + for product in products_config["products"]: + pid = product["id"] + if pid not in asset_names: + continue + schedules.append( + dg.ScheduleDefinition( + name=f"schedule_{pid}", + target=dg.AssetSelection.keys(pid), + cron_schedule=product.get("schedule", "0 6 * * *"), + execution_timezone="America/Denver", + ) + ) + return schedules + + +_products_config = _load_products() +_assets = _build_assets(_products_config) +_schedules = _build_schedules(_products_config, _assets) + +defs = dg.Definitions( + assets=_assets, + schedules=_schedules, + resources={ + "die_config": DIEConfigResource(), + "gcs": GCSResource( + bucket_name=_products_config.get("gcs_bucket", "die-products"), + ), + }, +) diff --git a/orchestration/pygeoapi/Dockerfile b/orchestration/pygeoapi/Dockerfile new file mode 100644 index 0000000..b87d49b --- /dev/null +++ b/orchestration/pygeoapi/Dockerfile @@ -0,0 +1,24 @@ +FROM geopython/pygeoapi:latest + +# pygeoapi base image includes GDAL with /vsigs/ GCS support. +# Auth uses Application Default Credentials — no key file needed on Cloud Run. + +WORKDIR /pygeoapi + +# Copy generation inputs +COPY config.yml.j2 /tmp/config.yml.j2 +COPY generate_config.py /tmp/generate_config.py +COPY ../config/products.yaml /tmp/products.yaml + +# Bake config into image at build time. +# §V: config generated from products.yaml, not hand-edited. +RUN pip install jinja2 pyyaml --quiet && \ + python /tmp/generate_config.py \ + --products /tmp/products.yaml \ + --template /tmp/config.yml.j2 \ + --output /pygeoapi/local.config.yml + +EXPOSE 80 + +# pygeoapi reads PYGEOAPI_CONFIG env var; default is local.config.yml +ENV PYGEOAPI_CONFIG=/pygeoapi/local.config.yml diff --git a/orchestration/pygeoapi/cloudbuild.yaml b/orchestration/pygeoapi/cloudbuild.yaml new file mode 100644 index 0000000..dabf080 --- /dev/null +++ b/orchestration/pygeoapi/cloudbuild.yaml @@ -0,0 +1,42 @@ +steps: + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-t' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi:$COMMIT_SHA' + - '-t' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi:latest' + - '-f' + - 'orchestration/pygeoapi/Dockerfile' + - 'orchestration/pygeoapi' # build context — allows COPY ../config/products.yaml + + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - '--all-tags' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi' + + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: gcloud + args: + - 'run' + - 'deploy' + - 'die-pygeoapi' + - '--image=$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi:$COMMIT_SHA' + - '--region=$_REGION' + - '--platform=managed' + - '--allow-unauthenticated' + - '--port=80' + - '--memory=512Mi' + - '--set-env-vars=PYGEOAPI_SERVER_URL=https://die-pygeoapi-$_SUFFIX.run.app' + +substitutions: + _REGION: us-central1 + _SUFFIX: placeholder # replace with Cloud Run URL suffix after first deploy + +images: + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi:$COMMIT_SHA' + - '$_REGION-docker.pkg.dev/$PROJECT_ID/die/die-pygeoapi:latest' + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/orchestration/pygeoapi/config.yml.j2 b/orchestration/pygeoapi/config.yml.j2 new file mode 100644 index 0000000..6eb32ba --- /dev/null +++ b/orchestration/pygeoapi/config.yml.j2 @@ -0,0 +1,82 @@ +server: + bind: + host: 0.0.0.0 + port: 80 + url: ${PYGEOAPI_SERVER_URL} + mimetype: application/json + encoding: utf-8 + language: en-US + cors: true + pretty_print: false + limit: 500 + +logging: + level: ERROR + +metadata: + identification: + title: NM Unified Water Data + description: OGC API - Features for New Mexico water data + keywords: + - water + - groundwater + - New Mexico + - NMBGMR + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: https://waterdata.nmt.edu + license: + name: CC-BY 4.0 + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: NM Bureau of Geology & Mineral Resources + url: https://geoinfo.nmt.edu + contact: + address: 801 Leroy Place + city: Socorro + stateorprovince: New Mexico + postalcode: "87801" + country: USA + +resources: +{% for product in products %} + {{ product.id }}: + type: collection + title: {{ product.title | tojson }} + description: {{ product.description | tojson }} + keywords: + - water + - groundwater + - New Mexico + extent: + spatial: + bbox: + - -109.05 + - 31.33 + - -103.00 + - 37.00 + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 +{% if product.output_type == 'ogc_timeseries' %} + temporal: + interval: + - - "1900-01-01T00:00:00Z" + - null +{% endif %} + providers: + - type: feature + name: OGR + data: + source_type: GeoJSON + source: /vsigs/{{ gcs_bucket }}/products/{{ product.id }}/latest.geojson + source_options: + GDAL_HTTP_UNSAFESSL: "NO" + gdal_ogr_options: + EMPTY_AS_NULL: "NO" + GDAL_CACHEMAX: "64" + id_field: id + layer: OGRGeoJSON +{% if product.output_type == 'ogc_timeseries' %} + time_field: datetime +{% endif %} + +{% endfor %} diff --git a/orchestration/pygeoapi/generate_config.py b/orchestration/pygeoapi/generate_config.py new file mode 100644 index 0000000..a9e4a90 --- /dev/null +++ b/orchestration/pygeoapi/generate_config.py @@ -0,0 +1,52 @@ +""" +Generate pygeoapi config.yml from products.yaml + Jinja2 template. + +§V: pygeoapi config MUST be generated from products.yaml — never hand-edited. +§V: pygeoapi OGR provider MUST use /vsigs/ path (GCS). + +Usage: + python generate_config.py \ + --products ../config/products.yaml \ + --template config.yml.j2 \ + --output /pygeoapi/local.config.yml +""" +import argparse +from pathlib import Path + +import yaml +from jinja2 import Environment, FileSystemLoader + + +def generate(products_path: Path, template_path: Path, output_path: Path) -> None: + products_config = yaml.safe_load(products_path.read_text()) + env = Environment( + loader=FileSystemLoader(str(template_path.parent)), + keep_trailing_newline=True, + ) + tmpl = env.get_template(template_path.name) + rendered = tmpl.render( + products=products_config["products"], + gcs_bucket=products_config["gcs_bucket"], + ) + + # Sanity check: every product must produce an OGR /vsigs/ entry + for product in products_config["products"]: + pid = product["id"] + bucket = products_config["gcs_bucket"] + expected = f"/vsigs/{bucket}/products/{pid}/latest.geojson" + assert expected in rendered, ( + f"§V violated: OGR provider for '{pid}' missing /vsigs/ path in generated config" + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered) + print(f"Generated {output_path} ({len(products_config['products'])} collections)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--products", required=True, type=Path) + parser.add_argument("--template", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + generate(args.products, args.template, args.output) diff --git a/orchestration/pyproject.toml b/orchestration/pyproject.toml new file mode 100644 index 0000000..ca41e35 --- /dev/null +++ b/orchestration/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "die-orchestration" +version = "0.1.0" +description = "DIE Dagster orchestration (internal, not published)" +requires-python = ">=3.10" +dependencies = [ + "dagster>=1.8", + "dagster-gcp>=0.24", + "dagster-webserver>=1.8", + "google-cloud-storage", + "google-cloud-secret-manager", + "Jinja2", + "nmuwd", +] + +[tool.uv.sources] +nmuwd = { path = "..", editable = true } + +[tool.hatch.build.targets.wheel] +packages = ["orchestration"] diff --git a/orchestration/resources/__init__.py b/orchestration/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestration/resources/die_config.py b/orchestration/resources/die_config.py new file mode 100644 index 0000000..ccbf911 --- /dev/null +++ b/orchestration/resources/die_config.py @@ -0,0 +1,41 @@ +from typing import Optional +import dagster as dg +from backend.config import Config + + +class DIEConfigResource(dg.ConfigurableResource): + """Dagster resource that constructs a DIE Config from a product spec dict.""" + + usgs_api_key: Optional[str] = None + + def get_config(self, product: dict) -> Config: + spatial = product.get("spatial_filter", {}) + sources_spec = product.get("sources", {}) + + payload: dict = { + "yes": True, + "output_summary": product.get("output_type") == "ogc_summary", + "output_format": product.get("output_type", "ogc_summary"), + } + + if spatial.get("county"): + payload["county"] = spatial["county"] + if spatial.get("state"): + payload["wkt"] = None + + if sources_spec.get("include"): + all_sources = [ + "bernco", "bor", "cabq", "ebid", "nmbgmr_amp", + "nmed_dwb", "nmose_isc_seven_rivers", "nmose_pod", + "nmose_roswell", "nwis", "pvacd", "wqp", + ] + for s in all_sources: + payload[f"use_source_{s}"] = s in sources_spec["include"] + elif sources_spec.get("exclude"): + for s in sources_spec["exclude"]: + payload[f"use_source_{s}"] = False + + config = Config(payload=payload) + config.parameter = product["parameter"] + config.finalize() + return config diff --git a/orchestration/resources/gcs.py b/orchestration/resources/gcs.py new file mode 100644 index 0000000..0400033 --- /dev/null +++ b/orchestration/resources/gcs.py @@ -0,0 +1,76 @@ +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import dagster as dg + +try: + from google.cloud import storage + _GCS_AVAILABLE = True +except ImportError: + _GCS_AVAILABLE = False + + +class GCSResource(dg.ConfigurableResource): + """ + Upload OGC Feature Collection GeoJSON files to GCS. + + §V: latest.geojson MUST be overwritten atomically + (copy from dated object, never direct overwrite of in-flight file). + §V: No database — GCS is the sole store. + """ + + bucket_name: str + products_prefix: str = "products" + + def _client(self): + if not _GCS_AVAILABLE: + raise ImportError("google-cloud-storage not installed") + return storage.Client() + + def upload_product( + self, + local_path: str, + product_id: str, + run_date: Optional[str] = None, + ) -> dict: + """ + Upload *local_path* as both a dated archive and latest.geojson. + + Returns dict with: + dated_uri: gs://bucket/products/{product_id}/{date}.geojson + latest_uri: gs://bucket/products/{product_id}/latest.geojson + feature_count: int + file_size_bytes: int + """ + if run_date is None: + run_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + client = self._client() + bucket = client.bucket(self.bucket_name) + + dated_key = f"{self.products_prefix}/{product_id}/{run_date}.geojson" + latest_key = f"{self.products_prefix}/{product_id}/latest.geojson" + + file_size = Path(local_path).stat().st_size + + dated_blob = bucket.blob(dated_key) + dated_blob.upload_from_filename(local_path, content_type="application/geo+json") + + # §V: atomic latest — copy from the just-uploaded dated blob, not + # another upload that could race with a concurrent reader. + bucket.copy_blob(dated_blob, bucket, latest_key) + + import json + with open(local_path, encoding="utf-8") as f: + data = json.load(f) + feature_count = data.get("numberReturned", len(data.get("features", []))) + + return { + "dated_uri": f"gs://{self.bucket_name}/{dated_key}", + "latest_uri": f"gs://{self.bucket_name}/{latest_key}", + "feature_count": feature_count, + "file_size_bytes": file_size, + "run_date": run_date, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..02dc69c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "nmuwd" +version = "0.10.3" +description = "New Mexico Water Data Integration Engine" +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [{ name = "Jake Ross" }] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +dependencies = [ + "click>=8.2.1", + "python-dotenv", + "frost_sta_client", + "geopandas", + "httpx", + "pandas", + "pyyaml", + "types-pyyaml", + "urllib3>=2.2.0,<3.0.0", +] + +[project.urls] +Homepage = "https://github.com/DataIntegrationGroup/DataIntegrationEngine" + +[project.optional-dependencies] +dev = ["pytest", "mypy", "flake8"] +geoserver = ["psycopg2-binary", "GeoAlchemy2", "SQLAlchemy"] +gcs = ["google-cloud-storage"] + +[project.scripts] +die = "frontend.cli:cli" + +[tool.hatch.build.targets.wheel] +packages = ["frontend", "backend"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = ["tests/archived"] + +[tool.mypy] +ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 8ea4712..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -; skip archived tests but keep for reference -norecursedirs = tests/archived \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index bd7f502..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -click==8.2.1 -python-dotenv -flask -frost_sta_client -Geoalchemy2 -geopandas -google-cloud-storage -gunicorn -httpx -mypy -pandas -psycopg2-binary -pytest -pyyaml -types-pyyaml -urllib3>=2.2.0,<3.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index da41f4c..0000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -# =============================================================================== -# Copyright 2024 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== - -from setuptools import setup, find_packages - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - - -setup( - name="nmuwd", - version="0.10.3", - author="Jake Ross", - description="New Mexico Water Data Integration Engine", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/DataIntegrationGroup/DataIntegrationEngine", - classifiers=[ - "Programming Language :: Python :: 3", - "Operating System :: OS Independent", - ], - install_requires=[ - "click==8.2.1", - "python-dotenv", - "flask", - "frost_sta_client", - "Geoalchemy2", - "geopandas", - "google-cloud-storage", - "gunicorn", - "httpx", - "mypy", - "pandas", - "psycopg2-binary", - "pytest", - "pyyaml", - "types-pyyaml", - "urllib3>=2.2.0,<3.0.0", - ], - entry_points={ - "console_scripts": [ - "die = frontend.cli:cli", - ], - }, - packages=["frontend", "backend"] - + [f"backend.{p}" for p in find_packages("backend")], - python_requires=">=3.6", - include_package_data=True, - # package_data={ - # If any package contains *.txt or *.rst files, include them: - # "templates": [ - # "*.template", - # ], - # }, -) -# ============= EOF ============================================= diff --git a/tests/test_persisters/__init__.py b/tests/test_persisters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_persisters/test_ogc_features.py b/tests/test_persisters/test_ogc_features.py new file mode 100644 index 0000000..61413df --- /dev/null +++ b/tests/test_persisters/test_ogc_features.py @@ -0,0 +1,173 @@ +import json +import os +import tempfile + +from backend.persisters.ogc_features import dump_summary_collection, dump_timeseries_collection +from backend.record import SummaryRecord, SiteRecord, WaterLevelRecord + + +def _make_summary_record(source="nmbgmr_amp", rid="RA-1234", lat=35.0, lon=-106.5): + return SummaryRecord({ + "source": source, + "id": rid, + "name": "Test Well", + "usgs_site_id": "", + "alternate_site_id": "", + "latitude": lat, + "longitude": lon, + "horizontal_datum": "WGS84", + "elevation": 1650.0, + "elevation_units": "ft", + "well_depth": None, + "well_depth_units": "ft", + "parameter_name": "waterlevels", + "parameter_units": "ft", + "nrecords": 10, + "min": 200.0, + "max": 250.0, + "mean": 225.0, + "earliest_date": "1990-01-01", + "earliest_time": "00:00:00", + "earliest_value": 200.0, + "earliest_units": "ft", + "latest_date": "2024-01-01", + "latest_time": "00:00:00", + "latest_value": 220.0, + "latest_units": "ft", + }) + + +def _make_site_record(source="nmbgmr_amp", rid="RA-1234", lat=35.0, lon=-106.5): + return SiteRecord({ + "source": source, + "id": rid, + "name": "Test Well", + "latitude": lat, + "longitude": lon, + "elevation": 1650.0, + "elevation_units": "ft", + "horizontal_datum": "WGS84", + "vertical_datum": "", + "usgs_site_id": "", + "alternate_site_id": "", + "formation": "", + "aquifer": "", + "well_depth": None, + "well_depth_units": "ft", + }) + + +def _make_wl_record(source="nmbgmr_amp", rid="RA-1234", date="2024-01-15", value=212.4): + return WaterLevelRecord({ + "source": source, + "id": rid, + "parameter_name": "waterlevels", + "parameter_value": value, + "parameter_units": "ft", + "date_measured": date, + "time_measured": "00:00:00", + "source_parameter_name": "depth_to_water", + "source_parameter_units": "ft", + "conversion_factor": 1.0, + }) + + +class TestDumpSummaryCollection: + def test_ogc_required_fields(self, tmp_path): + """§V: OGC FC MUST include top-level id, type, numberReturned, timeStamp.""" + records = [_make_summary_record()] + out = tmp_path / "summary.geojson" + result = dump_summary_collection(str(out), records, {"id": "nm_waterlevels"}) + + assert result["type"] == "FeatureCollection" + assert result["id"] == "nm_waterlevels" + assert "timeStamp" in result + assert "numberReturned" in result + assert result["numberReturned"] == 1 + + def test_feature_has_top_level_id(self, tmp_path): + """§V: Each Feature MUST have top-level id (not only in properties).""" + records = [_make_summary_record(source="nmbgmr_amp", rid="RA-1234")] + out = tmp_path / "summary.geojson" + result = dump_summary_collection(str(out), records, {"id": "test"}) + + feature = result["features"][0] + assert "id" in feature + assert feature["id"] == "nmbgmr_amp:RA-1234" + + def test_writes_valid_geojson_file(self, tmp_path): + records = [_make_summary_record(), _make_summary_record(rid="RA-5678")] + out = tmp_path / "summary.geojson" + dump_summary_collection(str(out), records, {"id": "test"}) + + with open(str(out)) as f: + data = json.load(f) + assert data["numberReturned"] == 2 + assert len(data["features"]) == 2 + + def test_geometry_has_coordinates(self, tmp_path): + records = [_make_summary_record(lat=35.123, lon=-106.456)] + out = tmp_path / "summary.geojson" + result = dump_summary_collection(str(out), records, {"id": "test"}) + + geom = result["features"][0]["geometry"] + assert geom["type"] == "Point" + assert geom["coordinates"][0] == -106.456 + assert geom["coordinates"][1] == 35.123 + + def test_empty_records(self, tmp_path): + out = tmp_path / "summary.geojson" + result = dump_summary_collection(str(out), [], {"id": "empty"}) + assert result["numberReturned"] == 0 + assert result["features"] == [] + + +class TestDumpTimeseriesCollection: + def test_flat_one_feature_per_observation(self, tmp_path): + """§V: ogc_timeseries MUST be flat (one per observation).""" + site = _make_site_record() + obs1 = _make_wl_record(date="2024-01-15", value=212.4) + obs2 = _make_wl_record(date="2024-04-20", value=218.1) + + out = tmp_path / "ts.geojson" + result = dump_timeseries_collection( + str(out), [site], [obs1, obs2], {"id": "nm_wl_ts"} + ) + + assert result["numberReturned"] == 2 + assert len(result["features"]) == 2 + + def test_iso8601_datetime_property(self, tmp_path): + """§V: MUST have ISO 8601 `datetime` property on each feature.""" + site = _make_site_record() + obs = _make_wl_record(date="2024-01-15") + + out = tmp_path / "ts.geojson" + result = dump_timeseries_collection(str(out), [site], [obs], {"id": "test"}) + + props = result["features"][0]["properties"] + assert "datetime" in props + assert props["datetime"].startswith("2024-01-15T") + + def test_feature_has_top_level_id(self, tmp_path): + """§V: Each Feature MUST have top-level id.""" + site = _make_site_record() + obs = _make_wl_record(date="2024-01-15") + + out = tmp_path / "ts.geojson" + result = dump_timeseries_collection(str(out), [site], [obs], {"id": "test"}) + + feature = result["features"][0] + assert "id" in feature + assert "nmbgmr_amp" in feature["id"] + assert "RA-1234" in feature["id"] + + def test_ogc_required_fields(self, tmp_path): + """§V: OGC FC MUST include type, id, numberReturned, timeStamp.""" + out = tmp_path / "ts.geojson" + result = dump_timeseries_collection(str(out), [], [], {"id": "nm_ts"}) + + assert result["type"] == "FeatureCollection" + assert result["id"] == "nm_ts" + assert "timeStamp" in result + assert "numberReturned" in result diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..434355e --- /dev/null +++ b/uv.lock @@ -0,0 +1,2020 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +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/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { 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]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "demjson3" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/d2/6a81a9b5311d50542e11218b470dafd8adbaf1b3e51fc1fddd8a57eed691/demjson3-3.0.6.tar.gz", hash = "sha256:37c83b0c6eb08d25defc88df0a2a4875d58a7809a9650bd6eee7afd8053cdbac", size = 131477, upload-time = "2022-10-22T19:09:05.379Z" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "frost-sta-client" +version = "1.1.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "demjson3" }, + { name = "furl" }, + { name = "geojson" }, + { name = "jsonpatch" }, + { name = "jsonpickle" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/71/85f70389b94ec827882f1f4487a5912d7063f53b0f65ed90647bd6a0f0b2/frost_sta_client-1.1.53.tar.gz", hash = "sha256:6701ed3019e801f225ccdb4462129df13bad9a9e7f1c4b35cc9758c4a9fe028d", size = 38663, upload-time = "2025-09-19T20:16:01.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/c7/298173a5698eb6cbb637f86f7b43a5b93c11fb1e0c78a77a6c5a27451c38/frost_sta_client-1.1.53-py2.py3-none-any.whl", hash = "sha256:ee680d2149c0342a89c312e88eadf6b495114b424d1d4fdf416f0cd8cafe2b2f", size = 61754, upload-time = "2025-09-19T20:16:00.847Z" }, +] + +[[package]] +name = "furl" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderedmultidict" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/e4/203a76fa2ef46cdb0a618295cc115220cbb874229d4d8721068335eb87f0/furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015", size = 57526, upload-time = "2025-03-09T05:36:21.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/8c/dce3b1b7593858eba995b2dfdb833f872c7f863e3da92aab7128a6b11af4/furl-2.1.4-py2.py3-none-any.whl", hash = "sha256:da34d0b34e53ffe2d2e6851a7085a05d96922b5b578620a37377ff1dbeeb11c8", size = 27550, upload-time = "2025-03-09T05:36:19.928Z" }, +] + +[[package]] +name = "geoalchemy2" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/74/6cb1ef591bf47d28f41aa770f2f3a91c0a570aee0a4083bed7f8c533d8df/geoalchemy2-0.20.0.tar.gz", hash = "sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322", size = 280805, upload-time = "2026-05-12T14:50:26.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/08/b66ad4239f592e05202e25925c08cdd04cc14c3994000ec70ec61fea202c/geoalchemy2-0.20.0-py3-none-any.whl", hash = "sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717", size = 96467, upload-time = "2026-05-12T14:50:24.998Z" }, +] + +[[package]] +name = "geojson" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/a9/bd61eee2c7904947094b74866b569f3fd5a8d6ac907ecdfecef74b19d459/geojson-3.3.0.tar.gz", hash = "sha256:92e83b9cb378a450b42f1207bb9b2a031f9fc89185f335153c44369b8b8b71fd", size = 25141, upload-time = "2026-05-28T21:48:08.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/5e/fdd72167b57158d743353f71d453200719744d1e75f18b1c8230508db370/geojson-3.3.0-py3-none-any.whl", hash = "sha256:a2d885187eeaa8b357600b3fcc9d963cb4300d1694196636dbd7eddc82fd0825", size = 15181, upload-time = "2026-05-28T21:48:06.648Z" }, +] + +[[package]] +name = "geopandas" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyogrio" }, + { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "shapely" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/ba/8e6b2091878e99e86a36a814dcaeff652ed48bdb03d53e78e15aaa63a914/geopandas-1.1.3.tar.gz", hash = "sha256:91a31989b6f566012838d21d5f8033f37dce882079ccb7cfdc40d5ccce7f284f", size = 336718, upload-time = "2026-03-09T21:49:09.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/78/6a04792ace63a93e162f1305392d500ae8ddcb620e7eb88a22fd622b35bb/geopandas-1.1.3-py3-none-any.whl", hash = "sha256:90d62a64f95eaa3be2ccc115c5f3d6e24208bb11983b390fdc0621a3eccd0230", size = 342514, upload-time = "2026-03-09T21:49:07.973Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, +] + +[[package]] +name = "google-auth" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/72/86f94e1639a8bcd9d33e8e01b49afcaa1c3a13bda7683c681717e0901e15/google_cloud_storage-3.12.0.tar.gz", hash = "sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be", size = 17338620, upload-time = "2026-06-12T18:03:29.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, + { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/3a/cd99db55dc908568f6b91845747b98b3b17a06052fa1803d091dc91da27d/greenlet-3.5.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8", size = 285626, upload-time = "2026-06-17T17:33:33.231Z" }, + { url = "https://files.pythonhosted.org/packages/ce/09/fd997a19cbb97641233c7d5f8fc89314c132be2c8867c4f14beff979996f/greenlet-3.5.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421", size = 601821, upload-time = "2026-06-17T18:07:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b0/62abd204addd913ad9856e091f5d8baaedc7c85df151f22f093b8a207c20/greenlet-3.5.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574", size = 615044, upload-time = "2026-06-17T18:29:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/34/67/ceaab731b51611a8238b0af2d4abb4fd727ec09b16cd499fca5295603f46/greenlet-3.5.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3", size = 615176, upload-time = "2026-06-17T17:39:25.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/51a0ee73b72a7e4a65b54433316bbd7b3b7902a585310cd4e3051d411ee3/greenlet-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7", size = 1574580, upload-time = "2026-06-17T18:22:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/41/d3/a3a2163b1fe73042d3e72cfcb9920f2481d5188a1df2645587a9b83a903f/greenlet-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16", size = 1641192, upload-time = "2026-06-17T17:40:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/b4d83fb451e2f7266cb45ccef23857f8a800e0a5d9a73263fafdf7ba7904/greenlet-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad", size = 238247, upload-time = "2026-06-17T17:34:54.794Z" }, + { url = "https://files.pythonhosted.org/packages/21/68/371ee6dad168be3386c46030bedaa8e3e7e3cf3d203621d4529e78ff36ef/greenlet-3.5.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5", size = 286925, upload-time = "2026-06-17T17:33:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/ed5706c26b4d26f3fabceb79abca992654eac8b0fa435def2ac6dbd92122/greenlet-3.5.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6", size = 606036, upload-time = "2026-06-17T18:07:18.538Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/f9c77093af9f5f96615922b7e3fe3690a9faff02adb89f1d74e21578b147/greenlet-3.5.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2", size = 617821, upload-time = "2026-06-17T18:29:41.317Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d4/642833e778c17d32b5cabb793e14ce7364c55952462fc506fecdee55d485/greenlet-3.5.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1", size = 616877, upload-time = "2026-06-17T17:39:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/7120f83e78b8be3cf7acbe2306b3b7bd2cbf99f5ad12e85e2f05d7b31961/greenlet-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f", size = 1577274, upload-time = "2026-06-17T18:22:10.692Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/05a0074ee485dd51c320fd706fd7ed48006b9cad3443092d7df1a655f0d2/greenlet-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f", size = 1643566, upload-time = "2026-06-17T17:40:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/35/fe/9fe2060bdeece682e38d381184ae66045b48ed183c107ab3f88b9886a630/greenlet-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e", size = 238643, upload-time = "2026-06-17T17:37:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/41/13/a9db72f5b6b700977ebd371d6a1f2984a08838357de924fcd5571607b1bf/greenlet-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d", size = 237135, upload-time = "2026-06-17T17:34:34.14Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" }, + { url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" }, + { url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" }, + { url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" }, + { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" }, + { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" }, + { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpickle" +version = "4.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/c0/dde9b4b42cc415b9579573f967f12efbb034e427a2a37e93ad5139891d87/jsonpickle-4.1.2.tar.gz", hash = "sha256:8afed18aa189fd81e2e833b426bb4af485594921f0b1d36c2001fc5637a2f210", size = 319120, upload-time = "2026-05-28T03:50:11.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/7b/fd3c7a09649aea9da1d3587aea624d8f9b29963dfd84a1bdb2aa93b36dac/jsonpickle-4.1.2-py3-none-any.whl", hash = "sha256:7ffe34426bc797684dbf1dc84185558bd864cd25b1ff5fb01b7405e392d0a937", size = 47203, upload-time = "2026-05-28T03:50:10.605Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nmuwd" +version = "0.10.3" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "frost-sta-client" }, + { name = "geopandas" }, + { name = "httpx" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "types-pyyaml" }, + { name = "urllib3" }, +] + +[package.optional-dependencies] +dev = [ + { name = "flake8" }, + { name = "mypy" }, + { name = "pytest" }, +] +gcs = [ + { name = "google-cloud-storage" }, +] +geoserver = [ + { name = "geoalchemy2" }, + { name = "psycopg2-binary" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2.1" }, + { name = "flake8", marker = "extra == 'dev'" }, + { name = "frost-sta-client" }, + { name = "geoalchemy2", marker = "extra == 'geoserver'" }, + { name = "geopandas" }, + { name = "google-cloud-storage", marker = "extra == 'gcs'" }, + { name = "httpx" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "pandas" }, + { name = "psycopg2-binary", marker = "extra == 'geoserver'" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "sqlalchemy", marker = "extra == 'geoserver'" }, + { name = "types-pyyaml" }, + { name = "urllib3", specifier = ">=2.2.0,<3.0.0" }, +] +provides-extras = ["dev", "geoserver", "gcs"] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "orderedmultidict" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/62/61ad51f6c19d495970230a7747147ce7ed3c3a63c2af4ebfdb1f6d738703/orderedmultidict-1.0.2.tar.gz", hash = "sha256:16a7ae8432e02cc987d2d6d5af2df5938258f87c870675c73ee77a0920e6f4a6", size = 13973, upload-time = "2025-11-18T08:00:42.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6c/d8a02ffb24876b5f51fbd781f479fc6525a518553a4196bd0433dae9ff8e/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b", size = 11897, upload-time = "2025-11-18T08:00:41.44Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", size = 3712314, upload-time = "2026-04-20T23:33:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", size = 3822389, upload-time = "2026-04-20T23:33:34.178Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", size = 4578448, upload-time = "2026-04-20T23:33:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", size = 4273705, upload-time = "2026-04-20T23:33:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", size = 5893784, upload-time = "2026-04-20T23:33:41.658Z" }, + { url = "https://files.pythonhosted.org/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", size = 4109306, upload-time = "2026-04-20T23:33:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", size = 3654400, upload-time = "2026-04-20T23:33:46.115Z" }, + { url = "https://files.pythonhosted.org/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", size = 3299215, upload-time = "2026-04-20T23:33:48.202Z" }, + { url = "https://files.pythonhosted.org/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", size = 3047724, upload-time = "2026-04-20T23:33:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", size = 3349183, upload-time = "2026-04-20T23:33:59.635Z" }, + { url = "https://files.pythonhosted.org/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", size = 2757036, upload-time = "2026-04-20T23:34:01.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +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/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pyogrio" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/d4/12f86b1ed09721363da4c09622464b604c851a9223fc0c6b393fb2012208/pyogrio-0.12.1.tar.gz", hash = "sha256:e548ab705bb3e5383693717de1e6c76da97f3762ab92522cb310f93128a75ff1", size = 303289, upload-time = "2025-11-28T19:04:53.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/04/e69f476c4cc279adc6d26194da4d3497f5d9efdd46777a6c0ad59c09233f/pyogrio-0.12.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c4735235ca0d8dcdb4ecd69bd73e66762d161bce913b10d4458a18137cc7062", size = 23672707, upload-time = "2025-11-28T19:02:54.87Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9e/805d640f050fc4a064ee5ba3289457f47d7f3464b57140caa8ddac039a67/pyogrio-0.12.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3249d06c2520857b622f3ff0f1b7b4849291ee1fb72f21587825f5fd0f24b787", size = 25247903, upload-time = "2025-11-28T19:02:57.756Z" }, + { url = "https://files.pythonhosted.org/packages/05/c3/65577611485bc3e53a466ffbcd2407f89e8bd7e1c4554e8a0d23a4b8a636/pyogrio-0.12.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4011b63f9d6c278ee6605971ffabe30b0e8f5992ec2c6df8c70ecfa68a5d02b", size = 31279563, upload-time = "2025-11-28T19:03:00.344Z" }, + { url = "https://files.pythonhosted.org/packages/b1/a6/5c03dffaf02542e8bae6c785d3e302bf4b890cd2ab281336b3c4dc867f84/pyogrio-0.12.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:940857c45051e1e19608ebfe8338bcdf7dd005389057431a3c7b5bff5beb0a5f", size = 30831678, upload-time = "2025-11-28T19:03:03.234Z" }, + { url = "https://files.pythonhosted.org/packages/c8/aa/0e484c13cf14bbe46c366ad363ab2406242a0fba85a7561d42bbd34c35dd/pyogrio-0.12.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0fd86bcd69126739325a543a489f312b5fd86db092d2dead682772ae4ee434f3", size = 32380362, upload-time = "2025-11-28T19:03:06.098Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/cc515005780235d9ab14a29d33868bcaa1d5b423cee7995dda94735c41dd/pyogrio-0.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:dcf9cca273ead32beba7c002dd3db8a304105f52dd66200d48fa1ef30d0676af", size = 22940628, upload-time = "2025-11-28T19:03:08.568Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/b2c2dcdfd88759b56f103365905fffb85e8b08c1db1ec7c8f8b4c4c26016/pyogrio-0.12.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:01b322dac2a258d24b024d1028dcaa03c9bb6d9c3988b86d298a64873d10dc65", size = 23670744, upload-time = "2025-11-28T19:03:11.299Z" }, + { url = "https://files.pythonhosted.org/packages/d9/21/b69f1bc51d805c00dd7c484a18e1fd2e75b41da1d9f5b8591d7d9d4a7d2f/pyogrio-0.12.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:e10087abcbd6b7e8212560a7002984e5078ac7b3a969ddc2c9929044dbb0d403", size = 25246184, upload-time = "2025-11-28T19:03:13.997Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/b6aae08e8fcc4f2a903da5f6bd8f888d2b6d7290e54dde5abe15b4cca8df/pyogrio-0.12.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f6c621972b09fd81a32317e742c69ff4a7763a803da211361a78317f9577765", size = 31434449, upload-time = "2025-11-28T19:03:16.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/f9/9538fa893c29a3fdfeddf3b4c9f8db77f2d4134bc766587929fec8405ebf/pyogrio-0.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c38253427b688464caad5316d4ebcec116b5e13f1f02cc4e3588502f136ca1b4", size = 30987586, upload-time = "2025-11-28T19:03:19.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/0aef5837b4e11840f501e48e01c31242838476c4f4aff9c05e228a083982/pyogrio-0.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5f47787251de7ce13cc06038da93a1214dc283cbccf816be6e03c080358226c8", size = 32534386, upload-time = "2025-11-28T19:03:22.292Z" }, + { url = "https://files.pythonhosted.org/packages/34/97/e8f2ed8a339152b86f8403c258ae5d5f23ab32d690eeb0545bb3473d0c69/pyogrio-0.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:c1d756cf2da4cdf5609779f260d1e1e89be023184225855d6f3dcd33bbe17cb0", size = 22941718, upload-time = "2025-11-28T19:03:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e0/656b6536549d41b5aec57e0deca1f269b4f17532f0636836f587e581603a/pyogrio-0.12.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:7a0d5ca39184030aec4cde30f4258f75b227a854530d2659babc8189d76e657d", size = 23661857, upload-time = "2025-11-28T19:03:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/14/78/313259e40da728bdb60106ffdc7ea8224d164498cb838ecb79b634aab967/pyogrio-0.12.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:feaff42bbe8087ca0b30e33b09d1ce049ca55fe83ad83db1139ef37d1d04f30c", size = 25237106, upload-time = "2025-11-28T19:03:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ca/5368571a8b00b941ccfbe6ea29a5566aaffd45d4eb1553b956f7755af43e/pyogrio-0.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81096a5139532de5a8003ef02b41d5d2444cb382a9aecd1165b447eb549180d3", size = 31417048, upload-time = "2025-11-28T19:03:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/ef/85/6eeb875f27bf498d657eb5dab9f58e4c48b36c9037122787abee9a1ba4ba/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:41b78863f782f7a113ed0d36a5dc74d59735bd3a82af53510899bb02a18b06bb", size = 30952115, upload-time = "2025-11-28T19:03:35.332Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/cf8bec9024625947e1a71441906f60a5fa6f9e4c441c4428037e73b1fcc8/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8b65be8c4258b27cc8f919b21929cecdadda4c353e3637fa30850339ef4d15c5", size = 32537246, upload-time = "2025-11-28T19:03:37.969Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/7c9f5e428273574e69f217eba3a6c0c42936188ad4dcd9e2c41ebb711188/pyogrio-0.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:1291b866c2c81d991bda15021b08b3621709b40ee3a85689229929e9465788bf", size = 22933980, upload-time = "2025-11-28T19:03:41.047Z" }, + { url = "https://files.pythonhosted.org/packages/be/56/f56e79f71b84aa9bea25fdde39fab3846841bd7926be96f623eb7253b7e1/pyogrio-0.12.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:ec0e47a5a704e575092b2fd5c83fa0472a1d421e590f94093eb837bb0a11125d", size = 23658483, upload-time = "2025-11-28T19:03:43.567Z" }, + { url = "https://files.pythonhosted.org/packages/66/ac/5559f8a35d58a16cbb2dd7602dd11936ff8796d8c9bf789f14da88764ec3/pyogrio-0.12.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:b4c888fc08f388be4dd99dfca5e84a5cdc5994deeec0230cc45144d3460e2b21", size = 25232737, upload-time = "2025-11-28T19:03:45.92Z" }, + { url = "https://files.pythonhosted.org/packages/59/58/925f1c129ddd7cbba8dea4e7609797cea7a76dbc863ac9afd318a679c4b9/pyogrio-0.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73a88436f9962750d782853727897ac2722cac5900d920e39fab3e56d7a6a7f1", size = 31377986, upload-time = "2025-11-28T19:03:48.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/5f/c87034e92847b1844d0e8492a6a8e3301147d32c5e57909397ce64dbedf5/pyogrio-0.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b5d248a0d59fe9bbf9a35690b70004c67830ee0ebe7d4f7bb8ffd8659f684b3a", size = 30915791, upload-time = "2025-11-28T19:03:51.267Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b874f79d03e9f900012cf609f7fff97b77164f2e14ee5aac282f8a999c1b/pyogrio-0.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0622bc1a186421547660271083079b38d42e6f868802936d8538c0b379f1ab6b", size = 32499754, upload-time = "2025-11-28T19:03:58.776Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c4/705678c9c4200130290b3a104b45c0cc10aaa48fcef3b2585b34e34ab3e1/pyogrio-0.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:207bd60c7ffbcea84584596e3637653aa7095e9ee20fa408f90c7f9460392613", size = 22933945, upload-time = "2025-11-28T19:04:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e0/d92d4944001330bc87742d43f112d63d12fc89378b6187e62ff3fc1e8e85/pyogrio-0.12.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1511b39a283fa27cda906cd187a791578942a87a40b6a06697d9b43bb8ac80b0", size = 23692697, upload-time = "2025-11-28T19:04:04.208Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d7/40acbe06d1b1140e3bb27b79e9163776469c1dc785f1be7d9a7fc7b95c87/pyogrio-0.12.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:e486cd6aa9ea8a15394a5f84e019d61ec18f257eeeb642348bd68c3d1e57280b", size = 25258083, upload-time = "2025-11-28T19:04:07.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/a1/39fefd9cddd95986700524f43d3093b4350f6e4fc200623c3838424a5080/pyogrio-0.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3f1a19f63bfd1d3042e45f37ad1d6598123a5a604b6c4ba3f38b419273486cd", size = 31368995, upload-time = "2025-11-28T19:04:09.88Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/da88c566e67d741a03851eb8d01358949d52e0b0fc2cd953582dc6d89ff8/pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f3dcc59b3316b8a0f59346bcc638a4d69997864a4d21da839192f50c4c92369a", size = 31035589, upload-time = "2025-11-28T19:04:12.993Z" }, + { url = "https://files.pythonhosted.org/packages/11/ac/8f0199f0d31b8ddbc4b4ea1918df8070fdf3e0a63100b898633ec9396224/pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a0643e041dee3e8e038fce69f52a915ecb486e6d7b674c0f9919f3c9e9629689", size = 32487973, upload-time = "2025-11-28T19:04:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/8541a27e9635a335835d234dfaeb19d6c26097fd88224eda7791f83ca98d/pyogrio-0.12.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5881017f29e110d3613819667657844d8e961b747f2d35cf92f273c27af6d068", size = 22987374, upload-time = "2025-11-28T19:04:18.91Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6f/b4d5e285e08c0c60bcc23b50d73038ddc7335d8de79cc25678cd486a3db0/pyogrio-0.12.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5a1b0453d1c9e7b03715dd57296c8f3790acb8b50d7e3b5844b3074a18f50709", size = 23660673, upload-time = "2025-11-28T19:04:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/8d/75/4b29e71489c5551aa1a1c5ca8c5160a60203c94f2f68c87c0e3614d58965/pyogrio-0.12.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e7ee560422239dd09ca7f8284cc8483a8919c30d25f3049bb0249bff4c38dec4", size = 25232194, upload-time = "2025-11-28T19:04:23.975Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/e9929d2261a07c36301983de2767bcde90d441ab5bf1d767ce56dd07f8b4/pyogrio-0.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:648c6f7f5f214d30e6cf493b4af1d59782907ac068af9119ca35f18153d6865a", size = 31336936, upload-time = "2025-11-28T19:04:26.594Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9e/c59941d734ed936d4e5c89b4b99cb5541307cc42b3fd466ee78a1850c177/pyogrio-0.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:58042584f3fd4cabb0f55d26c1405053f656be8a5c266c38140316a1e981aca0", size = 30902210, upload-time = "2025-11-28T19:04:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/68/cc07320a63f9c2586e60bf11d148b00e12d0e707673bffe609bbdcb7e754/pyogrio-0.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b438e38e4ccbaedaa5cb5824ff5de5539315d9b2fde6547c1e816576924ee8ca", size = 32461674, upload-time = "2025-11-28T19:04:31.792Z" }, + { url = "https://files.pythonhosted.org/packages/13/bc/e4522f429c45a3b6ad28185849dd76e5c8718b780883c4795e7ee41841ae/pyogrio-0.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f1d8d8a2fea3781dc2a05982c050259261ebc0f6c5e03732d6d79d582adf9363", size = 23550575, upload-time = "2025-11-28T19:04:34.556Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ac/34f0664d0e391994a7b68529ae07a96432b2b4926dbac173ddc4ec94d310/pyogrio-0.12.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9fe7286946f35a73e6370dc5855bc7a5e8e7babf9e4a8bad7a3279a1d94c7ea9", size = 23694285, upload-time = "2025-11-28T19:04:37.833Z" }, + { url = "https://files.pythonhosted.org/packages/8a/93/873255529faff1da09d0b27287e85ec805a318c60c0c74fd7df77f94e557/pyogrio-0.12.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2c50345b382f1be801d654ec22c70ee974d6057d4ba7afe984b55f2192bc94ee", size = 25259825, upload-time = "2025-11-28T19:04:40.125Z" }, + { url = "https://files.pythonhosted.org/packages/27/95/4d4c3644695d99c6fa0b0b42f0d6266ae9dfaf64478a3371eaac950bdd02/pyogrio-0.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0db95765ac0ca935c7fe579e29451294e3ab19c317b0c59c31fbe92a69155e0", size = 31371995, upload-time = "2025-11-28T19:04:42.736Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/71f6bcca8754c8bf55a4b7153c61c91f8ac5ba992568e9fa3e54a0ee76fd/pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fc882779075982b93064b3bf3d8642514a6df00d9dd752493b104817072cfb01", size = 31035498, upload-time = "2025-11-28T19:04:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/fd/47/75c1aa165a988347317afab9b938a01ad25dbca559b582ea34473703dc38/pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:806f620e0c54b54dbdd65e9b6368d24f344cda84c9343364b40a57eb3e1c4dca", size = 32496390, upload-time = "2025-11-28T19:04:48.786Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/4641dc5d952f6bdb71dabad2c50e3f8a5d58396cdea6ff8f8a08bfd4f4a6/pyogrio-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5399f66730978d8852ef5f44dbafa0f738e7f28f4f784349f36830b69a9d2134", size = 23620996, upload-time = "2025-11-28T19:04:51.132Z" }, +] + +[[package]] +name = "pyproj" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a3/c4cd4bba5b336075f145fe784fcaf4ef56ffbc979833303303e7a659dda2/pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:bf09dbeb333c34e9c546364e7df1ff40474f9fddf9e70657ecb0e4f670ff0b0e", size = 6262524, upload-time = "2025-02-16T04:27:19.725Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/4fdf18f4cc1995f1992771d2a51cf186a9d7a8ec973c9693f8453850c707/pyproj-3.7.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6575b2e53cc9e3e461ad6f0692a5564b96e7782c28631c7771c668770915e169", size = 4665102, upload-time = "2025-02-16T04:27:24.428Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d2/360eb127380106cee83569954ae696b88a891c804d7a93abe3fbc15f5976/pyproj-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cb516ee35ed57789b46b96080edf4e503fdb62dbb2e3c6581e0d6c83fca014b", size = 9432667, upload-time = "2025-02-16T04:27:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/c6e11b9a99ce146741fb4d184d5c468446c6d6015b183cae82ac822a6cfa/pyproj-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e47c4e93b88d99dd118875ee3ca0171932444cdc0b52d493371b5d98d0f30ee", size = 9259185, upload-time = "2025-02-16T04:27:30.35Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/a3c15c42145797a99363fa0fdb4e9805dccb8b4a76a6d7b2cdf36ebcc2a1/pyproj-3.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3e8d276caeae34fcbe4813855d0d97b9b825bab8d7a8b86d859c24a6213a5a0d", size = 10469103, upload-time = "2025-02-16T04:27:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/ef/73/c9194c2802fefe2a4fd4230bdd5ab083e7604e93c64d0356fa49c363bad6/pyproj-3.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f173f851ee75e54acdaa053382b6825b400cb2085663a9bb073728a59c60aebb", size = 10401391, upload-time = "2025-02-16T04:27:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/ce8bb5b9251b04d7c22d63619bb3db3d2397f79000a9ae05b3fd86a5837e/pyproj-3.7.1-cp310-cp310-win32.whl", hash = "sha256:f550281ed6e5ea88fcf04a7c6154e246d5714be495c50c9e8e6b12d3fb63e158", size = 5869997, upload-time = "2025-02-16T04:27:38.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/6a/ca145467fd2e5b21e3d5b8c2b9645dcfb3b68f08b62417699a1f5689008e/pyproj-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3537668992a709a2e7f068069192138618c00d0ba113572fdd5ee5ffde8222f3", size = 6278581, upload-time = "2025-02-16T04:27:41.051Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/63670fc527e664068b70b7cab599aa38b7420dd009bdc29ea257e7f3dfb3/pyproj-3.7.1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:a94e26c1a4950cea40116775588a2ca7cf56f1f434ff54ee35a84718f3841a3d", size = 6264315, upload-time = "2025-02-16T04:27:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/cbaf82cfb290d1f1fa42feb9ba9464013bb3891e40c4199f8072112e4589/pyproj-3.7.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:263b54ba5004b6b957d55757d846fc5081bc02980caa0279c4fc95fa0fff6067", size = 4666267, upload-time = "2025-02-16T04:27:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/24f9f9b8918c0550f3ff49ad5de4cf3f0688c9f91ff191476db8979146fe/pyproj-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6d6a2ccd5607cd15ef990c51e6f2dd27ec0a741e72069c387088bba3aab60fa", size = 9680510, upload-time = "2025-02-16T04:27:49.239Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ac/12fab74a908d40b63174dc704587febd0729414804bbfd873cabe504ff2d/pyproj-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5dcf24ede53d8abab7d8a77f69ff1936c6a8843ef4fcc574646e4be66e5739", size = 9493619, upload-time = "2025-02-16T04:27:52.65Z" }, + { url = "https://files.pythonhosted.org/packages/c4/45/26311d6437135da2153a178125db5dfb6abce831ce04d10ec207eabac70a/pyproj-3.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c2e7449840a44ce860d8bea2c6c1c4bc63fa07cba801dcce581d14dcb031a02", size = 10709755, upload-time = "2025-02-16T04:27:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/99/52/4ecd0986f27d0e6c8ee3a7bc5c63da15acd30ac23034f871325b297e61fd/pyproj-3.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0829865c1d3a3543f918b3919dc601eea572d6091c0dd175e1a054db9c109274", size = 10642970, upload-time = "2025-02-16T04:27:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a5/d3bfc018fc92195a000d1d28acc1f3f1df15ff9f09ece68f45a2636c0134/pyproj-3.7.1-cp311-cp311-win32.whl", hash = "sha256:6181960b4b812e82e588407fe5c9c68ada267c3b084db078f248db5d7f45d18a", size = 5868295, upload-time = "2025-02-16T04:28:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/92/39/ef6f06a5b223dbea308cfcbb7a0f72e7b506aef1850e061b2c73b0818715/pyproj-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ad0ff443a785d84e2b380869fdd82e6bfc11eba6057d25b4409a9bbfa867970", size = 6279871, upload-time = "2025-02-16T04:28:04.988Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c9/876d4345b8d17f37ac59ebd39f8fa52fc6a6a9891a420f72d050edb6b899/pyproj-3.7.1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:2781029d90df7f8d431e29562a3f2d8eafdf233c4010d6fc0381858dc7373217", size = 6264087, upload-time = "2025-02-16T04:28:09.036Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/5f8691f8c90e7f402cc80a6276eb19d2ec1faa150d5ae2dd9c7b0a254da8/pyproj-3.7.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d61bf8ab04c73c1da08eedaf21a103b72fa5b0a9b854762905f65ff8b375d394", size = 4669628, upload-time = "2025-02-16T04:28:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/16475bbb79c1c68845c0a0d9c60c4fb31e61b8a2a20bc18b1a81e81c7f68/pyproj-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04abc517a8555d1b05fcee768db3280143fe42ec39fdd926a2feef31631a1f2f", size = 9721415, upload-time = "2025-02-16T04:28:13.342Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/448f05b15e318bd6bea9a32cfaf11e886c4ae61fa3eee6e09ed5c3b74bb2/pyproj-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084c0a475688f934d386c2ab3b6ce03398a473cd48adfda70d9ab8f87f2394a0", size = 9556447, upload-time = "2025-02-16T04:28:15.818Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ae/bd15fe8d8bd914ead6d60bca7f895a4e6f8ef7e3928295134ff9a7dad14c/pyproj-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a20727a23b1e49c7dc7fe3c3df8e56a8a7acdade80ac2f5cca29d7ca5564c145", size = 10758317, upload-time = "2025-02-16T04:28:18.338Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/5ccefb8bca925f44256b188a91c31238cae29ab6ee7f53661ecc04616146/pyproj-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf84d766646f1ebd706d883755df4370aaf02b48187cedaa7e4239f16bc8213d", size = 10771259, upload-time = "2025-02-16T04:28:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7d/31dedff9c35fa703162f922eeb0baa6c44a3288469a5fd88d209e2892f9e/pyproj-3.7.1-cp312-cp312-win32.whl", hash = "sha256:5f0da2711364d7cb9f115b52289d4a9b61e8bca0da57f44a3a9d6fc9bdeb7274", size = 5859914, upload-time = "2025-02-16T04:28:23.303Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/c6ab03d6564a7c937590cff81a2742b5990f096cce7c1a622d325be340ee/pyproj-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:aee664a9d806612af30a19dba49e55a7a78ebfec3e9d198f6a6176e1d140ec98", size = 6273196, upload-time = "2025-02-16T04:28:25.227Z" }, + { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062, upload-time = "2025-02-16T04:28:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548, upload-time = "2025-02-16T04:28:29.526Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464, upload-time = "2025-02-16T04:28:31.437Z" }, + { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461, upload-time = "2025-02-16T04:28:33.848Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869, upload-time = "2025-02-16T04:28:37.34Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260, upload-time = "2025-02-16T04:28:40.639Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462, upload-time = "2025-02-16T04:28:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573, upload-time = "2025-02-16T04:28:44.727Z" }, +] + +[[package]] +name = "pyproj" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022, upload-time = "2025-08-14T12:03:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810, upload-time = "2025-08-14T12:03:53.808Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694, upload-time = "2025-08-14T12:03:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977, upload-time = "2025-08-14T12:03:57.937Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151, upload-time = "2025-08-14T12:04:00.271Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585, upload-time = "2025-08-14T12:04:02.228Z" }, + { url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533, upload-time = "2025-08-14T12:04:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742, upload-time = "2025-08-14T12:04:06.357Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772, upload-time = "2025-08-14T12:04:08.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +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/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" }, + { url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" }, + { url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From 28671f55bfd05bd8618019f00fa972618688ab48 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 12:08:13 -0600 Subject: [PATCH 115/126] =?UTF-8?q?spec:=20add=20=C2=A7T.10-=C2=A7T.15=20c?= =?UTF-8?q?omposition=20refactor=20tasks=20+=20=C2=A7V=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPEC.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/SPEC.md b/SPEC.md index 3b908a2..75e88bc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -574,8 +574,134 @@ Cloud Run Job env vars (from Secret Manager): --- +## §10 Composition Refactor + +### §10.1 Goals + +Replace inheritance-for-code-reuse with injected dependencies. Targets: +1. `Loggable` base class — used only to get `self.log()` → inject logger +2. `STSource` mixin via multiple inheritance → `STClient` composed into sources +3. ST2 class explosion (5 near-identical subclasses) → instances with config +4. `CloudStoragePersister` overrides `_dump_*` to redirect output → Strategy pattern +5. Transformer coupled by `transformer_klass` class attribute → inject transformer +6. Empty record subclasses (`WaterLevelRecord`, `AnalyteRecord`, etc.) → type field + +### §10.2 New Branch + +``` +feature/composition-refactor ← branch off main after §T.9 merged +``` + +--- + +### §T.10 [.] Replace `Loggable` base with injected logger +**Goal:** Remove `Loggable` from the inheritance chain of all classes. + +**Changes:** +- `backend/logger.py`: add `make_logger(name: str) -> Logger` factory function +- `BaseSource`, `BasePersister`, `BaseTransformer`, `Config`: remove `(Loggable)` base; call `make_logger(self.__class__.__name__)` in `__init__` +- All `self.log()` / `self.warn()` / `self.debug()` calls: keep working — keep the same helper wrappers as module-level or instance-assigned callables rather than inherited methods + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.11 [.] Replace `STSource` mixin with `STClient` composition +**Goal:** Kill multiple inheritance in all ST source classes. + +**Changes:** +- `backend/connectors/st_connector.py`: extract `STSource` methods into `STClient` class with `__init__(self, url: str)` + - `get_service()`, `get_things()`, `_extract_terminal_record()`, `_parse_result()` → methods on `STClient` +- `STSiteSource(BaseSiteSource, STSource)` → `STSiteSource(BaseSiteSource)` with `self.client = STClient(self.url)` +- `STWaterLevelSource(STSource, BaseWaterLevelSource)` → `STWaterLevelSource(BaseWaterLevelSource)` with `self.client = STClient(self.url)` +- `STAnalyteSource(STSource, BaseAnalyteSource)` → `STAnalyteSource(BaseAnalyteSource)` with `self.client = STClient(self.url)` +- All `self.get_service()` / `self._get_things()` call sites → `self.client.get_service()` / `self.client.get_things()` + +**Verification:** `uv run pytest tests/test_sources/ -k "st or bernco or cabq or ebid or pvacd or roswell" -q` + +--- + +### §T.12 [.] Collapse ST2 class hierarchy into configured instances +**Goal:** Delete 5 nearly-identical site source classes; replace with factory. + +**Affected classes (delete):** +`BernCoSiteSource`, `CABQSiteSource`, `EBIDSiteSource`, `PVACDSiteSource`, `NMOSERoswellSiteSource` + +**Changes:** +- `ST2SiteSource`: accept `agency: str`, `bounding_wkt: str | None`, `transformer_klass` in `__init__`; move per-subclass logic (bounding polygon, filter) into constructor +- `backend/connectors/st2/source.py` (or equivalent): replace class definitions with module-level instances: + ```python + BernCoSiteSource = ST2SiteSource(agency="BernCo", bounding_wkt=BERNCO_WKT, transformer_klass=BernCoSiteTransformer) + ``` +- `Config.water_level_sources()` / `Config.analyte_sources()`: update to use instances + +**Verification:** `uv run pytest tests/test_sources/ -k "bernco or cabq or ebid or pvacd" -q` + +--- + +### §T.13 [.] Replace `CloudStoragePersister` with output strategy injection +**Goal:** `BasePersister` accepts an output strategy; `CloudStoragePersister` subclass deleted. + +**Changes:** +- Add `backend/persisters/strategies.py`: + ```python + class OutputStrategy(Protocol): + def write(self, name: str, content: bytes) -> None: ... + def make_directory(self, path: str) -> None: ... + + class LocalFileStrategy: + def write(self, name, content): Path(name).write_bytes(content) + def make_directory(self, path): Path(path).mkdir(parents=True, exist_ok=True) + + class GCSStrategy: + def __init__(self, bucket_name: str, prefix: str): ... + def write(self, name, content): ... # uploads to GCS + def make_directory(self, path): pass # no-op + ``` +- `BasePersister.__init__`: accept `strategy: OutputStrategy = LocalFileStrategy()` +- All `_dump_*` methods: call `self.strategy.write(...)` instead of `Path.write_*` +- Delete `CloudStoragePersister` class +- Update `backend/unifier.py`: create `GCSStrategy` instead of `CloudStoragePersister` when `config.use_cloud_storage` + +**Verification:** `uv run pytest tests/ -q --ignore=tests/test_sources` + +--- + +### §T.14 [.] Inject transformer into source constructor +**Goal:** Remove `transformer_klass` class attribute pattern; pass transformer as dependency. + +**Changes:** +- `BaseSource.__init__`: accept `transformer: BaseTransformer` parameter; remove `self.transformer = self.transformer_klass()` +- All concrete source classes: remove `transformer_klass` class attribute; pass transformer in `super().__init__(transformer=XTransformer())` +- `set_config(config)`: still propagates to both source + transformer +- Tests that construct sources directly: update constructors + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.15 [.] Collapse empty record subclasses +**Goal:** `WaterLevelRecord`, `AnalyteRecord`, `WaterLevelSummaryRecord`, `AnalyteSummaryRecord` add zero behavior — remove them. + +**Changes:** +- `backend/record.py`: delete `WaterLevelRecord`, `AnalyteRecord`, `WaterLevelSummaryRecord`, `AnalyteSummaryRecord` +- Add `record_type: str` field to `ParameterRecord` and `SummaryRecord` keys +- `WaterLevelTransformer._get_record_klass()` → returns `ParameterRecord` or `SummaryRecord`; sets `record_type="waterlevels"` in transform +- `AnalyteTransformer._get_record_klass()` → same pattern with `record_type="analytes"` +- Grep for `isinstance(r, WaterLevelRecord)` etc. — update to `r.record_type == "waterlevels"` + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + ## §V Invariants +- No class MUST inherit `Loggable` — use `make_logger()` factory (§T.10) +- No ST source class MUST use multiple inheritance — `STClient` injected as `self.client` (§T.11) +- ST2 per-agency behavior MUST be expressed as constructor args, not subclasses (§T.12) +- `BasePersister` MUST NOT contain GCS-specific logic — output target injected via strategy (§T.13) +- Source classes MUST NOT declare `transformer_klass` — transformer passed to `__init__` (§T.14) +- `WaterLevelRecord`, `AnalyteRecord`, `WaterLevelSummaryRecord`, `AnalyteSummaryRecord` MUST NOT exist (§T.15) - Orchestration code MUST NOT appear in `[tool.hatch.build.targets.wheel].packages` - OGC FC output MUST include top-level `id`, `type`, `numberReturned`, `timeStamp` - Each Feature MUST have top-level `id` (not only in properties) From a18ceaf45d94c0793180a2c5e47029b3ab0590ed Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 12:14:06 -0600 Subject: [PATCH 116/126] =?UTF-8?q?spec:=20add=20=C2=A710=20backend=20impr?= =?UTF-8?q?ovements=20+=20=C2=A7T.16-=C2=A7T.26=20performance/reliability/?= =?UTF-8?q?observability/readability/composition=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPEC.md | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 75e88bc..d2c6621 100644 --- a/SPEC.md +++ b/SPEC.md @@ -574,9 +574,69 @@ Cloud Run Job env vars (from Secret Manager): --- -## §10 Composition Refactor +## §10 Backend Improvements -### §10.1 Goals +### §10.1 Performance + +**Retry backoff** (`_execute_text_request`, `_execute_json_request` in `source.py`): linear `time.sleep(tries)` → exponential backoff capped at 60s. + +**Polygon re-parse per record** (`BaseTransformer.contained()`, `transformer.py`): `_cached_polygon` is set at instance level but `config.bounding_wkt()` is called on every record. Cache shapely object permanently at first call. + +**Redundant list extraction in `BaseParameterSource.read()`** (`source.py`): `_extract_parameter_dates()`, `_extract_source_parameter_results()`, `_extract_source_parameter_units()`, `_extract_source_parameter_names()` called independently per site, each iterating the same `records` list. Batch extract once before loop. + +### §10.2 Reliability + +**Bare `except Exception`** (`_execute_text_request` line ~241, `_site_wrapper` in `unifier.py`): catches everything including `KeyboardInterrupt` siblings. Catch `httpx.HTTPError`, `httpx.TimeoutException`, `json.JSONDecodeError` specifically. Log full traceback. + +**No coordinate range validation** (`do_transform()` in `transformer.py`): checks `x == 0 or y == 0` but not whether lng/lat are in valid ranges (−180..180, −90..90). Silent pass-through of bogus coords. + +**Unchecked unit conversion** (`convert_units()` `transformer.py`): returns `None` if `die_parameter_name` is unrecognized, propagates silently into record payload. + +**`with` statement missing on file open** (`Config._load_from_yaml()` `config.py`): unclosed handle on read failure. + +**Manual slice rollback** (`_site_wrapper()` `unifier.py` lines ~183–202): slices `persister.records/timeseries/sites` back to pre-chunk length on error. Fragile — an atomic checkpoint abstraction is safer. + +### §10.3 Observability + +**`print()` instead of logger** (multiple): `generate_bounding_polygon()` in `source.py`, lines ~52/63/75 in `unifier.py`, line ~29 in `persister.py`. None go through `self.log()`. + +**No request timing** (`_execute_text_request/json_request`): no record of latency, retry count, or which URL failed. Add structured log entry: `source`, `url`, `status_code`, `attempt`, `elapsed_ms` on every attempt. + +**Low-information warnings**: "Failed to retrieve records after multiple attempts" doesn't include URL, params, or last exception. + +**No transform failure metrics** (`do_transform()` `transformer.py`): returns `None` silently. Caller doesn't know how many records were dropped and why. + +**No chunk progress** (`_site_wrapper()` `unifier.py`): no log of chunk index, site count per chunk, or timing. + +### §10.4 Readability + +**`BaseParameterSource` god class** (`source.py`, ~476 lines): handles extraction, validation, unit conversion, and summarization in one class + one 167-line `read()` method with 5 levels of nesting. Split into: `RecordExtractor`, `RecordValidator`, `RecordSummarizer`. + +**`do_transform()` god method** (`transformer.py`, ~191 lines): 6 sequential transform steps in one method body. Extract each into `_apply_datum_transform()`, `_apply_elevation_transform()`, `_apply_well_depth_transform()`, `_apply_unit_conversion()`. + +**`Config.get_config_and_false_agencies()`** (`config.py`, ~107 lines): repetitive `if/elif` per parameter. Replace with a dict mapping `parameter → (agency_defaults, source_classes)`. + +**`start_ind` / `end_ind` in `BaseParameterSource.read()`**: only used for logging but add confusion. Rename or remove if unused. + +**`bookend` naming** (`_extract_terminal_record()`): unclear. Rename to `position` or use `Literal["earliest", "latest"]`. + +### §10.5 Additional Composition (Sources / Transformers / Unifier) + +**HTTP client injection** (`BaseSource`): uses `httpx.get()` directly. Inject `httpx.Client` (or a protocol) so retry policy is testable and swappable. + +**Config post-construction injection** (`set_config()` on both `BaseSource` and `BaseTransformer`): config is required to function. Move to `__init__` param with `Optional` type; keep `set_config()` only as override for unifier's late binding. + +**`RecordExtractor` protocol** (`BaseParameterSource`): the 8 abstract `_extract_*` methods form an implicit interface. Define an explicit `ParameterExtractor` Protocol; `BaseParameterSource` accepts one in `__init__`. Enables injecting fake extractors in tests. + +**`UnitConverter` strategy** (`convert_units()` in `transformer.py`): 120+ line monolithic function. Extract to `UnitConverter` class; inject into `BaseTransformer`. Enables per-source custom conversions. + +**Persister factory in `unifier.py`**: `_unify_parameter()` contains if/else to pick persister class. Extract to `PersisterFactory(config) -> BasePersister`; inject factory into `Unifier.__init__`. + +--- + +## §11 Composition Refactor + +### §11.1 Goals Replace inheritance-for-code-reuse with injected dependencies. Targets: 1. `Loggable` base class — used only to get `self.log()` → inject logger @@ -586,7 +646,7 @@ Replace inheritance-for-code-reuse with injected dependencies. Targets: 5. Transformer coupled by `transformer_klass` class attribute → inject transformer 6. Empty record subclasses (`WaterLevelRecord`, `AnalyteRecord`, etc.) → type field -### §10.2 New Branch +### §11.2 New Branch ``` feature/composition-refactor ← branch off main after §T.9 merged @@ -694,8 +754,189 @@ feature/composition-refactor ← branch off main after §T.9 merged --- +### §T.16 [.] Exponential backoff + request structured logging +**Goal:** Fix linear retry backoff; add per-request structured log entries. + +**Changes:** +- `backend/source.py` `_execute_text_request()` + `_execute_json_request()`: + - Replace `time.sleep(tries)` with `time.sleep(min(2 ** tries, 60))` + - After each attempt log: `source`, `url`, `status_code`, `attempt`, `elapsed_ms` + - Catch `httpx.HTTPStatusError`, `httpx.TimeoutException`, `httpx.RequestError` specifically — no bare `except Exception` + - Include last exception message in "Failed after N attempts" warning + +**Verification:** `uv run pytest tests/test_cli/ -q` + +--- + +### §T.17 [.] Cache bounding polygon at class level +**Goal:** Prevent re-parsing WKT shapely object on every record. + +**Changes:** +- `backend/transformer.py` `BaseTransformer.contained()`: + - Move `_cached_polygon` from instance variable to class-level cache keyed on WKT string (e.g. `_polygon_cache: dict[str, Polygon] = {}`) + - First call for a given WKT parses and caches; subsequent calls return cached object + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + manual timing on 1000-record transform + +--- + +### §T.18 [.] Batch extraction in `BaseParameterSource.read()` +**Goal:** Extract dates/results/units/names once before the per-site loop, not once per site. + +**Changes:** +- `backend/source.py` `BaseParameterSource.read()`: + - Call `_extract_parameter_dates()`, `_extract_source_parameter_results()`, `_extract_source_parameter_units()`, `_extract_source_parameter_names()` once on full `cleaned` records before the site loop + - Pass extracted lists into inner loop rather than re-extracting per site + - Extract 167-line `read()` body into `_summarize_records()` and `_build_timeseries_records()` helpers (≤50 lines each) + +**Verification:** `uv run pytest tests/test_cli/ -q` + +--- + +### §T.19 [.] Replace all `print()` with structured logging +**Goal:** All console output goes through the logger; no raw `print()` in backend. + +**Changes:** +- `backend/source.py` `generate_bounding_polygon()` lines ~450–452: `print()` → `self.log()` +- `backend/unifier.py` lines ~52/63/75: `print()` → `config.log()` +- `backend/persister.py` line ~29: `print("google cloud storage not available")` → `logging.warning()` +- Grep `print(` across `backend/` — replace every hit +- Add `elapsed_ms` to transform failure log in `do_transform()` when returning `None` +- Log chunk index + site count per chunk in `_site_wrapper()` + +**Verification:** `grep -r "print(" backend/ | wc -l` → 0 + +--- + +### §T.20 [.] Specific exception handling + input validation +**Goal:** No bare `except Exception`; all swallowed errors surface detail. + +**Changes:** +- `backend/source.py`: + - `_execute_text_request` / `_execute_json_request`: replace bare except → specific httpx exceptions (see §T.16) + - `_extract_site_records()`: guard against `None`/empty `records` before returning + - `read()` inner ValueError/TypeError catches: log full `traceback.format_exc()`, not just message +- `backend/transformer.py` `convert_units()`: + - If `die_parameter_name` unrecognized → raise `ValueError(f"Unknown parameter: {die_parameter_name}")` instead of returning `None` + - Add lat/lng range check: `assert -180 <= lng <= 180 and -90 <= lat <= 90` +- `backend/unifier.py` `_site_wrapper()`: + - Replace `except BaseException` → `except Exception`; log `traceback.format_exc()` via `config.warn()` +- `backend/config.py` `_load_from_yaml()`: + - Wrap file open in `with` statement + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.21 [.] Split `BaseParameterSource` god class +**Goal:** 476-line class → focused classes ≤150 lines each. + +**Changes:** +- Extract `RecordValidator` class with `validate(record) -> bool`; holds current `_validate_record()` logic +- Extract `RecordSummarizer` class with `summarize(records, site_record) -> SummaryRecord`; holds summary path of `read()` +- `BaseParameterSource.__init__` accepts `validator: RecordValidator` (default = existing subclass method shim during migration) +- Split `read()` into `read_summary()` + `read_timeseries()` ≤50 lines each +- Rename `bookend` parameter → `position: Literal["earliest", "latest"]` + +**Verification:** `uv run pytest tests/ -q --ignore=tests/test_sources` + +--- + +### §T.22 [.] Split `do_transform()` into focused methods +**Goal:** 191-line method → orchestrator + focused helpers ≤30 lines each. + +**Changes:** +- `backend/transformer.py` `BaseTransformer.do_transform()`: + - Extract `_apply_geographic_filter(record) -> bool` + - Extract `_apply_datum_transform(record) -> record` + - Extract `_apply_elevation_transform(record) -> record` + - Extract `_apply_well_depth_transform(record) -> record` + - Extract `_apply_unit_conversion(record) -> record` + - `do_transform()` becomes orchestrator calling each in sequence ≤40 lines + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.23 [.] Data-driven `Config` source setup +**Goal:** Replace ~107-line `if/elif` per parameter in `get_config_and_false_agencies()` with a mapping. + +**Changes:** +- `backend/config.py`: + - Add `PARAMETER_SOURCE_MAP: dict[str, dict]` mapping each parameter name → `{site_source_klass, parameter_source_klass, agencies}` + - `get_config_and_false_agencies()` looks up parameter in map; raises `ValueError` for unknown parameter + - Extract duplicate `set_config()` calls in `analyte_sources()` / `water_level_sources()` / `all_site_sources()` into `_build_source_pair(site_klass, param_klass) -> tuple` + +**Verification:** `uv run pytest tests/test_cli/ -q` + +--- + +### §T.24 [.] Inject HTTP client into `BaseSource` +**Goal:** `httpx.get()` hardcoded → injected client; enables testability without live network. + +**Changes:** +- `backend/source.py` `BaseSource.__init__`: accept `http_client: httpx.Client | None = None`; default creates `httpx.Client(timeout=900)` +- `_execute_text_request()` / `_execute_json_request()`: use `self._http_client.get(...)` instead of `httpx.get(...)` +- Tests in `tests/test_cli/` or new `tests/test_sources_unit/`: pass mock client returning fixture responses — no live HTTP + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.25 [.] `UnitConverter` as injectable strategy +**Goal:** Replace 120+ line `convert_units()` monolith with pluggable converter. + +**Changes:** +- `backend/converter.py` (new file): + ```python + class UnitConverter(Protocol): + def convert(self, value: float, from_units: str, to_units: str, parameter: str) -> float: ... + + class StandardUnitConverter: + def convert(self, value, from_units, to_units, parameter): ... + # current convert_units() logic moved here + ``` +- `backend/transformer.py` `BaseTransformer.__init__`: accept `converter: UnitConverter = StandardUnitConverter()` +- Remove `convert_units()` module-level function; call `self.converter.convert(...)` in `_apply_unit_conversion()` +- ST/DWB sources needing custom conversion: pass custom `UnitConverter` subclass + +**Verification:** `uv run pytest tests/test_cli/ tests/test_persisters/ -q` + +--- + +### §T.26 [.] `PersisterFactory` extracted from `Unifier` +**Goal:** Remove persister selection if/else from `_unify_parameter()`. + +**Changes:** +- `backend/persisters/factory.py` (new file): + ```python + def make_persister(config: Config) -> BasePersister: + if config.output_format == OutputFormat.GEOSERVER: + ... + elif config.use_cloud_storage: + ... + else: + return BasePersister(config) + ``` +- `backend/unifier.py` `_unify_parameter()`: call `make_persister(config)` instead of inline if/else +- `Unifier.__init__`: optionally accept `persister_factory: Callable[[Config], BasePersister]` for testing + +**Verification:** `uv run pytest tests/test_cli/ -q` + +--- + ## §V Invariants +- HTTP retry backoff MUST be exponential with cap: `min(2**n, 60)` seconds (§T.16) +- HTTP request attempts MUST log `source`, `url`, `status_code`, `attempt`, `elapsed_ms` (§T.16) +- No bare `except Exception` in `backend/` — catch specific exception types (§T.20) +- `convert_units()` MUST raise `ValueError` on unknown parameter, never return `None` silently (§T.20) +- `print()` MUST NOT appear in `backend/` — all output through logger (§T.19) +- No method in `backend/` MUST exceed 50 lines (excluding `__init__`) (§T.21 §T.22) +- `Config` source setup MUST be driven by `PARAMETER_SOURCE_MAP`, not `if/elif` chains (§T.23) +- `BaseSource` MUST accept injected `http_client`; no direct `httpx.get()` calls (§T.24) +- `UnitConverter` MUST be injectable into `BaseTransformer` (§T.25) +- Persister selection logic MUST live in `make_persister()`, not in `Unifier` (§T.26) - No class MUST inherit `Loggable` — use `make_logger()` factory (§T.10) - No ST source class MUST use multiple inheritance — `STClient` injected as `self.client` (§T.11) - ST2 per-agency behavior MUST be expressed as constructor args, not subclasses (§T.12) From ff33cdb80e36b746cfb30f222d2b3ba4afc51fb3 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 13:27:53 -0600 Subject: [PATCH 117/126] =?UTF-8?q?T10+T17:=20Loggable=E2=86=92make=5Flogg?= =?UTF-8?q?er=20factory;=20class-level=20polygon=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §V: No class inherits Loggable (§T.10) §V: Polygon cache keyed by WKT at class level, not instance (§T.17) --- SPEC.md | 4 +-- backend/config.py | 10 ++++--- backend/logger.py | 26 +++++++++++++++++ backend/persister.py | 15 ++++------ backend/source.py | 52 ++++------------------------------ backend/transformer.py | 63 +++++++++--------------------------------- 6 files changed, 59 insertions(+), 111 deletions(-) diff --git a/SPEC.md b/SPEC.md index d2c6621..4774e90 100644 --- a/SPEC.md +++ b/SPEC.md @@ -654,7 +654,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.10 [.] Replace `Loggable` base with injected logger +### §T.10 [x] Replace `Loggable` base with injected logger **Goal:** Remove `Loggable` from the inheritance chain of all classes. **Changes:** @@ -768,7 +768,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.17 [.] Cache bounding polygon at class level +### §T.17 [x] Cache bounding polygon at class level **Goal:** Prevent re-parsing WKT shapely object on every record. **Changes:** diff --git a/backend/config.py b/backend/config.py index d45e95d..81b55c6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -73,7 +73,7 @@ ) from .connectors.usgs.source import NWISSiteSource, NWISWaterLevelSource from .connectors.wqp.source import WQPSiteSource, WQPAnalyteSource, WQPWaterLevelSource -from backend.logger import Loggable +from backend.logger import make_logger SOURCE_DICT = { @@ -104,7 +104,7 @@ def get_source(source): return klass() -class Config(Loggable): +class Config: site_limit: int = 0 dry: bool = False @@ -157,8 +157,10 @@ class Config(Loggable): yes: bool = False def __init__(self, model=None, payload=None, path=None): - # need to initialize logger - super().__init__() + _l = make_logger(self.__class__.__name__) + self.log = _l.log + self.warn = _l.warn + self.debug = _l.debug if path: payload = self._load_from_yaml(path) diff --git a/backend/logger.py b/backend/logger.py index c567362..71cb429 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -24,7 +24,33 @@ _managed_handlers = [] +class Logger: + """Standalone logger. Use make_logger() to create instances.""" + + def __init__(self, name: str): + self._name = name + self.logger = logging.getLogger(name) + + def log(self, msg, level=None, fg="yellow", **kwargs): + if level is None: + level = logging.INFO + click.secho(f"{self._name:40s}{msg}", fg=fg) + self.logger.log(level, msg, **kwargs) + + def warn(self, msg, fg="red", **kwargs): + self.log(msg, fg=fg, level=logging.WARNING, **kwargs) + + def debug(self, msg): + self.log(msg, level=logging.DEBUG, fg="blue") + + +def make_logger(name: str) -> Logger: + return Logger(name) + + class Loggable: + """Deprecated — do not subclass. Use make_logger() instead.""" + def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) diff --git a/backend/persister.py b/backend/persister.py index 8887828..1aeb453 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -20,7 +20,7 @@ import json from backend import OutputFormat -from backend.logger import Loggable +from backend.logger import make_logger try: @@ -87,19 +87,16 @@ def dump_sites_summary(path, records, output_format: OutputFormat): json.dump(feature_collection, f, indent=4) -class BasePersister(Loggable): - """ - Class to persist the data to a file or cloud storage. - If persisting to a file, the output directory is created by config._make_output_path() - """ - +class BasePersister: def __init__(self, config=None): self.records = [] self.timeseries = [] self.sites = [] self.config = config - - super().__init__() + _l = make_logger(self.__class__.__name__) + self.log = _l.log + self.warn = _l.warn + self.debug = _l.debug # self.keys = record_klass.keys def load(self, records: list): diff --git a/backend/source.py b/backend/source.py index 8d61e59..3a954fd 100644 --- a/backend/source.py +++ b/backend/source.py @@ -30,7 +30,7 @@ EARLIEST, LATEST, ) -from backend.logger import Loggable +from backend.logger import make_logger from backend.record import ( AnalyteRecord, AnalyteSummaryRecord, @@ -132,55 +132,15 @@ def get_analyte_search_param(parameter: str, mapping: dict) -> str: ) -class BaseSource(Loggable): - """ - The BaseSource class is a base class for all sources, whether it be a site source or a parameter source. - - ============================================================================ - Attributes - ============================================================================ - transformer_klass : BaseTransformer - - config : Config - the configuration class for the source - - tag : str - ============================================================================ - Methods With Universal Implementations (Already Implemented) - ============================================================================ - warn - Prints warning messages to the console in red - - log - Prints the message to the console in yellow - - _execute_text_request - Executes a get request to the provided url with query parameters and and returns the text response - - _execute_json_request - Executes a get request to the provided url with query parameters and and returns the json response - - ============================================================================ - Methods Implemented in BaseSiteSource and BaseParameterSource - ============================================================================ - read - Returns a list of transformed records - - ============================================================================ - Methods That Need to be Implemented For Each Source - ============================================================================ - health - Determines if the source is healthy - - get_records - Returns the site or parameter records from the source - """ - +class BaseSource: transformer_klass = BaseTransformer def __init__(self): self.transformer = self.transformer_klass() - super().__init__() + _l = make_logger(self.__class__.__name__) + self.log = _l.log + self.warn = _l.warn + self.debug = _l.debug @property def tag(self): diff --git a/backend/transformer.py b/backend/transformer.py index 4c8cbb8..ca5f51e 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -35,7 +35,7 @@ LATEST, ) from backend.geo_utils import datum_transform, ALLOWED_DATUMS -from backend.logger import Loggable +from backend.logger import make_logger from backend.record import ( WaterLevelSummaryRecord, WaterLevelRecord, @@ -45,8 +45,6 @@ AnalyteRecord, ) -logger = Loggable() - def transform_horizontal_datum( x: int | float, y: int | float, in_datum: str, out_datum: str @@ -301,47 +299,15 @@ def standardize_datetime(dt, record_id): return dt.strftime("%Y-%m-%d"), tt -class BaseTransformer(Loggable): - """ - Base class for transforming records. Transformers are used in BaseSiteSource and BaseParameterSource to transform records - - ============================================================================ - Methods With Universal Implementations (Already Implemented) - ============================================================================ - do_transform - Transforms a record, site or parameter, into a standardized format - - contained - Checks if a point is contained within a polygon - - ============================================================================ - Methods That Need to be Implemented For Each SiteTransformer - ============================================================================ - _transform - Transforms a record into a standardized format - - _post_transform - - ============================================================================ - Methods Implemented In Each ParameterTransformer (Don't Need To Be Implemented For Each Source) - ============================================================================ - _transform - - _get_parameter - - ============================================================================ - Methods That Are Implemented In Each ParameterTransformer and SiteTransformer (Don't Need To Be Implemented For Each Source) - ============================================================================ - _get_record_klass - """ - - _cached_polygon = None - # config = None +class BaseTransformer: + _polygon_cache: dict = {} check_contained = True - # ========================================================================== - # Methods Already Implemented - # ========================================================================== + def __init__(self): + _l = make_logger(self.__class__.__name__) + self.log = _l.log + self.warn = _l.warn + self.debug = _l.debug def set_config(self, config): """ @@ -594,14 +560,11 @@ def contained( """ config = self.config if config and config.has_bounds() and self.check_contained: - if not self._cached_polygon: - poly = shapely.wkt.loads(config.bounding_wkt()) - self._cached_polygon = poly - else: - poly = self._cached_polygon - - pt = Point(lng, lat) - return poly.contains(pt) + wkt = config.bounding_wkt() + if wkt not in BaseTransformer._polygon_cache: + BaseTransformer._polygon_cache[wkt] = shapely.wkt.loads(wkt) + poly = BaseTransformer._polygon_cache[wkt] + return poly.contains(Point(lng, lat)) return True From 778b9670b1a5e102a3ab36b1636abfe01d81bb09 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 13:29:28 -0600 Subject: [PATCH 118/126] T19: replace all print() with structured logging in backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §V: print() MUST NOT appear in backend/ core code --- SPEC.md | 2 +- backend/connectors/isc_seven_rivers/source.py | 2 +- backend/connectors/nmbgmr/source.py | 6 +++--- backend/persister.py | 4 +++- backend/record.py | 3 +-- backend/source.py | 9 ++------- backend/unifier.py | 14 ++++++++------ 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4774e90..d524ea0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -793,7 +793,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.19 [.] Replace all `print()` with structured logging +### §T.19 [x] Replace all `print()` with structured logging **Goal:** All console output goes through the logger; no raw `print()` in backend. **Changes:** diff --git a/backend/connectors/isc_seven_rivers/source.py b/backend/connectors/isc_seven_rivers/source.py index a796e1b..2f28dd5 100644 --- a/backend/connectors/isc_seven_rivers/source.py +++ b/backend/connectors/isc_seven_rivers/source.py @@ -79,7 +79,7 @@ def health(self): resp = self.get_records() return bool(resp) except Exception as e: - print("Failed to get records", e) + self.warn(f"Failed to get records: {e}") return False def get_records(self): diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 862bca6..dfce30d 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -98,14 +98,14 @@ def get_records(self): if not config.sites_only: for site in sites: if get_bool_env_variable("IS_TESTING_ENV"): - print( - f"Skipping well data for {site['properties']['point_id']} for testing (until well data can be retrieved in batches)" + self.log( + f"Skipping well data for {site['properties']['point_id']} for testing" ) site["properties"]["formation"] = None site["properties"]["well_depth"] = None site["properties"]["well_depth_units"] = FEET else: - print(f"Obtaining well data for {site['properties']['point_id']}") + self.log(f"Obtaining well data for {site['properties']['point_id']}") well_data = self._execute_json_request( _make_url("wells"), params={"pointid": site["properties"]["point_id"]}, diff --git a/backend/persister.py b/backend/persister.py index 1aeb453..d50f2ec 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -23,10 +23,12 @@ from backend.logger import make_logger +import logging as _logging + try: from google.cloud import storage except ImportError: - print("google cloud storage not available") + _logging.getLogger(__name__).debug("google cloud storage not available") def write_memory(func, records, output_format=None): diff --git a/backend/record.py b/backend/record.py index ac8a9f9..7b46002 100644 --- a/backend/record.py +++ b/backend/record.py @@ -71,8 +71,7 @@ def _get_sigfig_formatted_value(self, attr): try: v = round(v, sigfigs) except TypeError as e: - print(field, attr) - raise e + raise TypeError(f"rounding failed for field={field!r} attr={attr!r}") from e break return v diff --git a/backend/source.py b/backend/source.py index 3a954fd..f2900fd 100644 --- a/backend/source.py +++ b/backend/source.py @@ -402,15 +402,10 @@ def tag(self): return self.__class__.__name__.lower().replace("sitesource", "") def generate_bounding_polygon(self): - """ - Generates a bounding MultiPolygon base on the longitude and latitude - of each site record - """ records = self.read_sites() - print(records[0].latitude) + self.log(str(records[0].latitude)) mpt = MultiPoint([(r.longitude, r.latitude) for r in records]) - print(mpt.convex_hull.buffer(1 / 60.0).wkt) - # print(mpt.convex_hull.wkt) + self.log(mpt.convex_hull.buffer(1 / 60.0).wkt) def intersects(self, wkt: str) -> bool: """ diff --git a/backend/unifier.py b/backend/unifier.py index 1f7a39b..0f77cee 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -16,7 +16,9 @@ import shapely from backend.config import Config, get_source, OutputFormat -from backend.logger import setup_logging +from backend.logger import make_logger, setup_logging + +_log = make_logger("unifier") from backend.constants import WATERLEVELS from backend.persister import BasePersister try: @@ -49,7 +51,7 @@ def health_check(source: BaseSiteSource) -> bool | None: def unify_analytes(config): - print("Unifying analytes\n") + _log.log("Unifying analytes") # config.report() -- report is done in cli.py, no need to do it twice config.validate() @@ -60,7 +62,7 @@ def unify_analytes(config): def unify_waterlevels(config): - print("Unifying waterlevels\n") + _log.log("Unifying waterlevels") # config.report() -- report is done in cli.py, no need to do it twice config.validate() @@ -72,7 +74,7 @@ def unify_waterlevels(config): def unify_sites(config): - print("Unifying sites only\n") + _log.log("Unifying sites only") # config.report() -- report is done in cli.py, no need to do it twice config.validate() @@ -286,7 +288,7 @@ def get_sources_in_polygon(polygon): sources = get_sources() rets = [] for source in sources: - print(source) + _log.log(str(source)) if source.intersects(polygon): rets.append(source.tag) return rets @@ -405,7 +407,7 @@ def get_datastreams(): s = get_source("pvacd") for si in s.read_sites(): ds = get_datastream(si.id) - print(si, si.id, ds["@iot.id"]) + _log.log(f"{si} {si.id} {ds['@iot.id']}") # if __name__ == "__main__": From 78b9072dab59a0bdd4d26a4e85d65e6ea2b45b69 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 13:30:13 -0600 Subject: [PATCH 119/126] T16: exponential backoff min(2^n,60)s; structured HTTP request logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §V: retry backoff = min(2**n, 60) §V: HTTP attempts log source/url/status/attempt/elapsed_ms --- SPEC.md | 2 +- backend/source.py | 111 +++++++++++++++------------------------------- 2 files changed, 36 insertions(+), 77 deletions(-) diff --git a/SPEC.md b/SPEC.md index d524ea0..ebf8cbe 100644 --- a/SPEC.md +++ b/SPEC.md @@ -754,7 +754,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.16 [.] Exponential backoff + request structured logging +### §T.16 [x] Exponential backoff + request structured logging **Goal:** Fix linear retry backoff; add per-request structured log entries. **Changes:** diff --git a/backend/source.py b/backend/source.py index f2900fd..9d3242a 100644 --- a/backend/source.py +++ b/backend/source.py @@ -163,51 +163,32 @@ def discover(self, *args, **kw): # ========================================================================== def _execute_text_request(self, url: str, params: dict | None = None, max_tries: int = 7, **kw) -> str: - """ - Executes a get request to the provided url and returns the text response. - - Parameters - ---------- - url : str - the url to request - - params : dict - key-value query parameters to pass to the get request - - max_tries : int - the maximum number of times to retry the request if it fails - - Returns - ------- - str - the text responses - """ if "timeout" not in kw: - kw["timeout"] = 10 + kw["timeout"] = 900 tries: int = 0 - + last_err: str = "" while tries < max_tries: + t0 = time.monotonic() try: resp = httpx.get(url, params=params, **kw) + elapsed = int((time.monotonic() - t0) * 1000) + self.log( + f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_tries} elapsed_ms={elapsed} url={url}" + ) if resp.status_code == 200: return resp.text - else: - self.warn(f"service responded with status {resp.status_code}") - self.warn(f"service responded with text {resp.text}") - self.warn(f"URL: {url}") - self.warn(f"Parameters: {params}") - self.warn(f"Retrying... {tries+1}/{max_tries}") - except Exception as e: - self.warn(f"Error during request: {e}") - self.warn(f"URL: {url}") - self.warn(f"Parameters: {params}") - self.warn(f"Retrying... {tries+1}/{max_tries}") + last_err = f"status {resp.status_code}: {resp.text[:200]}" + self.warn(f"Received status code {resp.status_code}. Retrying... {tries+1}/{max_tries}") + except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.RequestError) as e: + elapsed = int((time.monotonic() - t0) * 1000) + last_err = str(e) + self.warn(f"Request error attempt={tries+1}/{max_tries} elapsed_ms={elapsed} url={url}: {e}") tries += 1 - time.sleep(tries) + time.sleep(min(2 ** tries, 60)) - self.warn("Failed to retrieve records after multiple attempts") - raise PartialOrNoDataError("Failed to retrieve records after multiple attempts") + self.warn(f"Failed to retrieve records after {max_tries} attempts. Last error: {last_err}") + raise PartialOrNoDataError(f"Failed to retrieve records after {max_tries} attempts. Last error: {last_err}") def _execute_json_request( self, @@ -217,59 +198,37 @@ def _execute_json_request( max_retries: int = 7, **kw ) -> dict: - """ - Executes a get request to the provided url and returns the json response. - - Parameters - ---------- - url : str - the url to request - - params : dict - key-value query parameters to pass to the get request - - tag : str - the key to extract from the json response if required - - max_retries : int - the maximum number of times to retry the request if it fails - - Returns - ------- - dict - the json response - """ tries: int = 0 + last_err: str = "" while tries < max_retries: + t0 = time.monotonic() try: resp = httpx.get(url, params=params, **kw) - + elapsed = int((time.monotonic() - t0) * 1000) + self.log( + f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_retries} elapsed_ms={elapsed} url={url}" + ) if resp.status_code == 200: try: obj = resp.json() if tag and isinstance(obj, dict): return obj[tag] return obj - except JSONDecodeError: - self.warn(f"service responded but with invalid or no JSON data. \n{resp.text}") - self.warn(f"URL: {url}") - self.warn(f"Parameters: {params}") - self.warn(f"Retrying... {tries+1}/{max_retries}") + except JSONDecodeError as e: + last_err = f"JSONDecodeError: {e}. Response: {resp.text[:200]}" + self.warn(f"Invalid JSON response attempt={tries+1}/{max_retries} url={url}: {last_err}") else: - self.warn(f"service responded with status {resp.status_code}") - self.warn(f"service responded with text {resp.text} for url {resp.url}") - self.warn(f"Parameters: {params}") - self.warn(f"Retrying... {tries+1}/{max_retries}") - except Exception as e: - self.warn(f"Error during request: {e}") - self.warn(f"URL: {url}") - self.warn(f"Parameters: {params}") - self.warn(f"Retrying... {tries+1}/{max_retries}") + last_err = f"status {resp.status_code}: {resp.text[:200]}" + self.warn(f"Received status code {resp.status_code}. Retrying... {tries+1}/{max_retries}") + except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.RequestError) as e: + elapsed = int((time.monotonic() - t0) * 1000) + last_err = str(e) + self.warn(f"Request error attempt={tries+1}/{max_retries} elapsed_ms={elapsed} url={url}: {e}") tries += 1 - time.sleep(tries) - - self.warn("Failed to retrieve records after multiple attempts") - raise PartialOrNoDataError("Failed to retrieve records after multiple attempts") + time.sleep(min(2 ** tries, 60)) + + self.warn(f"Failed to retrieve records after {max_retries} attempts. Last error: {last_err}") + raise PartialOrNoDataError(f"Failed to retrieve records after {max_retries} attempts. Last error: {last_err}") # ========================================================================== # Methods Implemented in BaseSiteSource and BaseParameterSource From f923603e8d1e58c31a61b03d5c66aa2ec99bf8ea Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 13:31:14 -0600 Subject: [PATCH 120/126] =?UTF-8?q?T20:=20specific=20exceptions,=20coord?= =?UTF-8?q?=20range=20validation,=20fix=20BaseException=E2=86=92Exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §V: no bare except Exception — catch specific types §V: coordinate range validated before transform --- SPEC.md | 2 +- backend/transformer.py | 6 ++++++ backend/unifier.py | 5 ++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index ebf8cbe..d5ee5ad 100644 --- a/SPEC.md +++ b/SPEC.md @@ -808,7 +808,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.20 [.] Specific exception handling + input validation +### §T.20 [x] Specific exception handling + input validation **Goal:** No bare `except Exception`; all swallowed errors surface detail. **Changes:** diff --git a/backend/transformer.py b/backend/transformer.py index ca5f51e..c13715c 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -425,6 +425,12 @@ def do_transform( ) return None + if not (-180 <= x <= 180) or not (-90 <= y <= 90): + self.warn( + f"Skipping site {klassed_record.id}. Coordinates out of range: lng={x}, lat={y}" + ) + return None + input_horizontal_datum = klassed_record.horizontal_datum if input_horizontal_datum not in ALLOWED_DATUMS: diff --git a/backend/unifier.py b/backend/unifier.py index 0f77cee..40a9d3b 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -238,11 +238,10 @@ def _site_wrapper(site_source, parameter_source, persister, config): ] break - except BaseException: + except Exception: import traceback - exc = traceback.format_exc() - config.warn(exc) + config.warn(traceback.format_exc()) config.warn(f"Failed to unify {site_source}") From 71617cc2ecfd09624a6fa247959445e17c2e59f1 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 13:31:52 -0600 Subject: [PATCH 121/126] T26: PersisterFactory extracted from Unifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §V: persister selection logic in make_persister(), not in Unifier --- SPEC.md | 2 +- backend/persisters/factory.py | 22 ++++++++++++++++++++++ backend/unifier.py | 16 ++-------------- 3 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 backend/persisters/factory.py diff --git a/SPEC.md b/SPEC.md index d5ee5ad..dd6d455 100644 --- a/SPEC.md +++ b/SPEC.md @@ -904,7 +904,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.26 [.] `PersisterFactory` extracted from `Unifier` +### §T.26 [x] `PersisterFactory` extracted from `Unifier` **Goal:** Remove persister selection if/else from `_unify_parameter()`. **Changes:** diff --git a/backend/persisters/factory.py b/backend/persisters/factory.py new file mode 100644 index 0000000..e45a652 --- /dev/null +++ b/backend/persisters/factory.py @@ -0,0 +1,22 @@ +from backend import OutputFormat +from backend.persister import BasePersister, CloudStoragePersister + + +def make_persister(config) -> BasePersister: + try: + from backend.persisters.geoserver import GeoServerPersister + except ImportError: + GeoServerPersister = None + + if config.output_format == OutputFormat.GEOSERVER: + if GeoServerPersister is None: + raise ImportError( + "GeoServer output requires 'geoserver' extras: " + "pip install nmuwd[geoserver]" + ) + return GeoServerPersister(config) + + if config.use_cloud_storage: + return CloudStoragePersister(config) + + return BasePersister(config) diff --git a/backend/unifier.py b/backend/unifier.py index 40a9d3b..739f6a7 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -20,11 +20,7 @@ _log = make_logger("unifier") from backend.constants import WATERLEVELS -from backend.persister import BasePersister -try: - from backend.persisters.geoserver import GeoServerPersister -except ImportError: - GeoServerPersister = None +from backend.persisters.factory import make_persister from backend.source import BaseSiteSource from backend.exceptions import USGSRateLimitError, PartialOrNoDataError @@ -250,15 +246,7 @@ def _unify_parameter( sources, ): - if config.output_format == OutputFormat.GEOSERVER: - if GeoServerPersister is None: - raise ImportError( - "GeoServer output requires 'geoserver' extras: " - "pip install nmuwd[geoserver]" - ) - persister = GeoServerPersister(config) - else: - persister = BasePersister(config) + persister = make_persister(config) for site_source, parameter_source in sources: _site_wrapper( From 33e4ae3168378a26cd71af5c6aa9d54abd3461b8 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 22 Jun 2026 16:06:45 -0600 Subject: [PATCH 122/126] =?UTF-8?q?T11-T15,T18,T21-T25:=20composition=20re?= =?UTF-8?q?factor=20=E2=80=94=20eliminate=20inheritance,=20inject=20depend?= =?UTF-8?q?encies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T11: STSource mixin → STClient composition; single inheritance throughout - T12: 5 ST2 subclasses → functools.partial configured instances - T13: CloudStoragePersister → OutputStrategy protocol (LocalFileStrategy, GCSStrategy) - T14: transformer_klass class attr → transformer= constructor injection; BaseTransformer default - T15: WaterLevelRecord/AnalyteRecord/WaterLevelSummaryRecord/AnalyteSummaryRecord deleted; record_type field replaces isinstance() checks - T18: BaseParameterSource.read() extracts _summarize_records/_build_timeseries_records; fixes double do_transform() call in timeseries path - T21: BaseParameterSource 477→133 lines; RecordValidator + subclasses extracted; RecordSummarizer extracted; read()→read_summary()+read_timeseries(); bookend→position - T22: do_transform() 191-line monolith → orchestrator + 6 focused helpers (_apply_geographic_filter, _standardize_datetime, _apply_datum_transform, _apply_elevation_transform, _apply_well_depth_transform, _apply_unit_conversion) - T23: get_config_and_false_agencies() 107-line if/elif → PARAMETER_SOURCE_MAP lookup; _build_source_pair() DRYs analyte_sources()/water_level_sources() - T24: BaseSource.__init__ accepts http_client=; all httpx.get() → self._http_client.get() - T25: backend/converter.py — UnitConverter protocol + StandardUnitConverter; BaseTransformer.converter injectable; convert_units() kept as deprecated wrapper Co-Authored-By: Claude Sonnet 4.6 --- SPEC.md | 24 +- backend/config.py | 206 +--- backend/connectors/bor/source.py | 11 +- backend/connectors/bor/transformer.py | 2 +- backend/connectors/ckan/source.py | 15 +- backend/connectors/ckan/transformer.py | 2 +- backend/connectors/isc_seven_rivers/source.py | 20 +- backend/connectors/nmbgmr/source.py | 18 +- backend/connectors/nmbgmr/transformer.py | 2 +- backend/connectors/nmenv/source.py | 16 +- backend/connectors/nmose/source.py | 4 +- backend/connectors/st2/source.py | 119 +- backend/connectors/st2/transformer.py | 6 +- backend/connectors/st_connector.py | 106 +- backend/connectors/usgs/source.py | 15 +- backend/connectors/usgs/transformer.py | 2 +- backend/connectors/wqp/source.py | 17 +- backend/connectors/wqp/transformer.py | 2 +- backend/converter.py | 118 ++ backend/persister.py | 169 +-- backend/persisters/factory.py | 10 +- backend/persisters/strategies.py | 63 ++ backend/record.py | 18 +- backend/source.py | 1007 ++++------------- backend/transformer.py | 453 ++------ tests/test_persisters/test_ogc_features.py | 5 +- 26 files changed, 840 insertions(+), 1590 deletions(-) create mode 100644 backend/converter.py create mode 100644 backend/persisters/strategies.py diff --git a/SPEC.md b/SPEC.md index dd6d455..af548c4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -514,7 +514,7 @@ Cloud Run Job env vars (from Secret Manager): ## §9 Tasks -### §T.1 [~] uv migration +### §T.1 [x] uv migration - Delete `setup.py`, `requirements.txt`, `pytest.ini`, `mypy.ini` - Write root `pyproject.toml` (§7.1) - Run `uv lock` @@ -666,7 +666,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.11 [.] Replace `STSource` mixin with `STClient` composition +### §T.11 [x] Replace `STSource` mixin with `STClient` composition **Goal:** Kill multiple inheritance in all ST source classes. **Changes:** @@ -681,7 +681,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.12 [.] Collapse ST2 class hierarchy into configured instances +### §T.12 [x] Collapse ST2 class hierarchy into configured instances **Goal:** Delete 5 nearly-identical site source classes; replace with factory. **Affected classes (delete):** @@ -699,7 +699,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.13 [.] Replace `CloudStoragePersister` with output strategy injection +### §T.13 [x] Replace `CloudStoragePersister` with output strategy injection **Goal:** `BasePersister` accepts an output strategy; `CloudStoragePersister` subclass deleted. **Changes:** @@ -727,7 +727,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.14 [.] Inject transformer into source constructor +### §T.14 [x] Inject transformer into source constructor **Goal:** Remove `transformer_klass` class attribute pattern; pass transformer as dependency. **Changes:** @@ -740,7 +740,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.15 [.] Collapse empty record subclasses +### §T.15 [x] Collapse empty record subclasses **Goal:** `WaterLevelRecord`, `AnalyteRecord`, `WaterLevelSummaryRecord`, `AnalyteSummaryRecord` add zero behavior — remove them. **Changes:** @@ -780,7 +780,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.18 [.] Batch extraction in `BaseParameterSource.read()` +### §T.18 [x] Batch extraction in `BaseParameterSource.read()` **Goal:** Extract dates/results/units/names once before the per-site loop, not once per site. **Changes:** @@ -828,7 +828,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.21 [.] Split `BaseParameterSource` god class +### §T.21 [x] Split `BaseParameterSource` god class **Goal:** 476-line class → focused classes ≤150 lines each. **Changes:** @@ -842,7 +842,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.22 [.] Split `do_transform()` into focused methods +### §T.22 [x] Split `do_transform()` into focused methods **Goal:** 191-line method → orchestrator + focused helpers ≤30 lines each. **Changes:** @@ -858,7 +858,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.23 [.] Data-driven `Config` source setup +### §T.23 [x] Data-driven `Config` source setup **Goal:** Replace ~107-line `if/elif` per parameter in `get_config_and_false_agencies()` with a mapping. **Changes:** @@ -871,7 +871,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.24 [.] Inject HTTP client into `BaseSource` +### §T.24 [x] Inject HTTP client into `BaseSource` **Goal:** `httpx.get()` hardcoded → injected client; enables testability without live network. **Changes:** @@ -883,7 +883,7 @@ feature/composition-refactor ← branch off main after §T.9 merged --- -### §T.25 [.] `UnitConverter` as injectable strategy +### §T.25 [x] `UnitConverter` as injectable strategy **Goal:** Replace 120+ line `convert_units()` monolith with pluggable converter. **Changes:** diff --git a/backend/config.py b/backend/config.py index 81b55c6..1709b55 100644 --- a/backend/config.py +++ b/backend/config.py @@ -76,6 +76,27 @@ from backend.logger import make_logger +PARAMETER_SOURCE_MAP = { + WATERLEVELS: {"agencies": ["bernco", "cabq", "ebid", "nmbgmr_amp", "nmose_isc_seven_rivers", "nmose_roswell", "nwis", "pvacd", "wqp"]}, + CARBONATE: {"agencies": ["nmbgmr_amp", "wqp"]}, + ARSENIC: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "wqp"]}, + URANIUM: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "wqp"]}, + SPECIFIC_CONDUCTANCE: {"agencies": ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + CONDUCTIVITY: {"agencies": ["bor", "nmose_isc_seven_rivers", "wqp"]}, + BICARBONATE: {"agencies": ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + CALCIUM: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + CHLORIDE: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + FLUORIDE: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + MAGNESIUM: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + NITRATE: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + PH: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + POTASSIUM: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + SILICA: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + SODIUM: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + SULFATE: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, + TDS: {"agencies": ["bor", "nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"]}, +} + SOURCE_DICT = { "bernco": BernCoSiteSource, "bor": BORSiteSource, @@ -218,114 +239,19 @@ def _load_from_yaml(self, path): self.warn(f"Config file {path} not found") def get_config_and_false_agencies(self): - if self.parameter == WATERLEVELS: - config_agencies = [ - "bernco", - "cabq", - "ebid", - "nmbgmr_amp", - "nmose_isc_seven_rivers", - "nmose_roswell", - "nwis", - "pvacd", - "wqp", - ] - false_agencies = ["bor", "nmose_pod", "nmed_dwb"] - elif self.parameter == CARBONATE: - config_agencies = ["nmbgmr_amp", "wqp"] - false_agencies = [ - "bor", - "bernco", - "cabq", - "ebid", - "nmed_dwb", - "nmose_isc_seven_rivers", - "nmose_pod", - "nmose_roswell", - "nwis", - "pvacd", - ] - elif self.parameter in [ARSENIC, URANIUM]: - config_agencies = ["bor", "nmbgmr_amp", "nmed_dwb", "wqp"] - false_agencies = [ - "bernco", - "cabq", - "ebid", - "nmose_isc_seven_rivers", - "nmose_roswell", - "nmose_pod", - "nwis", - "pvacd", - ] - elif self.parameter in [SPECIFIC_CONDUCTANCE]: - config_agencies = ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"] - false_agencies = [ - "bor", - "bernco", - "cabq", - "ebid", - "nmose_roswell", - "nmose_pod", - "nwis", - "pvacd", - ] - elif self.parameter in [CONDUCTIVITY]: - config_agencies = ["bor", "nmose_isc_seven_rivers", "wqp"] - false_agencies = [ - "bor", - "bernco", - "cabq", - "ebid", - "nmbgmr_amp", - "nmed_dwb", - "nmose_roswell", - "nmose_pod", - "nwis", - "pvacd", - ] - elif self.parameter in [BICARBONATE]: - config_agencies = ["nmbgmr_amp", "nmed_dwb", "nmose_isc_seven_rivers", "wqp"] - false_agencies = [ - "bor", - "bernco", - "cabq", - "ebid", - "nmose_roswell", - "nmose_pod", - "nwis", - "pvacd", - ] - elif self.parameter in [ - CALCIUM, - CHLORIDE, - FLUORIDE, - MAGNESIUM, - NITRATE, - PH, - POTASSIUM, - SILICA, - SODIUM, - SULFATE, - TDS, - ]: - config_agencies = [ - "bor", - "nmbgmr_amp", - "nmed_dwb", - "nmose_isc_seven_rivers", - "wqp", - ] - false_agencies = [ - "bernco", - "cabq", - "ebid", - "nmose_roswell", - "nmose_pod", - "nwis", - "pvacd", - ] + entry = PARAMETER_SOURCE_MAP.get(self.parameter) + if entry is None: + raise ValueError(f"Unknown parameter {self.parameter!r}. Valid parameters: {sorted(PARAMETER_SOURCE_MAP)}") + config_agencies = entry["agencies"] + false_agencies = [a for a in SOURCE_KEYS if a not in config_agencies] return config_agencies, false_agencies + def _build_source_pair(self, site_klass, param_klass): + s, ss = site_klass(), param_klass() + s.set_config(self) + ss.set_config(self) + return s, ss + def finalize(self): self._update_output_units() if self.output_format != OutputFormat.GEOSERVER: @@ -348,56 +274,28 @@ def all_site_sources(self): return sources def analyte_sources(self): - sources = [] - - if self.use_source_bor: - sources.append((BORSiteSource(), BORAnalyteSource())) - if self.use_source_wqp: - sources.append((WQPSiteSource(), WQPAnalyteSource())) - if self.use_source_nmose_isc_seven_rivers: - sources.append((ISCSevenRiversSiteSource(), ISCSevenRiversAnalyteSource())) - if self.use_source_nmbgmr_amp: - sources.append((NMBGMRSiteSource(), NMBGMRAnalyteSource())) - if self.use_source_nmed_dwb: - sources.append((DWBSiteSource(), DWBAnalyteSource())) - - for s, ss in sources: - s.set_config(self) - ss.set_config(self) - - return sources + pairs = [ + (BORSiteSource, BORAnalyteSource, self.use_source_bor), + (WQPSiteSource, WQPAnalyteSource, self.use_source_wqp), + (ISCSevenRiversSiteSource, ISCSevenRiversAnalyteSource, self.use_source_nmose_isc_seven_rivers), + (NMBGMRSiteSource, NMBGMRAnalyteSource, self.use_source_nmbgmr_amp), + (DWBSiteSource, DWBAnalyteSource, self.use_source_nmed_dwb), + ] + return [self._build_source_pair(s, ss) for s, ss, enabled in pairs if enabled] def water_level_sources(self): - sources = [] - if self.use_source_nmbgmr_amp: - sources.append((NMBGMRSiteSource(), NMBGMRWaterLevelSource())) - - if self.use_source_nmose_isc_seven_rivers: - sources.append( - (ISCSevenRiversSiteSource(), ISCSevenRiversWaterLevelSource()) - ) - - if self.use_source_nwis: - sources.append((NWISSiteSource(), NWISWaterLevelSource())) - - if self.use_source_nmose_roswell: - sources.append((NMOSERoswellSiteSource(), NMOSERoswellWaterLevelSource())) - if self.use_source_pvacd: - sources.append((PVACDSiteSource(), PVACDWaterLevelSource())) - if self.use_source_bernco: - sources.append((BernCoSiteSource(), BernCoWaterLevelSource())) - if self.use_source_ebid: - sources.append((EBIDSiteSource(), EBIDWaterLevelSource())) - if self.use_source_cabq: - sources.append((CABQSiteSource(), CABQWaterLevelSource())) - if self.use_source_wqp: - sources.append((WQPSiteSource(), WQPWaterLevelSource())) - - for s, ss in sources: - s.set_config(self) - ss.set_config(self) - - return sources + pairs = [ + (NMBGMRSiteSource, NMBGMRWaterLevelSource, self.use_source_nmbgmr_amp), + (ISCSevenRiversSiteSource, ISCSevenRiversWaterLevelSource, self.use_source_nmose_isc_seven_rivers), + (NWISSiteSource, NWISWaterLevelSource, self.use_source_nwis), + (NMOSERoswellSiteSource, NMOSERoswellWaterLevelSource, self.use_source_nmose_roswell), + (PVACDSiteSource, PVACDWaterLevelSource, self.use_source_pvacd), + (BernCoSiteSource, BernCoWaterLevelSource, self.use_source_bernco), + (EBIDSiteSource, EBIDWaterLevelSource, self.use_source_ebid), + (CABQSiteSource, CABQWaterLevelSource, self.use_source_cabq), + (WQPSiteSource, WQPWaterLevelSource, self.use_source_wqp), + ] + return [self._build_source_pair(s, ss) for s, ss, enabled in pairs if enabled] def bbox_bounding_points(self, bbox=None): if bbox is None: diff --git a/backend/connectors/bor/source.py b/backend/connectors/bor/source.py index 050a9bf..7ef532e 100644 --- a/backend/connectors/bor/source.py +++ b/backend/connectors/bor/source.py @@ -41,7 +41,8 @@ class BORSiteSource(BaseSiteSource): - transformer_klass = BORSiteTransformer + def __init__(self): + super().__init__(transformer=BORSiteTransformer()) def __repr__(self): return "BORSiteSource" @@ -65,8 +66,10 @@ def parse_dt(dt): class BORAnalyteSource(BaseAnalyteSource): - transformer_klass = BORAnalyteTransformer _catalog_item_idx = None + + def __init__(self): + super().__init__(transformer=BORAnalyteTransformer()) _source_parameter_name = None def __repr__(self): @@ -95,8 +98,8 @@ def _extract_parameter_dates(self, records): def _extract_source_parameter_names(self, records): return [self._source_parameter_name for ri in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "attributes.dateTime", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "attributes.dateTime", position=position) return { "value": record["attributes"]["result"], "datetime": parse_dt(record["attributes"]["dateTime"]), diff --git a/backend/connectors/bor/transformer.py b/backend/connectors/bor/transformer.py index 4dd81a3..8692b52 100644 --- a/backend/connectors/bor/transformer.py +++ b/backend/connectors/bor/transformer.py @@ -16,7 +16,7 @@ import pprint import json -from backend.record import SiteRecord, WaterLevelRecord, AnalyteSummaryRecord +from backend.record import SiteRecord from backend.transformer import ( BaseTransformer, WaterLevelTransformer, diff --git a/backend/connectors/ckan/source.py b/backend/connectors/ckan/source.py index 736d668..680c914 100644 --- a/backend/connectors/ckan/source.py +++ b/backend/connectors/ckan/source.py @@ -62,7 +62,7 @@ def get_response(self): raise NotImplementedError("base_url is not set") if self._cached_response is None: - self._cached_response = httpx.get(self.base_url, params=self._get_params()) + self._cached_response = self._http_client.get(self.base_url, params=self._get_params()) return self._cached_response @@ -91,9 +91,8 @@ def _get_params(self): class OSERoswellSiteSource(OSERoswellSource, BaseSiteSource): - transformer_klass = OSERoswellSiteTransformer - def __init__(self, resource_id, **kw): + kw.setdefault("transformer", OSERoswellSiteTransformer()) super().__init__(resource_id, **kw) if resource_id == HONDO_RESOURCE_ID: self.bounding_polygon = OSE_ROSWELL_HONDO_BOUNDING_POLYGON @@ -108,7 +107,7 @@ def __repr__(self): def health(self): params = self._get_params() params["limit"] = 1 - resp = httpx.get(self.base_url, params=params) + resp = self._http_client.get(self.base_url, params=params) return resp.status_code == 200 def _parse_response(self, resp): @@ -123,7 +122,9 @@ def _parse_response(self, resp): class OSERoswellWaterLevelSource(OSERoswellSource, BaseWaterLevelSource): - transformer_klass = OSERoswellWaterLevelTransformer + def __init__(self, resource_id=None, **kw): + kw.setdefault("transformer", OSERoswellWaterLevelTransformer()) + super().__init__(resource_id, **kw) def __repr__(self): return "NMOSERoswellWaterLevelSource" @@ -138,8 +139,8 @@ def _parse_response(self, site_record, resp): def _extract_source_parameter_results(self, records): return [float(r["DTWGS"]) for r in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, tag="Date", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, tag="Date", position=position) return { "value": record["DTWGS"], "datetime": record["Date"], diff --git a/backend/connectors/ckan/transformer.py b/backend/connectors/ckan/transformer.py index e56d609..e9ba1ee 100644 --- a/backend/connectors/ckan/transformer.py +++ b/backend/connectors/ckan/transformer.py @@ -15,7 +15,7 @@ # =============================================================================== import pprint -from backend.record import SiteRecord, WaterLevelRecord +from backend.record import SiteRecord from backend.transformer import BaseTransformer, WaterLevelTransformer, SiteTransformer diff --git a/backend/connectors/isc_seven_rivers/source.py b/backend/connectors/isc_seven_rivers/source.py index 2f28dd5..d456c18 100644 --- a/backend/connectors/isc_seven_rivers/source.py +++ b/backend/connectors/isc_seven_rivers/source.py @@ -68,9 +68,11 @@ def _make_url(endpoint): class ISCSevenRiversSiteSource(BaseSiteSource): - transformer_klass = ISCSevenRiversSiteTransformer bounding_polygon = ISC_SEVEN_RIVERS_BOUNDING_POLYGON + def __init__(self): + super().__init__(transformer=ISCSevenRiversSiteTransformer()) + def __repr__(self): return "ISCSevenRiversSiteSource" @@ -90,10 +92,12 @@ def get_records(self): class ISCSevenRiversAnalyteSource(BaseAnalyteSource): - transformer_klass = ISCSevenRiversAnalyteTransformer _analyte_ids = None _source_parameter_name = None + def __init__(self): + super().__init__(transformer=ISCSevenRiversAnalyteTransformer()) + def __repr__(self): return "ISCSevenRiversAnalyteSource" @@ -123,8 +127,8 @@ def _extract_parameter_record(self, record): return record - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "dateTime", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "dateTime", position=position) return { "value": record["result"], @@ -170,8 +174,10 @@ def get_records(self, site_record): class ISCSevenRiversWaterLevelSource(BaseWaterLevelSource): - transformer_klass = ISCSevenRiversWaterLevelTransformer _source_parameter_name = "depthToWaterFeet" + + def __init__(self): + super().__init__(transformer=ISCSevenRiversWaterLevelTransformer()) _source_parameter_units = FEET def get_records(self, site_record): @@ -216,8 +222,8 @@ def _extract_source_parameter_names(self, records): def _extract_source_parameter_units(self, records): return [self._source_parameter_units for r in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "dateTime", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "dateTime", position=position) t = get_datetime(record) return { "value": record["depthToWaterFeet"], diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index dfce30d..5823bee 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -60,10 +60,12 @@ def _make_url(endpoint): class NMBGMRSiteSource(BaseSiteSource): - transformer_klass = NMBGMRSiteTransformer chunk_size = 10 bounding_polygon = NM_STATE_BOUNDING_POLYGON + def __init__(self): + super().__init__(transformer=NMBGMRSiteTransformer()) + def __repr__(self): return "NMBGMRSiteSource" @@ -119,7 +121,8 @@ def get_records(self): class NMBGMRAnalyteSource(BaseAnalyteSource): - transformer_klass = NMBGMRAnalyteTransformer + def __init__(self): + super().__init__(transformer=NMBGMRAnalyteTransformer()) def __repr__(self): return "NMBGMRAnalyteSource" @@ -151,8 +154,8 @@ def _extract_site_records(self, records, site_record): def _extract_source_parameter_units(self, records): return [r["Units"] for r in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "info.CollectionDate", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "info.CollectionDate", position=position) return { "value": record["SampleValue"], "datetime": record["info"]["CollectionDate"], @@ -181,7 +184,8 @@ def _extract_parameter_record(self, record): class NMBGMRWaterLevelSource(BaseWaterLevelSource): - transformer_klass = NMBGMRWaterLevelTransformer + def __init__(self): + super().__init__(transformer=NMBGMRWaterLevelTransformer()) def __repr__(self): return "NMBGMRWaterLevelSource" @@ -203,8 +207,8 @@ def _extract_parameter_record(self, record, *args, **kw): record[SOURCE_PARAMETER_UNITS] = record["DepthToWaterBGSUnits"] return record - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "DateMeasured", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "DateMeasured", position=position) return { "value": record["DepthToWaterBGS"], "datetime": (record["DateMeasured"], record["TimeMeasured"]), diff --git a/backend/connectors/nmbgmr/transformer.py b/backend/connectors/nmbgmr/transformer.py index 420c7f6..db7bf67 100644 --- a/backend/connectors/nmbgmr/transformer.py +++ b/backend/connectors/nmbgmr/transformer.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from backend.constants import DTW -from backend.record import SiteRecord, WaterLevelRecord +from backend.record import SiteRecord from backend.transformer import ( BaseTransformer, WaterLevelTransformer, diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index 50141d0..3ae2170 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -38,9 +38,11 @@ class DWBSiteSource(STSiteSource): url = URL - transformer_klass = DWBSiteTransformer bounding_polygon = NM_STATE_BOUNDING_POLYGON + def __init__(self): + super().__init__(transformer=DWBSiteTransformer()) + def __repr__(self): return "DWBSiteSource" @@ -59,7 +61,7 @@ def get_records(self, *args, **kw): elif self.config: analyte = self.config.parameter - service = self.get_service() + service = self.client.get_service() if self.config.sites_only: ds = service.things() q = ds.query() @@ -110,7 +112,9 @@ def get_records(self, *args, **kw): class DWBAnalyteSource(STAnalyteSource): url = URL - transformer_klass = DWBAnalyteTransformer + + def __init__(self): + super().__init__(transformer=DWBAnalyteTransformer()) def __repr__(self): return "DWBAnalyteSource" @@ -131,7 +135,7 @@ def _parse_result( return float(result.split(" ")[0]) def get_records(self, site, *args, **kw): - service = self.get_service() + service = self.client.get_service() analyte = get_analyte_search_param(self.config.parameter, DWB_ANALYTE_MAPPING) ds = service.datastreams() @@ -190,10 +194,10 @@ def _extract_parameter_dates(self, records: list) -> list: def _extract_source_parameter_names(self, records: list) -> list: return [r["datastream"].observed_property.name for r in records] - def _extract_terminal_record(self, records, bookend): + def _extract_terminal_record(self, records, position): # this is only used in summary output record = get_terminal_record( - records, tag=lambda x: x["observation"].phenomenon_time, bookend=bookend + records, tag=lambda x: x["observation"].phenomenon_time, position=position ) return { diff --git a/backend/connectors/nmose/source.py b/backend/connectors/nmose/source.py index 5def1bf..fa785e0 100644 --- a/backend/connectors/nmose/source.py +++ b/backend/connectors/nmose/source.py @@ -19,10 +19,12 @@ class NMOSEPODSiteSource(BaseSiteSource): It is used to fetch site data from the NMOSEPOD API. """ - transformer_klass = NMOSEPODSiteTransformer chunk_size: int = 5000 bounding_polygon = NM_STATE_BOUNDING_POLYGON + def __init__(self): + super().__init__(transformer=NMOSEPODSiteTransformer()) + def get_records(self, *args, **kw) -> List[Dict]: config = self.config params: Dict[str, Any] = {} diff --git a/backend/connectors/st2/source.py b/backend/connectors/st2/source.py index 181513b..ee947f9 100644 --- a/backend/connectors/st2/source.py +++ b/backend/connectors/st2/source.py @@ -14,8 +14,7 @@ # limitations under the License. # =============================================================================== import datetime - -import frost_sta_client as fsc +from functools import partial from backend.connectors import ( PVACD_BOUNDING_POLYGON, @@ -56,63 +55,60 @@ class ST2SiteSource(STSiteSource): - agency: str url = URL + def __init__(self, agency: str, bounding_polygon=None, transformer=None): + self.agency = agency + if bounding_polygon is not None: + self.bounding_polygon = bounding_polygon + super().__init__(transformer=transformer) + + def __repr__(self): + return f"ST2SiteSource(agency={self.agency!r})" + def _get_filters(self): if self.agency is None: raise ValueError(f"{self.__class__.__name__}. Agency not set") - return [f"properties/agency eq '{self.agency}'"] -class NMOSERoswellSiteSource(ST2SiteSource): - transformer_klass = NMOSERoswellSiteTransformer - agency = "OSE-Roswell" - - def __repr__(self): - return "NMOSERoswellSiteSource" - - -class PVACDSiteSource(ST2SiteSource): - transformer_klass = PVACDSiteTransformer - agency = "PVACD" - bounding_polygon = PVACD_BOUNDING_POLYGON - - def __repr__(self): - return "PVACDSiteSource" - - -class EBIDSiteSource(ST2SiteSource): - transformer_klass = EBIDSiteTransformer - agency = "EBID" - bounding_polygon = EBID_BOUNDING_POLYGON - - def __repr__(self): - return "EBIDSiteSource" - - -class BernCoSiteSource(ST2SiteSource): - agency = "BernCo" - transformer_klass = BernCoSiteTransformer - bounding_polygon = BERNCO_BOUNDING_POLYGON - - def __repr__(self): - return "BernCoSiteSource" - - -class CABQSiteSource(ST2SiteSource): - transformer_klass = CABQSiteTransformer - agency = "CABQ" - bounding_polygon = CABQ_BOUNDING_POLYGON - - def __repr__(self): - return "CABQSiteSource" +NMOSERoswellSiteSource = partial( + ST2SiteSource, + agency="OSE-Roswell", + transformer=NMOSERoswellSiteTransformer(), +) +PVACDSiteSource = partial( + ST2SiteSource, + agency="PVACD", + bounding_polygon=PVACD_BOUNDING_POLYGON, + transformer=PVACDSiteTransformer(), +) +EBIDSiteSource = partial( + ST2SiteSource, + agency="EBID", + bounding_polygon=EBID_BOUNDING_POLYGON, + transformer=EBIDSiteTransformer(), +) +BernCoSiteSource = partial( + ST2SiteSource, + agency="BernCo", + bounding_polygon=BERNCO_BOUNDING_POLYGON, + transformer=BernCoSiteTransformer(), +) +CABQSiteSource = partial( + ST2SiteSource, + agency="CABQ", + bounding_polygon=CABQ_BOUNDING_POLYGON, + transformer=CABQSiteTransformer(), +) class ST2WaterLevelSource(STWaterLevelSource): url = URL + def __init__(self, transformer=None): + super().__init__(transformer=transformer) + def _extract_parameter_record(self, record): record[PARAMETER_NAME] = DTW record[PARAMETER_VALUE] = record["observation"].result @@ -132,15 +128,14 @@ def _extract_source_parameter_names(self, records: list) -> list: return [r["datastream"].name for r in records] def _clean_records(self, records: list) -> list: - rs = [r for r in records if r["observation"].result is not None] - return rs + return [r for r in records if r["observation"].result is not None] def get_records(self, site_record, *args, **kw): - service = self.get_service() + service = self.client.get_service() config = self.config records = [] - for t in self._get_things(service, site_record): + for t in self.client._get_things(service, site_record): if t.name == "Water Well": for di in t.datastreams: @@ -152,7 +147,6 @@ def get_records(self, site_record, *args, **kw): if fi: q = q.filter(fi) - # if config.latest_water_level_only and not config.output_summary: q = q.orderby("phenomenonTime", "desc") for obs in q.list(): @@ -164,48 +158,55 @@ def get_records(self, site_record, *args, **kw): "observation": obs, } ) - - # if config.latest_water_level_only and not config.output_summary: - # break return records class NMOSERoswellWaterLevelSource(ST2WaterLevelSource): - transformer_klass = NMOSERoswellWaterLevelTransformer agency = "OSE-Roswell" + def __init__(self): + super().__init__(transformer=NMOSERoswellWaterLevelTransformer()) + def __repr__(self): return "NMOSERoswellWaterLevelSource" class PVACDWaterLevelSource(ST2WaterLevelSource): - transformer_klass = PVACDWaterLevelTransformer agency = "PVACD" + def __init__(self): + super().__init__(transformer=PVACDWaterLevelTransformer()) + def __repr__(self): return "PVACDWaterLevelSource" class EBIDWaterLevelSource(ST2WaterLevelSource): - transformer_klass = EBIDWaterLevelTransformer agency = "EBID" + def __init__(self): + super().__init__(transformer=EBIDWaterLevelTransformer()) + def __repr__(self): return "EBIDWaterLevelSource" class BernCoWaterLevelSource(ST2WaterLevelSource): agency = "BernCo" - transformer_klass = BernCoWaterLevelTransformer + + def __init__(self): + super().__init__(transformer=BernCoWaterLevelTransformer()) def __repr__(self): return "BernCoWaterLevelSource" class CABQWaterLevelSource(ST2WaterLevelSource): - transformer_klass = CABQWaterLevelTransformer agency = "CABQ" + def __init__(self): + super().__init__(transformer=CABQWaterLevelTransformer()) + def __repr__(self): return "CABQWaterLevelSource" diff --git a/backend/connectors/st2/transformer.py b/backend/connectors/st2/transformer.py index eaf62f1..5417306 100644 --- a/backend/connectors/st2/transformer.py +++ b/backend/connectors/st2/transformer.py @@ -17,12 +17,12 @@ import sys from backend.connectors.st_connector import STSiteTransformer -from backend.record import SiteRecord, WaterLevelRecord +from backend.record import SiteRecord +from backend.converter import StandardUnitConverter from backend.transformer import ( BaseTransformer, WaterLevelTransformer, SiteTransformer, - convert_units, ) @@ -73,7 +73,7 @@ def _transform_elevation(self, elevation, record): try: thing = record.things._entities[0] stickup_height_ft = thing._properties["stickup_height"]["value"] - stickup_height_m, conversion_factor, warning_msg = convert_units( + stickup_height_m, conversion_factor, warning_msg = StandardUnitConverter().convert( stickup_height_ft, "ft", "m", "stickup_height", "stickup_height" ) elevation = elevation - stickup_height_m diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index dd9550c..3c6e914 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -16,7 +16,7 @@ from datetime import datetime import frost_sta_client as fsc -from shapely import MultiPolygon, Polygon, unary_union +from shapely import MultiPolygon, unary_union from backend.bounding_polygons import get_state_polygon from backend.constants import EARLIEST, LATEST @@ -30,48 +30,28 @@ def get_service(url): - s = fsc.SensorThingsService(url) - return s + return fsc.SensorThingsService(url) -class STSource: - url: str +class STClient: + def __init__(self, url: str): + self._url = url def get_service(self): - if self.url is None: + if self._url is None: raise ValueError("URL not set") + return get_service(self._url) - return get_service(self.url) - - def _get_things( - self, service, site, expand="Locations,Datastreams", additional_filters=None - ): - + def _get_things(self, service, site, expand="Locations,Datastreams", additional_filters=None): things = service.things().query().expand(expand) fs = [f"Locations/id eq {site.id}"] - if additional_filters is not None: + if additional_filters: for fi in additional_filters: fs.append(fi) if fs: things.filter(" and ".join(fs)) - return things.list() - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record( - records, tag=lambda x: x["observation"].phenomenon_time, bookend=bookend - ) - - return { - "value": self._parse_result(record["observation"].result), - "datetime": record["observation"].phenomenon_time, - "source_parameter_units": record["datastream"].unit_of_measurement.symbol, - "source_parameter_name": record["datastream"].name, - } - - def _parse_result(self, result): - return result - def make_dt_filter(tag, start, end): if start: @@ -84,36 +64,37 @@ def make_dt_filter(tag, start, end): elif end: e = end.strftime("%Y-%m-%dT%H:%M:%S.%fZ") return f"{tag} le {e}" - return "" -class STSiteSource(BaseSiteSource, STSource): +class STSiteSource(BaseSiteSource): + url: str = None + + def __init__(self, transformer=None): + super().__init__(transformer=transformer) + self.client = STClient(self.url) + def health(self): try: - # Health checks may run before config is initialized; probe the service directly. - service = self.get_service() + service = self.client.get_service() resp = list(service.locations().query().top(1).list()) return bool(resp) except Exception: return False - + def get_records(self, *args, **kw): - service = self.get_service() + service = self.client.get_service() config = self.config fs = [] if config: if config.has_bounds(): - poly = config.bounding_wkt(as_wkt=False) - # if poly is a MULTIPOLYGON convert to POLYGON if type(poly) == MultiPolygon: if len(poly.geoms) == 1: poly = unary_union(poly) else: - # HUC4 1508 has 2 polygons, one of them is outside of NM state_boundary = get_state_polygon("NM") for geom in poly: if state_boundary.contains(geom): @@ -144,17 +125,53 @@ def _get_filters(self): return [] -class STWaterLevelSource(STSource, BaseWaterLevelSource): - pass +class STWaterLevelSource(BaseWaterLevelSource): + url: str = None + def __init__(self, transformer=None): + super().__init__(transformer=transformer) + self.client = STClient(self.url) + + def _parse_result(self, result): + return result -class STAnalyteSource(STSource, BaseAnalyteSource): - pass + def _extract_terminal_record(self, records, position): + record = get_terminal_record( + records, tag=lambda x: x["observation"].phenomenon_time, position=position + ) + return { + "value": self._parse_result(record["observation"].result), + "datetime": record["observation"].phenomenon_time, + "source_parameter_units": record["datastream"].unit_of_measurement.symbol, + "source_parameter_name": record["datastream"].name, + } + + +class STAnalyteSource(BaseAnalyteSource): + url: str = None + + def __init__(self, transformer=None): + super().__init__(transformer=transformer) + self.client = STClient(self.url) + + def _parse_result(self, result): + return result + + def _extract_terminal_record(self, records, position): + record = get_terminal_record( + records, tag=lambda x: x["observation"].phenomenon_time, position=position + ) + return { + "value": self._parse_result(record["observation"].result), + "datetime": record["observation"].phenomenon_time, + "source_parameter_units": record["datastream"].unit_of_measurement.symbol, + "source_parameter_name": record["datastream"].name, + } class STSiteTransformer(SiteTransformer): source_id: str - check_contained = False # API returns only records within the bounds + check_contained = False def _transform_elevation(self, elevation, record): return elevation @@ -170,9 +187,6 @@ def _transform(self, record): lat = coordinates[1] lng = coordinates[0] - # if not self.contained(lng, lat): - # print("not contained") - # return ele = None if len(coordinates) == 3: diff --git a/backend/connectors/usgs/source.py b/backend/connectors/usgs/source.py index cd6d079..424a7bb 100644 --- a/backend/connectors/usgs/source.py +++ b/backend/connectors/usgs/source.py @@ -45,8 +45,10 @@ MAX_RETRIES = 7 class NWISSiteSource(BaseSiteSource): - transformer_klass = NWISSiteTransformer chunk_size = 500 + + def __init__(self): + super().__init__(transformer=NWISSiteTransformer()) bounding_polygon = NM_STATE_BOUNDING_POLYGON sites_url: str = "https://api.waterdata.usgs.gov/ogcapi/v0/collections/combined-metadata/items" @@ -63,7 +65,7 @@ def health(self): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} - response = httpx.get( + response = self._http_client.get( url=self.sites_url, params={"limit": 1, "parameter_code": "72019", "site_type_code": "GW", "state_code": "35"}, timeout=30, @@ -107,7 +109,7 @@ def get_records(self): headers = {"X-API-Key": os.environ["USGS_API_KEY"]} else: headers = {} - response = httpx.get( + response = self._http_client.get( url=self.sites_url, params=params, timeout=TIMEOUT, @@ -153,7 +155,8 @@ def get_records(self): class NWISWaterLevelSource(BaseWaterLevelSource): - transformer_klass = NWISWaterLevelTransformer + def __init__(self): + super().__init__(transformer=NWISWaterLevelTransformer()) # USGS complex queries allow up to 250 sites to be queried at once # https://api.waterdata.usgs.gov/docs/ogcapi/complex-queries num_sites = 250 @@ -295,8 +298,8 @@ def _extract_source_parameter_names(self, records: list) -> list: def _extract_source_parameter_units(self, records): return [r["source_parameter_units"] for r in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "datetime_measured", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "datetime_measured", position=position) return { "value": float(record["value"]), # "datetime": (record["date_measured"], record["time_measured"]), diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index fc95212..35a5ee0 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from backend.record import SiteRecord, WaterLevelRecord +from backend.record import SiteRecord from backend.transformer import BaseTransformer, WaterLevelTransformer, SiteTransformer diff --git a/backend/connectors/wqp/source.py b/backend/connectors/wqp/source.py index 4adc95d..63fc74f 100644 --- a/backend/connectors/wqp/source.py +++ b/backend/connectors/wqp/source.py @@ -69,17 +69,18 @@ def get_date_range(config): class WQPSiteSource(BaseSiteSource): - transformer_klass = WQPSiteTransformer chunk_size = 50 - bounding_polygon = NM_STATE_BOUNDING_POLYGON + def __init__(self): + super().__init__(transformer=WQPSiteTransformer()) + def __repr__(self): return "WQPSiteSource" def health(self): try: - r = httpx.get( + r = self._http_client.get( "https://www.waterqualitydata.us/data/Station/search", params={"mimeType": "tsv", "siteid": "325754103461301"}, ) @@ -206,8 +207,8 @@ def _extract_parameter_dates(self, records): def _extract_source_parameter_names(self, records): return [ri["CharacteristicName"] for ri in records] - def _extract_terminal_record(self, records, bookend): - record = get_terminal_record(records, "ActivityStartDate", bookend=bookend) + def _extract_terminal_record(self, records, position): + record = get_terminal_record(records, "ActivityStartDate", position=position) return { "value": record["ResultMeasureValue"], "datetime": record["ActivityStartDate"], @@ -249,7 +250,8 @@ def _parameter_units_hook(self): class WQPAnalyteSource(WQPParameterSource, BaseAnalyteSource): - transformer_klass = WQPAnalyteTransformer + def __init__(self): + super().__init__(transformer=WQPAnalyteTransformer()) def __repr__(self): return "WQPAnalyteSource" @@ -260,7 +262,8 @@ def _parameter_units_hook(self): # inherit from WQPParameterSource first so that its _extract_souce_parameter_units method is used instead of BaseWaterLevelSource's method class WQPWaterLevelSource(WQPParameterSource, BaseWaterLevelSource): - transformer_klass = WQPWaterLevelTransformer + def __init__(self): + super().__init__(transformer=WQPWaterLevelTransformer()) def __repr__(self): return "WQPWaterLevelSource" diff --git a/backend/connectors/wqp/transformer.py b/backend/connectors/wqp/transformer.py index 4764dc0..a2cf623 100644 --- a/backend/connectors/wqp/transformer.py +++ b/backend/connectors/wqp/transformer.py @@ -15,7 +15,7 @@ # =============================================================================== import pprint -from backend.record import SiteRecord, AnalyteSummaryRecord +from backend.record import SiteRecord from backend.transformer import ( BaseTransformer, SiteTransformer, diff --git a/backend/converter.py b/backend/converter.py new file mode 100644 index 0000000..42337b7 --- /dev/null +++ b/backend/converter.py @@ -0,0 +1,118 @@ +# =============================================================================== +# Copyright 2024 Jake Ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from typing import Protocol, runtime_checkable + +from backend.constants import ( + MILLIGRAMS_PER_LITER, + PARTS_PER_MILLION, + PARTS_PER_BILLION, + FEET, + METERS, + TONS_PER_ACRE_FOOT, + MICROGRAMS_PER_LITER, +) + + +@runtime_checkable +class UnitConverter(Protocol): + def convert( + self, + input_value: float, + input_units: str, + output_units: str, + source_parameter_name: str, + die_parameter_name: str, + dt: str | None = None, + ) -> tuple[float, float | None, str]: ... + + +class StandardUnitConverter: + def convert( + self, + input_value: float, + input_units: str, + output_units: str, + source_parameter_name: str, + die_parameter_name: str, + dt: str | None = None, + ) -> tuple[float, float | None, str]: + warning = "" + conversion_factor = None + + input_value = float(input_value) + input_units = input_units.strip().lower() + output_units = output_units.strip().lower() + source_parameter_name = source_parameter_name.strip().lower() + die_parameter_name = die_parameter_name.strip().lower() + + mgl = MILLIGRAMS_PER_LITER.lower() + ugl = MICROGRAMS_PER_LITER.lower() + ppm = PARTS_PER_MILLION.lower() + ppb = PARTS_PER_BILLION.lower() + tpaf = TONS_PER_ACRE_FOOT.lower() + ft = FEET.lower() + m = METERS.lower() + + if die_parameter_name == "ph": + conversion_factor = 1.0 + elif die_parameter_name in ["conductivity", "specific_conductance"]: + if input_units in ["μmhos/cm", "umho/cm", "cm-1", "micromhos per centimeter", "mg/l", "su", "us/cm", "us/cm @25c", "µs/cm", "μs/cm"]: + conversion_factor = 1.0 + elif output_units == mgl: + if input_units in ["mg/l caco3", "mg/l caco3**"]: + if die_parameter_name == "bicarbonate": + conversion_factor = 1.22 + elif die_parameter_name == "calcium": + conversion_factor = 0.4 + elif die_parameter_name == "carbonate": + conversion_factor = 0.6 + elif input_units == "mg/l as n": + conversion_factor = 4.427 + elif input_units in ["mg/l asno3", "mg/l as no3"]: + conversion_factor = 1.0 + elif input_units == "ug/l as n": + conversion_factor = 0.004427 + elif input_units == "pci/l": + conversion_factor = 0.00149 + elif input_units in (ugl, ppb): + conversion_factor = 0.001 + elif input_units == tpaf: + conversion_factor = 735.47 + elif input_units == ppm: + conversion_factor = 1.0 + elif input_units == output_units: + if source_parameter_name in ["nitrate as n", "nitrate (as n)"]: + conversion_factor = 4.427 + else: + conversion_factor = 1.0 + elif output_units == ft: + if input_units in [m, "meters"]: + conversion_factor = 3.28084 + elif input_units in [ft, "feet"]: + conversion_factor = 1.0 + elif output_units == m: + if input_units in [ft, "feet"]: + conversion_factor = 0.3048 + elif input_units in [m, "meters"]: + conversion_factor = 1.0 + + if conversion_factor: + return input_value * conversion_factor, conversion_factor, warning + warning = f"Failed to convert {input_value} {input_units} {source_parameter_name} (source) to {output_units} {die_parameter_name} (die) on {dt}" + return input_value, conversion_factor, warning + + +# ============= EOF ============================================= diff --git a/backend/persister.py b/backend/persister.py index d50f2ec..a4453b4 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -15,54 +15,36 @@ # =============================================================================== import csv import io -import os -from pprint import pprint import json +import os from backend import OutputFormat from backend.logger import make_logger +from backend.persisters.strategies import LocalFileStrategy -import logging as _logging - -try: - from google.cloud import storage -except ImportError: - _logging.getLogger(__name__).debug("google cloud storage not available") - +def _timeseries_to_bytes(timeseries: list) -> bytes: + buf = io.StringIO() + writer = csv.writer(buf) + headers_written = False + for i, records in enumerate(timeseries): + for record in records: + if not headers_written: + writer.writerow(record.keys) + headers_written = True + writer.writerow(record.to_row()) + return buf.getvalue().encode("utf-8") -def write_memory(func, records, output_format=None): - f = io.BytesIO() - func(f, records, output_format) - return f.getvalue() - -def dump_timeseries(path, timeseries: list[list]): - """ - Dumps timeseries records to a CSV file. The timeseries must be a list of - lists, where each inner list contains the records for a single site. In the case - of timeseries separated, the inner list will contain the records for a single site - and this function will be called multiple times, once for each site. - """ - with open(path, "w", newline="", encoding="utf-8") as f: - writer = csv.writer(f) - headers_have_not_been_written = True - for i, records in enumerate(timeseries): - for record in records: - if i == 0 and headers_have_not_been_written: - writer.writerow(record.keys) - headers_have_not_been_written = False - writer.writerow(record.to_row()) - - -def dump_sites_summary(path, records, output_format: OutputFormat): +def _records_to_bytes(records: list, output_format: OutputFormat) -> bytes: if output_format == OutputFormat.CSV: - with open(path, "w", newline="", encoding="utf-8") as f: - writer = csv.writer(f) - for i, site in enumerate(records): - if i == 0: - writer.writerow(site.keys) - writer.writerow(site.to_row()) + buf = io.StringIO() + writer = csv.writer(buf) + for i, site in enumerate(records): + if i == 0: + writer.writerow(site.keys) + writer.writerow(site.to_row()) + return buf.getvalue().encode("utf-8") else: features = [ { @@ -70,42 +52,41 @@ def dump_sites_summary(path, records, output_format: OutputFormat): "geometry": { "type": "Point", "coordinates": [ - getattr(record, "longitude"), - getattr(record, "latitude"), - getattr(record, "elevation"), + getattr(r, "longitude"), + getattr(r, "latitude"), + getattr(r, "elevation"), ], }, "properties": { - k: getattr(record, k) - for k in record.keys + k: getattr(r, k) + for k in r.keys if k not in ["latitude", "longitude", "elevation"] }, } - for record in records + for r in records ] - feature_collection = {"type": "FeatureCollection", "features": features} - - with open(path, "w", encoding="utf-8") as f: - json.dump(feature_collection, f, indent=4) + fc = {"type": "FeatureCollection", "features": features} + return json.dumps(fc, indent=4).encode("utf-8") class BasePersister: - def __init__(self, config=None): + def __init__(self, config=None, strategy=None): self.records = [] self.timeseries = [] self.sites = [] self.config = config + self._strategy = strategy if strategy is not None else LocalFileStrategy() _l = make_logger(self.__class__.__name__) self.log = _l.log self.warn = _l.warn self.debug = _l.debug - # self.keys = record_klass.keys def load(self, records: list): self.records.extend(records) def finalize(self, output_name: str): - pass + if hasattr(self._strategy, "finalize"): + self._strategy.finalize() def dump_sites(self, path: str): try: @@ -180,91 +161,13 @@ def add_extension(self, path: str, extension: OutputFormat): def _dump_sites_summary( self, path: str, records: list, output_format: OutputFormat ): - dump_sites_summary(path, records, output_format) + self._strategy.write_bytes(path, _records_to_bytes(records, output_format)) def _dump_timeseries(self, path: str, timeseries: list): - dump_timeseries(path, timeseries) - - def _make_output_directory(self, output_directory: str): - os.mkdir(output_directory) - - -class CloudStoragePersister(BasePersister): - extension = "csv" - _content: list - - def __init__(self, *args, **kwargs): - super(CloudStoragePersister, self).__init__(*args, **kwargs) - self._content = [] - - def finalize(self, output_name: str): - """ - zip content and upload to google cloud storage - :return: - """ - if not self._content: - self.log("no content to save", fg="red") - return - - storage_client = storage.Client() - bucket = storage_client.bucket("die_cache") - if len(self._content) > 1: - import zipfile - - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: - for path, cnt in self._content: - zf.writestr(path, cnt) - blob = bucket.blob(f"{output_name}.zip") - blob.upload_from_string(zip_buffer.getvalue()) - else: - path, cnt = self._content[0] - - # this is a hack. need a better way to specify the output path - dirname = os.path.basename(os.path.dirname(path)) - path = os.path.join(dirname, os.path.basename(path)) - - blob = bucket.blob(path) - blob.upload_from_string( - cnt, - content_type=( - "application/json" - if self.config.output_format == OutputFormat.GEOJSON - else "text/csv" - ), - ) + self._strategy.write_bytes(path, _timeseries_to_bytes(timeseries)) def _make_output_directory(self, output_directory: str): - # prevent making root directory, because we are not saving to disk - pass - - def _add_content(self, path: str, content: str): - self._content.append((path, content)) - - def _dump_sites_summary( - self, path: str, records: list, output_format: OutputFormat - ): - content = write_memory(dump_sites_summary, records, output_format) - self._add_content(path, content) - - def _dump_timeseries_unified(self, path: str, timeseries: list): - content = write_memory(path, dump_timeseries, timeseries) - self._add_content(path, content) - - -# class ST2Persister(BasePersister): -# extension = "st2" -# -# def save(self, path): -# import frost_sta_client as fsc -# -# service = fsc.SensorThingsService( -# "https://st.newmexicowaterdata.org/FROST-Server/v1.0", -# auth_handler=AuthHandler(os.getenv("ST2_USER"), os.getenv("ST2_PASSWORD")), -# ) -# for record in self.records: -# for t in service.things().query().filter(name=record["id"]).list(): -# print(t) + self._strategy.make_directory(output_directory) # ============= EOF ============================================= diff --git a/backend/persisters/factory.py b/backend/persisters/factory.py index e45a652..3d06510 100644 --- a/backend/persisters/factory.py +++ b/backend/persisters/factory.py @@ -1,5 +1,6 @@ from backend import OutputFormat -from backend.persister import BasePersister, CloudStoragePersister +from backend.persister import BasePersister +from backend.persisters.strategies import GCSStrategy def make_persister(config) -> BasePersister: @@ -17,6 +18,11 @@ def make_persister(config) -> BasePersister: return GeoServerPersister(config) if config.use_cloud_storage: - return CloudStoragePersister(config) + strategy = GCSStrategy( + bucket_name="die_cache", + output_name=config.output_name, + output_format=config.output_format.value, + ) + return BasePersister(config, strategy=strategy) return BasePersister(config) diff --git a/backend/persisters/strategies.py b/backend/persisters/strategies.py new file mode 100644 index 0000000..c0dff2b --- /dev/null +++ b/backend/persisters/strategies.py @@ -0,0 +1,63 @@ +import io +import os +from typing import Protocol + + +class OutputStrategy(Protocol): + def write_bytes(self, path: str, content: bytes) -> None: ... + def make_directory(self, path: str) -> None: ... + + +class LocalFileStrategy: + def write_bytes(self, path: str, content: bytes) -> None: + with open(path, "wb") as f: + f.write(content) + + def make_directory(self, path: str) -> None: + os.mkdir(path) + + +class GCSStrategy: + def __init__(self, bucket_name: str, output_name: str, output_format: str): + self._bucket_name = bucket_name + self._output_name = output_name + self._output_format = output_format + self._content: list[tuple[str, bytes]] = [] + + def write_bytes(self, path: str, content: bytes) -> None: + self._content.append((path, content)) + + def make_directory(self, path: str) -> None: + pass + + def finalize(self) -> None: + from google.cloud import storage + + if not self._content: + return + + client = storage.Client() + bucket = client.bucket(self._bucket_name) + + if len(self._content) > 1: + import zipfile + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for path, cnt in self._content: + zf.writestr(path, cnt) + blob = bucket.blob(f"{self._output_name}.zip") + blob.upload_from_string(buf.getvalue()) + else: + path, cnt = self._content[0] + dirname = os.path.basename(os.path.dirname(path)) + blob_path = os.path.join(dirname, os.path.basename(path)) + blob = bucket.blob(blob_path) + blob.upload_from_string( + cnt, + content_type=( + "application/json" + if self._output_format == "geojson" + else "text/csv" + ), + ) diff --git a/backend/record.py b/backend/record.py index 7b46002..fc50585 100644 --- a/backend/record.py +++ b/backend/record.py @@ -63,7 +63,7 @@ def _get_sigfig_formatted_value(self, attr): # both analyte and water level tables have the same fields, but the # rounding should only occur for water level tables - if isinstance(self, WaterLevelRecord): + if self._payload.get("record_type") == "waterlevels": field_sigfigs.append((PARAMETER_VALUE, 2)) for field, sigfigs in field_sigfigs: @@ -104,14 +104,6 @@ class ParameterRecord(BaseRecord): defaults: dict = {} -class WaterLevelRecord(ParameterRecord): - pass - - -class AnalyteRecord(ParameterRecord): - pass - - class SummaryRecord(BaseRecord): keys: tuple = ( "source", @@ -144,14 +136,6 @@ class SummaryRecord(BaseRecord): defaults: dict = {} -class WaterLevelSummaryRecord(SummaryRecord): - pass - - -class AnalyteSummaryRecord(SummaryRecord): - pass - - class SiteRecord(BaseRecord): keys: tuple = ( "source", diff --git a/backend/source.py b/backend/source.py index 9d3242a..40085f1 100644 --- a/backend/source.py +++ b/backend/source.py @@ -14,11 +14,11 @@ # limitations under the License. # =============================================================================== from json import JSONDecodeError +from typing import Literal, Union, List, Callable, Dict import httpx import shapely.wkt from shapely import MultiPoint -from typing import Union, List, Callable, Dict import time from backend.constants import ( @@ -32,98 +32,149 @@ ) from backend.logger import make_logger from backend.record import ( - AnalyteRecord, - AnalyteSummaryRecord, - WaterLevelRecord, - WaterLevelSummaryRecord, + ParameterRecord, SiteRecord, + SummaryRecord, ) -from backend.transformer import BaseTransformer, convert_units +from backend.transformer import BaseTransformer from backend.exceptions import PartialOrNoDataError -def make_site_list(site_record: list[SiteRecord] | SiteRecord) -> list | str: - """ - Returns a list of site ids, as defined by site_record - - Parameters - ---------- - site_record: SiteRecord or list of SiteRecords - - Returns - ------- - list - a list of site ids - """ - if isinstance(site_record, list): - sites = [r.id for r in site_record] - else: - sites = site_record.id - return sites +# ============================================================================= +# Record validation strategies +# ============================================================================= +class RecordValidator: + config = None -def get_terminal_record(records: list, tag: Union[str, Callable], bookend: str) -> dict: - """ - Returns the most recent record based on the tag + def set_config(self, config) -> None: + self.config = config - Parameters - ---------- - records: list - a list of records + def validate(self, record: dict) -> None: + raise NotImplementedError(f"{self.__class__.__name__} must implement validate") - tag: str or callable - the tag to use to sort the records - bookend: str - determines if the earliest or lastest record is retrieved +class AnalyteRecordValidator(RecordValidator): + def validate(self, record: dict) -> None: + record[PARAMETER_NAME] = self.config.parameter + for k in (PARAMETER_VALUE, PARAMETER_UNITS, DT_MEASURED): + if k not in record: + raise ValueError(f"Invalid record. Missing {k}") - Returns - ------- - dict - the most recent record for every site - """ - if callable(tag): - func = tag - else: - if "." in tag: - def func(x): - for t in tag.split("."): - x = x[t] - return x +class WaterLevelRecordValidator(RecordValidator): + def validate(self, record: dict) -> None: + for k in (PARAMETER_VALUE, PARAMETER_UNITS, DT_MEASURED): + if k not in record: + raise ValueError(f"Invalid record. Missing {k}") + - else: +class _SubclassValidatorShim(RecordValidator): + """Shim: delegates to source._validate_record() for subclasses that override it.""" + def __init__(self, source): + self._source = source + + def set_config(self, config) -> None: + pass # source._validate_record uses self.config directly + + def validate(self, record: dict) -> None: + self._source._validate_record(record) + + +# ============================================================================= +# Record summarization strategy +# ============================================================================= + +class RecordSummarizer: + def __init__(self, source): + self._source = source + + def summarize(self, site, cleaned: list): + s = self._source + source_results = s._extract_source_parameter_results(cleaned) + source_units = s._extract_source_parameter_units(cleaned) + dates = s._extract_parameter_dates(cleaned) + source_names = s._extract_source_parameter_names(cleaned) + + kept_items = [] + skipped_items = [] + for source_result, source_unit, date, source_name in zip( + source_results, source_units, dates, source_names + ): + try: + converted_result, _factor, warning_msg = s.transformer.converter.convert( + float(source_result), + source_unit, + s._get_output_units(), + source_name, + s.config.parameter, + date, + ) + if warning_msg == "": + kept_items.append(converted_result) + else: + s.warn(f"{warning_msg} for {site.id}") + except (TypeError, ValueError): + skipped_items.append((site.id, source_result, source_unit)) + + if skipped_items: + s.warn(f"Skipped results because of formatting: {skipped_items}") + if not kept_items: + return None + + n = len(kept_items) + earliest_result = s._extract_earliest_record(cleaned) + latest_result = s._extract_latest_record(cleaned) + if not latest_result: + return None + + rec = { + "nrecords": n, + "min": min(kept_items), + "max": max(kept_items), + "mean": sum(kept_items) / n, + "earliest_datetime": earliest_result["datetime"], + "earliest_value": earliest_result["value"], + "earliest_source_units": earliest_result["source_parameter_units"], + "earliest_source_name": earliest_result["source_parameter_name"], + "latest_datetime": latest_result["datetime"], + "latest_value": latest_result["value"], + "latest_source_units": latest_result["source_parameter_units"], + "latest_source_name": latest_result["source_parameter_name"], + } + return s.transformer.do_transform(rec, site) + + +# ============================================================================= +# Module-level helpers +# ============================================================================= + +def make_site_list(site_record: list[SiteRecord] | SiteRecord) -> list | str: + if isinstance(site_record, list): + return [r.id for r in site_record] + return site_record.id - def func(x): - return x[tag] - if bookend == EARLIEST: +def get_terminal_record(records: list, tag: Union[str, Callable], position: str) -> dict: + if callable(tag): + func = tag + elif "." in tag: + def func(x): + for t in tag.split("."): + x = x[t] + return x + else: + def func(x): + return x[tag] + + if position == EARLIEST: return sorted(records, key=func)[0] - elif bookend == LATEST: + elif position == LATEST: return sorted(records, key=func)[-1] - else: - raise ValueError( - f"Invalid bookend {bookend}. Must be either {EARLIEST} or {LATEST}" - ) + raise ValueError(f"Invalid position {position}. Must be either {EARLIEST} or {LATEST}") def get_analyte_search_param(parameter: str, mapping: dict) -> str: - """ - Get the search parameter for a provided analyte, as defined by the mapping for a source - - Parameters - ---------- - parameter : str - the analyte name used in the query - - mapping : dict - a mapping of analytes to search parameters for the source - - Returns - ------- - str - the search parameter for the provided analyte for a particular source - """ try: return mapping[parameter] except KeyError: @@ -132,11 +183,16 @@ def get_analyte_search_param(parameter: str, mapping: dict) -> str: ) +# ============================================================================= +# Base source classes +# ============================================================================= + class BaseSource: - transformer_klass = BaseTransformer + transformer_klass = BaseTransformer # deprecated: pass transformer= to __init__ - def __init__(self): - self.transformer = self.transformer_klass() + def __init__(self, transformer: BaseTransformer = None, http_client: httpx.Client | None = None): + self.transformer = transformer if transformer is not None else self.transformer_klass() + self._http_client = http_client if http_client is not None else httpx.Client(timeout=900) _l = make_logger(self.__class__.__name__) self.log = _l.log self.warn = _l.warn @@ -149,33 +205,23 @@ def tag(self): def set_config(self, config): self.config = config self.transformer.set_config(config) + if hasattr(self, "_validator"): + self._validator.set_config(config) def check(self, *args, **kw): return True - # raise NotImplementedError(f"check not implemented by {self.__class__.__name__}") def discover(self, *args, **kw): return [] - # raise NotImplementedError(f"discover not implemented by {self.__class__.__name__}") - - # ========================================================================== - # Methods Already Implemented - # ========================================================================== def _execute_text_request(self, url: str, params: dict | None = None, max_tries: int = 7, **kw) -> str: - if "timeout" not in kw: - kw["timeout"] = 900 - - tries: int = 0 - last_err: str = "" + tries, last_err = 0, "" while tries < max_tries: t0 = time.monotonic() try: - resp = httpx.get(url, params=params, **kw) + resp = self._http_client.get(url, params=params, **kw) elapsed = int((time.monotonic() - t0) * 1000) - self.log( - f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_tries} elapsed_ms={elapsed} url={url}" - ) + self.log(f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_tries} elapsed_ms={elapsed} url={url}") if resp.status_code == 200: return resp.text last_err = f"status {resp.status_code}: {resp.text[:200]}" @@ -186,28 +232,17 @@ def _execute_text_request(self, url: str, params: dict | None = None, max_tries: self.warn(f"Request error attempt={tries+1}/{max_tries} elapsed_ms={elapsed} url={url}: {e}") tries += 1 time.sleep(min(2 ** tries, 60)) - self.warn(f"Failed to retrieve records after {max_tries} attempts. Last error: {last_err}") raise PartialOrNoDataError(f"Failed to retrieve records after {max_tries} attempts. Last error: {last_err}") - def _execute_json_request( - self, - url: str, - params: dict | None = None, - tag: str | None = None, - max_retries: int = 7, - **kw - ) -> dict: - tries: int = 0 - last_err: str = "" + def _execute_json_request(self, url: str, params: dict | None = None, tag: str | None = None, max_retries: int = 7, **kw) -> dict: + tries, last_err = 0, "" while tries < max_retries: t0 = time.monotonic() try: - resp = httpx.get(url, params=params, **kw) + resp = self._http_client.get(url, params=params, **kw) elapsed = int((time.monotonic() - t0) * 1000) - self.log( - f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_retries} elapsed_ms={elapsed} url={url}" - ) + self.log(f"HTTP GET source={self.tag} status={resp.status_code} attempt={tries+1}/{max_retries} elapsed_ms={elapsed} url={url}") if resp.status_code == 200: try: obj = resp.json() @@ -226,63 +261,16 @@ def _execute_json_request( self.warn(f"Request error attempt={tries+1}/{max_retries} elapsed_ms={elapsed} url={url}: {e}") tries += 1 time.sleep(min(2 ** tries, 60)) - self.warn(f"Failed to retrieve records after {max_retries} attempts. Last error: {last_err}") raise PartialOrNoDataError(f"Failed to retrieve records after {max_retries} attempts. Last error: {last_err}") - # ========================================================================== - # Methods Implemented in BaseSiteSource and BaseParameterSource - # ========================================================================== - def read(self, *args, **kw) -> list | None: - """ - Returns the records. Implemented in BaseSiteSource and BaseAnalyteSource - """ raise NotImplementedError(f"read not implemented by {self.__class__.__name__}") - # ========================================================================== - # Methods That Need to be Implemented For Each Source - # ========================================================================== - def get_records(self, *args, **kw) -> List[Dict]: - """ - Returns records as a dictionary, where the keys are site ids and - the values are site or parameter records. - - If site records, the values are dictionaries with the site records. - - If parameter records, the values are lists of dictionaries with parameter records. - - Called by the read method. Needs to be implemented by all subclasses. - - Parameters - ---------- - If parameter records: - site_record : dict - the site record for the location whose parameter records are to be retrieved - - If site records: - There are no parameters - - Returns - ------- - dict - a dictionary of site or parameter records, where the keys are site ids - and the values are site or parameter records - """ - raise NotImplementedError( - f"get_records not implemented by {self.__class__.__name__}" - ) + raise NotImplementedError(f"get_records not implemented by {self.__class__.__name__}") def health(self) -> bool: - """ - Checks the health of the source. Implemented for each site source - - Returns - -------- - bool - True if the source is healthy, else False - """ raise NotImplementedError(f"test not implemented by {self.__class__.__name__}") @@ -290,69 +278,17 @@ class BaseContainerSource(BaseSource): def __init__(self, *args, **kw): super().__init__(*args, **kw) - # locate image - # make container - # container writes messages to stdout - # this class captures the messages from stdout - def check(self): - # run the container with the check command pass def discover(self, *args, **kw): - # run the container with the discover command pass def read(self, *args, **kw): - # run the container with the read command pass class BaseSiteSource(BaseSource): - """ - The BaseSiteSource class is a base class for all site sources. - It provides a common interface for all site sources - - Attributes - ---------- - chunk_size : int - the number of records to process at once - - bounding_polygon : str - a WKT string defining the bounding polygon for the site sources - - - Methods With Universal Implementations (Already Implemented) - ------- - generate_bounding_polygon - Generates a bounding polygon based on the site records - - intersects(wkt) - Returns True if the bounding polygon intersects with the provided WKT string - - read(*args, **kw) - Reads the site records and returns the transformed records, where the - transform standardizes the records so the format is the same for all sources - - _transform_sites(records) - Transforms the site records into the standardized format and returns - the transformed records - - chunks(records, chunk_size=None) - Returns a list of records split into lists of size chunk_size. If - chunk_size less than 1 then the records are not split - - - Methods That Need to be Implemented For Each Source - ------- - get_records - Returns a dictionary of site records, where the keys are the site ids - and the values are the site records - - health - Checks the health of the source - """ - chunk_size = 1 bounding_polygon = None @@ -367,656 +303,197 @@ def generate_bounding_polygon(self): self.log(mpt.convex_hull.buffer(1 / 60.0).wkt) def intersects(self, wkt: str) -> bool: - """ - Determines if the bounding polygon intersects with the provided WKT string - - Parameters - ---------- - wkt : str - a WKT string - - Returns - ------- - bool - True if the bounding polygon intersects with the provided WKT string - True if there is no bounding polygon - """ if self.bounding_polygon: wkt = shapely.wkt.loads(wkt) return self.bounding_polygon.intersects(wkt) - return True def read(self, *args, **kw) -> List[SiteRecord] | None: - """ - Returns a list of transformed site records. - Calls self.get_records, which needs to be implemented for each source - - Returns - ------- - list[SiteRecord] - a list of transformed site records - """ self.log("Gathering site records") records = self.get_records() if records: self.log(f"total records={len(records)}") return self._transform_sites(records) - else: - self.warn("No site records returned") - return None + self.warn("No site records returned") + return None def _transform_sites(self, records: list) -> List[SiteRecord]: - """ - Transforms site records into the standardized format. - - Parameters - ---------- - records : list - a list of site records - - Returns - ------- - list[SiteRecord] - a list of transformed site records as SiteRecords - """ transformed_records = [] for record in records: record = self.transformer.do_transform(record) if record: record.chunk_size = self.chunk_size transformed_records.append(record) - self.log(f"processed nrecords={len(transformed_records)}") return transformed_records def chunks(self, records: list, chunk_size: int | None = None) -> list: - """ - Returns a list of records split into lists of size chunk_size. If - chunk_size less than 1 then the records are not split - - Parameters - ---------- - records : list - a list of records - - chunk_size : int - the size of the chunks - - Returns - ------- - list - a list of records split into lists of size chunk_size. If chunk_size - less than 1 then the records are not split - """ if chunk_size is None: chunk_size = self.chunk_size - if chunk_size > 1: - return [ - records[i : i + chunk_size] for i in range(0, len(records), chunk_size) - ] - else: - return records + return [records[i:i + chunk_size] for i in range(0, len(records), chunk_size)] + return records class BaseParameterSource(BaseSource): - """ - The BaseParameterSource class is a base class for all parameter sources, - whether it be an analyte source or a water level source. - - ============================================================================ - Methods With Universal Implementations (Already Implemented) - ============================================================================ - - _extract_earliest_record - Returns the earliest record for a particular site. Requires _extract_terminal_record - to be implemented for each source - - _extract_latest_record - Returns the most recent record for a particular site. Requires _extract_terminal_record - to be implemented for each source - - read - Reads the parameter records and returns the transformed records, where the - transform standardizes the records so the format is the same for all sources - - - ============================================================================ - Methods Implemented in BaseAnalyteSource and BaseWaterLevelSource - ============================================================================ - - _validate_record - Validates the record to ensure it has the required fields - - _get_output_units - Returns the output units for the source - - ============================================================================ - Methods That Need to be Implemented For Each Source - ============================================================================ - - get_records - Returns a dictionary of parameter records where the keys are the site ids - and the values are a list of the parameter records - - _extract_site_records - Returns all records for a single site as a list of records - - _extract_terminal_record - Returns the terminal record for a particular site. This is only used for - summary, not time series, outputs. - - _clean_records (optional) - Returns cleaned records if this function is defined for each source. - Otherwise returns the records as-is - - _extract_source_parameter_units - Returns the units of the parameter records as a list, in the same order as the records themselves - - _extract_parameter_record - Returns a parameter record with standardized fields added. - - - backend.constants.PARAMETER - - backend.constants.PARAMETER_VALUE - - backend.constants.PARAMETER_UNITS - - _extract_source_parameter_results - Returns the parameter results as a list from the records, in the same order as the records themselves - """ - name = "" - # ========================================================================== - # Methods Already Implemented - # ========================================================================== + def __init__(self, transformer=None, validator: RecordValidator = None, http_client: httpx.Client | None = None): + super().__init__(transformer=transformer, http_client=http_client) + self._validator = validator if validator is not None else _SubclassValidatorShim(self) + self._summarizer = RecordSummarizer(self) def _extract_earliest_record(self, records: list) -> dict: - """ - Returns the earliest record for a particular site + return self._extract_terminal_record(records, position=EARLIEST) - Parameters - ---------- - records : list - a list of records + def _extract_latest_record(self, records: list) -> dict: + return self._extract_terminal_record(records, position=LATEST) - Returns - ------- - dict - the earliest record - """ - return self._extract_terminal_record(records, bookend=EARLIEST) + def read(self, site_record: SiteRecord | list, use_summarize: bool, start_ind: int, end_ind: int) -> List[ParameterRecord | SummaryRecord] | None: + if use_summarize: + return self.read_summary(site_record, start_ind, end_ind) + return self.read_timeseries(site_record) - def _extract_latest_record(self, records: list) -> dict: - """ - Returns the most recent record for a particular site - - Parameters - ---------- - records : list - a list of records - - Returns - ------- - dict - the most recent record - """ - return self._extract_terminal_record(records, bookend=LATEST) - - def read( - self, - site_record: SiteRecord | list, - use_summarize: bool, - start_ind: int, - end_ind: int, - ) -> ( - List[ - AnalyteRecord - | AnalyteSummaryRecord - | WaterLevelRecord - | WaterLevelSummaryRecord - ] - | None - ): - """ - Returns a list of transformed parameter records. Transformed parameter records - are standardized so that all of the records have the same format. They are - defined in the record module. They behave just like a dictionary, but also have the - to_row() method so that a record can be written to a table. - - If use_summarize is True, the summary of records for each site are returned. - Otherwise, the cleaned and sorted records are returned for a site. - - Parameters - ---------- - site_record : SiteRecord - the site record(s) for the location whose parameter records are to be retrieved - - use_summarize : bool - if True, the summary of records for each site are returned - - Returns - -------- - list[AnalyteRecord | AnalyteSummaryRecord | WaterLevelRecord | WaterLevelSummaryRecord] - a list of transformed parameter records - """ + def read_summary(self, site_record: SiteRecord | list, start_ind: int, end_ind: int) -> List[SummaryRecord] | None: if isinstance(site_record, list): - self.log( - f"Gathering {self.name} summary for {len(site_record)} sites. {start_ind}-{end_ind}" - ) + self.log(f"Gathering {self.name} summary for {len(site_record)} sites. {start_ind}-{end_ind}") else: self.log(f"{site_record.id}: Gathering {self.name} data") - all_analyte_records = self.get_records(site_record) - if all_analyte_records: - if not isinstance(site_record, list): - site_record = [site_record] - - # return values - ret = [] - - # iterate over each site record and extract the parameter records for each site - for site in site_record: - site_records = self._extract_site_records(all_analyte_records, site) - if not site_records: - self.warn(f"{site.id}: No records found") - continue - # get cleaned records if _clean_records is defined by the source. This usually removes Nones/Null - cleaned = self._clean_records(site_records) - if not cleaned: - self.warn(f"{site.id} No clean records found") - continue - - if use_summarize: - - # doesn't need to be returned, but can be used to debug/for development - kept_items = [] - skipped_items = [] - - source_results = self._extract_source_parameter_results(cleaned) - source_units = self._extract_source_parameter_units(cleaned) - dates = self._extract_parameter_dates(cleaned) - source_names = self._extract_source_parameter_names(cleaned) - - for source_result, source_unit, date, source_name in zip( - source_results, source_units, dates, source_names - ): - try: - converted_result, conversion_factor, warning_msg = ( - convert_units( - float(source_result), - source_unit, - self._get_output_units(), - source_name, - self.config.parameter, - date, - ) - ) - if warning_msg == "": - kept_items.append(converted_result) - else: - msg = f"{warning_msg} for {site.id}" - self.warn(msg) - except TypeError: - skipped_items.append((site.id, source_result, source_unit)) - except ValueError: - skipped_items.append((site.id, source_result, source_unit)) - - if len(skipped_items) > 0: - self.warn( - f"Skipped results because of formatting: {skipped_items}" - ) - - # if items is None or empty, no records were found or all results were None - if kept_items is not None and len(kept_items): - n = len(kept_items) - - earliest_result = self._extract_earliest_record(cleaned) - latest_result = self._extract_latest_record(cleaned) - if not latest_result: - continue - rec = { - "nrecords": n, - "min": min(kept_items), - "max": max(kept_items), - "mean": sum(kept_items) / n, - "earliest_datetime": earliest_result["datetime"], - "earliest_value": earliest_result["value"], - "earliest_source_units": earliest_result[ - "source_parameter_units" - ], - "earliest_source_name": earliest_result[ - "source_parameter_name" - ], - "latest_datetime": latest_result["datetime"], - "latest_value": latest_result["value"], - "latest_source_units": latest_result[ - "source_parameter_units" - ], - "latest_source_name": latest_result[ - "source_parameter_name" - ], - } - transformed_record = self.transformer.do_transform( - rec, - site, - ) - if transformed_record is None: - continue - else: - ret.append(transformed_record) - else: - cleaned_sorted = [ - self.transformer.do_transform( - self._extract_parameter(record), site - ) - for record in cleaned - if self.transformer.do_transform( - self._extract_parameter(record), site - ) - is not None - ] - if len(cleaned_sorted) == 0: - self.warn(f"{site.id}: No clean records found") - continue - cleaned_sorted = sorted(cleaned_sorted, key=self._sort_func) - ret.append((site, cleaned_sorted)) - return ret + all_records = self.get_records(site_record) + if not all_records: + names = [str(r.id) for r in site_record] if isinstance(site_record, list) else [str(site_record.id)] + self.warn(f"{','.join(names)}: No records found") + return None + + if not isinstance(site_record, list): + site_record = [site_record] + + ret = [] + for site in site_record: + site_records = self._extract_site_records(all_records, site) + if not site_records: + self.warn(f"{site.id}: No records found") + continue + cleaned = self._clean_records(site_records) + if not cleaned: + self.warn(f"{site.id} No clean records found") + continue + result = self._summarize_records(site, cleaned) + if result is not None: + ret.append(result) + return ret + + def read_timeseries(self, site_record: SiteRecord | list) -> List[ParameterRecord] | None: + if isinstance(site_record, list): + self.log(f"Gathering {self.name} timeseries for {len(site_record)} sites") else: - if isinstance(site_record, list): - names = [str(r.id) for r in site_record] - else: - names = [str(site_record.id)] + self.log(f"{site_record.id}: Gathering {self.name} data") - name = ",".join(names) - self.warn(f"{name}: No records found") + all_records = self.get_records(site_record) + if not all_records: + names = [str(r.id) for r in site_record] if isinstance(site_record, list) else [str(site_record.id)] + self.warn(f"{','.join(names)}: No records found") return None - # ========================================================================== - # Methods Implemented in BaseAnalyteSource and BaseWaterLevelSource - # ========================================================================== - - def _validate_record(self, record: dict) -> None: - """ - Determines that all standardized fields are present in the record. - Raises a ValueError if any fields are missing from a record. - - For an analyte, the fields are - - backend.constants.PARAMETER - - backend.constants.PARAMETER_VALUE - - backend.constants.PARAMETER_UNITS - - For a water level, the fields are - - backend.constants.DTW - - backend.constants.DTW_UNITS - - backend.constants.DT_MEASURED - - Parameters - ---------- - record : dict - a record - - Returns - ------- - None - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _validate_record" - ) + if not isinstance(site_record, list): + site_record = [site_record] + + ret = [] + for site in site_record: + site_records = self._extract_site_records(all_records, site) + if not site_records: + self.warn(f"{site.id}: No records found") + continue + cleaned = self._clean_records(site_records) + if not cleaned: + self.warn(f"{site.id} No clean records found") + continue + result = self._build_timeseries_records(site, cleaned) + if result is not None: + ret.append(result) + return ret + + def _summarize_records(self, site, cleaned: list): + return self._summarizer.summarize(site, cleaned) + + def _build_timeseries_records(self, site, cleaned: list): + records = [] + for record in cleaned: + transformed = self.transformer.do_transform(self._extract_parameter(record), site) + if transformed is not None: + records.append(transformed) + if not records: + self.warn(f"{site.id}: No clean records found") + return None + return (site, sorted(records, key=self._sort_func)) def _get_output_units(self) -> str: - """ - Determines the output units for the source from the configuration - - If the source is an analyte source, the output units are backend.config.Config.analyte_output_units - If the source is a water level source, the output units are backend.config.Config.waterlevel_output_units - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _get_output_units" - ) - - # ========================================================================== - # Methods That Need to be Implemented For Each Source - # ========================================================================== + raise NotImplementedError(f"{self.__class__.__name__} Must implement _get_output_units") def _extract_site_records(self, records: list[dict], site_record) -> list: - """ - Returns all records for a single site as a list of records (which are dictionaries). - - Parameters - ---------- - records : dict - a dictionary of lists, where the keys are site ids and the values are parameter records - - site_record : dict - the site record for the location whose parameter records are to be retrieved - - Returns - ------- - list - a list of records for the site - """ if site_record.chunk_size == 1: return records - - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_site_records" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_site_records") def _clean_records(self, records: list) -> list: - """ - Returns cleaned records if this function is defined for each source. - Otherwise returns the records as-is. - - Parameters - ---------- - records : list - a list of records - - Returns - ------- - list - a list of cleaned records if this function is defined for each - source. Otherwise returns the records as is. - """ return records - def _extract_terminal_record(self, records, bookend): - """ - Returns the terminal record for a particular site - - Parameters - ---------- - records : list - a list of records - - bookend : str - determines if the first or last record is retrieved - - Returns - ------- - dict - the most recent record for every site - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_terminal_record" - ) + def _extract_terminal_record(self, records, position: Literal["earliest", "latest"]): + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_terminal_record") def _extract_source_parameter_units(self, records: list) -> list: - """ - Returns the units of the parameter records as a list, in the same order as the records themselves - - Parameters - ---------- - records: list - a list of parameter records - - Returns - ------- - list - a list of units for the parameter records in the same order as the records - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_source_parameter_units" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_source_parameter_units") def _extract_parameter_dates(self, records: list) -> list: - """ - Returns the dates of the parameter records as a list, in the same order as the records themselves - - Parameters - ---------- - records: list - a list of parameter records - - Returns - ------- - list - a list of dates for the parameter records in the same order as the records - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_parameter_dates" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_parameter_dates") def _extract_source_parameter_names(self, records: list) -> list: - """ - Returns the source names of the parameter records as a list, in the same order as the records themselves - - Parameters - ---------- - records: list - a list of parameter records - - Returns - ------- - list - a list of source names for the parameter records in the same order as the records - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_source_parameter_names" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_source_parameter_names") def _extract_parameter_record(self, record: dict) -> dict: - """ - Returns a parameter record with standardized fields added. This is only used for time series, not summary outputs - - For an analyte, the fields are - - backend.constants.PARAMETER_NAME_SOURCE - - backend.constants.PARAMETER_NAME_DIE - - backend.constants.PARAMETER_VALUE - - backend.constants.PARAMETER_UNITS - - Parameters - ---------- - record: dict - a parameter record - - Returns - ------- - dict - the parameter record with the fields added - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_parameter_record" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_parameter_record") def _extract_source_parameter_results(self, records: list) -> list: - """ - Returns the parameter results as a list from the records, in the same order as the records themselves. This is only used for summary outputs, not time serie - - Parameters - ---------- - records: list - a list of parameter records for a site - - Returns - ------- - list - a list of parameter results from the records, in the same order as the records - """ - raise NotImplementedError( - f"{self.__class__.__name__} Must implement _extract_source_parameter_results" - ) + raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_source_parameter_results") def _extract_parameter(self, record: dict) -> dict: - """ - Extracts a parameter record from a list of records. This is only used for time series, not summary outputs - - Parameters - ---------- - record : dict - a record - - Returns - -------- - dict - a record with the fields "datetime_measured", "parameter_value", "parameter_units", and "parameter" added - """ record = self._extract_parameter_record(record) - self._validate_record(record) + self._validator.validate(record) return record def _sort_func(self, x): - """ - Sorting function to sort the records by date_measured - - Parameters - ---------- - x : a record - - Returns - ------- - datetime - the date_measured of the record - """ return x.date_measured + # deprecated: override via validator= __init__ arg instead + def _validate_record(self, record: dict) -> None: + raise NotImplementedError(f"{self.__class__.__name__} Must implement _validate_record") -class BaseAnalyteSource(BaseParameterSource): - """ - Base class for all analyte sources. - - See BaseParameterSource for the methods that need to be implemented for each source - """ +class BaseAnalyteSource(BaseParameterSource): name = "analyte" + def __init__(self, transformer=None, http_client: httpx.Client | None = None): + super().__init__(transformer=transformer, validator=AnalyteRecordValidator(), http_client=http_client) + def _get_output_units(self): return self.config.analyte_output_units - def _validate_record(self, record): - record[PARAMETER_NAME] = self.config.parameter - for k in (PARAMETER_VALUE, PARAMETER_UNITS, DT_MEASURED): - if k not in record: - raise ValueError(f"Invalid record. Missing {k}") - class BaseWaterLevelSource(BaseParameterSource): - """ - Base class for all water level sources. - - See BaseParameterSource for the methods that need to be implemented for each source - """ - name = "water levels" + def __init__(self, transformer=None, http_client: httpx.Client | None = None): + super().__init__(transformer=transformer, validator=WaterLevelRecordValidator(), http_client=http_client) + def _get_output_units(self): return self.config.waterlevel_output_units def _extract_source_parameter_units(self, records): return [FEET for _ in records] - def _validate_record(self, record): - for k in (PARAMETER_VALUE, PARAMETER_UNITS, DT_MEASURED): - if k not in record: - raise ValueError(f"Invalid record. Missing {k}") - class BaseFileSource(BaseSource): - """ - Base class for all file sources - """ - name = "files" diff --git a/backend/transformer.py b/backend/transformer.py index c13715c..38a1bb0 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -37,12 +37,9 @@ from backend.geo_utils import datum_transform, ALLOWED_DATUMS from backend.logger import make_logger from backend.record import ( - WaterLevelSummaryRecord, - WaterLevelRecord, + ParameterRecord, SiteRecord, - AnalyteSummaryRecord, SummaryRecord, - AnalyteRecord, ) @@ -131,119 +128,9 @@ def convert_units( die_parameter_name: str, dt: str | None = None, ) -> tuple[float, float | None, str]: - """ - Converts the following units for any parameter value: - - Concentration: - - mg/L to ppm - - ppm to mg/L - - ton/ac-ft to mg/L - - ug/L to mg/L - - mg/L CaCO3 to mg/L - - mg/L N to mg/L (for NO3) - - length: - - ft to m - - m to ft - - Parameters - -------- - input_value: int | float | str - The value to convert - - input_units: str - The input unit of the value - - output_units: str - The output unit of the value - - source_parameter_name: str - The name of the parameter from the source - - die_parameter_name: str - The name of the parameter as it is called in the DIE - - dt: str - The date of the record - - Returns - -------- - tuple[float, float, str] - converted value, conversion factor, warning message - """ - warning = "" - conversion_factor = None - - input_value = float(input_value) - input_units = input_units.strip().lower() - output_units = output_units.strip().lower() - source_parameter_name = source_parameter_name.strip().lower() - die_parameter_name = die_parameter_name.strip().lower() - - mgl = MILLIGRAMS_PER_LITER.lower() - ugl = MICROGRAMS_PER_LITER.lower() - ppm = PARTS_PER_MILLION.lower() - ppb = PARTS_PER_BILLION.lower() - tpaf = TONS_PER_ACRE_FOOT.lower() - ft = FEET.lower() - m = METERS.lower() - - """ - Each output_unit block needs a check for if input_units == output_units. - - This should go at the end of each block because there are some cases where - the input_units == output_units, but the conversion factor is not 1 due to - the source_parameter_name (e.g. nitrate as n). - """ - if die_parameter_name == "ph": - conversion_factor = 1.0 - elif die_parameter_name in ["conductivity","specific_conductance"]: - # mg/l is assumed to be a mistake. the name of the source parameter is "CONDUCTIVITY @ 25 C UMHOS/CM" - if input_units in ["�mhos/cm", "umho/cm", "cm-1", "micromhos per centimeter", "mg/l", "su", "us/cm", "us/cm @25c", "µs/cm", "\u03bcs/cm",]: - conversion_factor = 1.0 - elif output_units == mgl: - if input_units in ["mg/l caco3", "mg/l caco3**"]: - if die_parameter_name == "bicarbonate": - conversion_factor = 1.22 - elif die_parameter_name == "calcium": - conversion_factor = 0.4 - elif die_parameter_name == "carbonate": - conversion_factor = 0.6 - elif input_units == "mg/l as n": - conversion_factor = 4.427 - elif input_units in ["mg/l asno3", "mg/l as no3"]: - conversion_factor = 1.0 - elif input_units == "ug/l as n": - conversion_factor = 0.004427 - elif input_units == "pci/l": - conversion_factor = 0.00149 - elif input_units in (ugl, ppb): - conversion_factor = 0.001 - elif input_units == tpaf: - conversion_factor = 735.47 - elif input_units == ppm: - conversion_factor = 1.0 - elif input_units == output_units: - if source_parameter_name in ["nitrate as n", "nitrate (as n)"]: - conversion_factor = 4.427 - else: - conversion_factor = 1.0 - elif output_units == ft: - if input_units in [m, "meters"]: - conversion_factor = 3.28084 - elif input_units in [ft, "feet"]: - conversion_factor = 1.0 - elif output_units == m: - if input_units in [ft, "feet"]: - conversion_factor = 0.3048 - elif input_units in [m, "meters"]: - conversion_factor = 1.0 - - if conversion_factor: - return input_value * conversion_factor, conversion_factor, warning - else: - warning = f"Failed to convert {input_value} {input_units} {source_parameter_name} (source) to {output_units} {die_parameter_name} (die) on {dt}" - return input_value, conversion_factor, warning + """Deprecated: use StandardUnitConverter().convert() instead.""" + from backend.converter import StandardUnitConverter + return StandardUnitConverter().convert(input_value, input_units, output_units, source_parameter_name, die_parameter_name, dt) def standardize_datetime(dt, record_id): @@ -303,7 +190,9 @@ class BaseTransformer: _polygon_cache: dict = {} check_contained = True - def __init__(self): + def __init__(self, converter=None): + from backend.converter import StandardUnitConverter + self.converter = converter if converter is not None else StandardUnitConverter() _l = make_logger(self.__class__.__name__) self.log = _l.log self.warn = _l.warn @@ -323,202 +212,105 @@ def set_config(self, config): def do_transform( self, inrecord: dict, *args, **kw - ) -> ( - AnalyteRecord - | WaterLevelRecord - | SiteRecord - | AnalyteSummaryRecord - | WaterLevelSummaryRecord - | SummaryRecord - | None - ): - """ - Transforms a record, site or parameter, into a standardized format. - Populating the correct fields is performed in _transform, then the - record is standardized in this method. This includes standardizing the datetime - for all record types and geographic/well information for site and summary - records. - - The fields for a site record are: - - source - - id - - name - - latitude - - longitude - - elevation - - elevation_units - - horizontal_datum - - vertical_datum - - usgs_site_id (optional) - - alternate_site_id (optional) - - aquifer (optional) - - well_depth (optional) - - well_depth_units (optional) - - The fields for a parameter record are: - - parameter_name - - parameter_value - - parameter_units - - date_measured - - time_measured - - source_parameter_name - - source_parameter_units - - conversion_factor - - Parameters - -------- - inrecord: dict - The record to transform - - Returns - -------- - AnalyteRecord | WaterLevelRecord | SiteRecord | AnalyteSummaryRecord | WaterLevelSummaryRecord | SummaryRecord - The transformed and standardized record - """ - # _transform needs to be implemented by each SiteTransformer - # _transform is already implemented in each ParameterTransformer + ) -> ParameterRecord | SiteRecord | SummaryRecord | None: transformed_record = self._transform(inrecord, *args, **kw) if not transformed_record: return None - - # ensure that a site or summary record is contained within the boundaing polygon - if "longitude" in transformed_record and "latitude" in transformed_record: - if not self.contained( - transformed_record["longitude"], transformed_record["latitude"] - ): - self.warn( - f"Skipping site {transformed_record['id']}. It is not within the defined geographic bounds" - ) + if not self._apply_geographic_filter(transformed_record): + return None + self._post_transform(transformed_record, *args, **kw) + self._standardize_datetime(transformed_record) + klass = self._get_record_klass() + transformed_record["record_type"] = self._get_record_type() + klassed_record = klass(transformed_record) + if isinstance(klassed_record, (SiteRecord, SummaryRecord)): + klassed_record = self._apply_datum_transform(klassed_record) + if klassed_record is None: return None + klassed_record = self._apply_elevation_transform(klassed_record) + klassed_record = self._apply_well_depth_transform(klassed_record) + elif klassed_record.record_type in ("analytes", "waterlevels"): + klassed_record = self._apply_unit_conversion(klassed_record) + return klassed_record - self._post_transform(transformed_record, *args, **kw) + def _apply_geographic_filter(self, transformed_record: dict) -> bool: + if "longitude" not in transformed_record or "latitude" not in transformed_record: + return True + if not self.contained(transformed_record["longitude"], transformed_record["latitude"]): + self.warn(f"Skipping site {transformed_record['id']}. It is not within the defined geographic bounds") + return False + return True - # standardize datetime + def _standardize_datetime(self, transformed_record: dict) -> None: dt = transformed_record.get(DT_MEASURED) if dt: d, t = standardize_datetime(dt, transformed_record["id"]) - transformed_record["date_measured"] = d - transformed_record["time_measured"] = t else: mrd = transformed_record.get("latest_datetime") - if mrd: - d, t = standardize_datetime(mrd, transformed_record["id"]) - transformed_record["date_measured"] = d - transformed_record["time_measured"] = t - - # convert to proper record type - # a record klass holds the original record's data as a dictionary, and has methods to update the record's data and get the record's data - klass = self._get_record_klass() - klassed_record = klass(transformed_record) - - # update the record's geographic information and well data if it is a SiteRecord or SummaryRecord - # transforms the horizontal datum and lon/lat coordinates to WGS84 - # transforms the elevation and well depth units to the output unit specified in the config - # transforms the well depth and well depth units to the output unit specified in the config - if isinstance(klassed_record, (SiteRecord, SummaryRecord)): - y = float(klassed_record.latitude) - x = float(klassed_record.longitude) - - if x == 0 or y == 0: - self.warn( - f"Skipping site {klassed_record.id}. Latitude or Longitude is 0" - ) - return None - - if not (-180 <= x <= 180) or not (-90 <= y <= 90): - self.warn( - f"Skipping site {klassed_record.id}. Coordinates out of range: lng={x}, lat={y}" - ) - return None - - input_horizontal_datum = klassed_record.horizontal_datum - - if input_horizontal_datum not in ALLOWED_DATUMS: - self.warn( - f"Skipping site {klassed_record.id}. Datum {input_horizontal_datum} cannot be processed" - ) - return None - - output_elevation_units = "" - well_depth_units = "" - output_horizontal_datum = "WGS84" - if self.config: - output_elevation_units = self.config.output_elevation_units - well_depth_units = self.config.output_well_depth_units - output_horizontal_datum = self.config.output_horizontal_datum - - lng, lat, datum = transform_horizontal_datum( - x, - y, - input_horizontal_datum, - output_horizontal_datum, - ) - - if not self.in_nm(lng, lat): - self.warn( - f"Skipping site {klassed_record.id}. Coordinates {x}, {y} with datum {input_horizontal_datum} are not within 25km of New Mexico" - ) - return None + if not mrd: + return + d, t = standardize_datetime(mrd, transformed_record["id"]) + transformed_record["date_measured"] = d + transformed_record["time_measured"] = t + + def _apply_datum_transform(self, klassed_record): + y = float(klassed_record.latitude) + x = float(klassed_record.longitude) + if x == 0 or y == 0: + self.warn(f"Skipping site {klassed_record.id}. Latitude or Longitude is 0") + return None + if not (-180 <= x <= 180) or not (-90 <= y <= 90): + self.warn(f"Skipping site {klassed_record.id}. Coordinates out of range: lng={x}, lat={y}") + return None + input_datum = klassed_record.horizontal_datum + if input_datum not in ALLOWED_DATUMS: + self.warn(f"Skipping site {klassed_record.id}. Datum {input_datum} cannot be processed") + return None + output_datum = self.config.output_horizontal_datum if self.config else "WGS84" + lng, lat, datum = transform_horizontal_datum(x, y, input_datum, output_datum) + if not self.in_nm(lng, lat): + self.warn(f"Skipping site {klassed_record.id}. Coordinates {x}, {y} with datum {input_datum} are not within 25km of New Mexico") + return None + klassed_record.update(latitude=lat, longitude=lng, horizontal_datum=datum) + return klassed_record - klassed_record.update(latitude=lat) - klassed_record.update(longitude=lng) - klassed_record.update(horizontal_datum=datum) + def _apply_elevation_transform(self, klassed_record): + units = self.config.output_elevation_units if self.config else "" + elevation, unit = transform_length_units(klassed_record.elevation, klassed_record.elevation_units, units) + klassed_record.update(elevation=elevation, elevation_units=unit) + return klassed_record - elevation, elevation_unit = transform_length_units( - klassed_record.elevation, - klassed_record.elevation_units, - output_elevation_units, - ) - klassed_record.update(elevation=elevation) - klassed_record.update(elevation_units=elevation_unit) + def _apply_well_depth_transform(self, klassed_record): + units = self.config.output_well_depth_units if self.config else "" + depth, unit = transform_length_units(klassed_record.well_depth, klassed_record.well_depth_units, units) + klassed_record.update(well_depth=depth, well_depth_units=unit) + return klassed_record - well_depth, well_depth_unit = transform_length_units( - klassed_record.well_depth, - klassed_record.well_depth_units, - well_depth_units, + def _apply_unit_conversion(self, klassed_record): + output_units = ( + self.config.analyte_output_units + if klassed_record.record_type == "analytes" + else self.config.waterlevel_output_units + ) + source_result = klassed_record.parameter_value + source_unit = klassed_record.source_parameter_units + source_name = klassed_record.source_parameter_name + dt = klassed_record.date_measured + warning_msg = "" + conversion_factor = None + try: + converted_result, conversion_factor, warning_msg = self.converter.convert( + float(source_result), source_unit, output_units, source_name, self.config.parameter, dt ) - klassed_record.update(well_depth=well_depth) - klassed_record.update(well_depth_units=well_depth_unit) - - # update the units to the output unit for analyte records - # this is done after converting the units to the output unit for the analyte records - # convert the parameter value to the output unit specified in the config - elif isinstance(klassed_record, (AnalyteRecord, WaterLevelRecord)): - if isinstance(klassed_record, AnalyteRecord): - output_units = self.config.analyte_output_units - else: - output_units = self.config.waterlevel_output_units - - source_result = klassed_record.parameter_value - source_unit = klassed_record.source_parameter_units - dt = klassed_record.date_measured - source_name = klassed_record.source_parameter_name - conversion_factor = None # conversion factor will remain None if record is kept for time series and cannot be converted, such as non-detects - warning_msg = "" - try: - converted_result, conversion_factor, warning_msg = convert_units( - float(source_result), - source_unit, - output_units, - source_name, - self.config.parameter, - dt, - ) - if warning_msg != "": - msg = f"{warning_msg} for {klassed_record.id}" - self.warn(msg) - except (TypeError, ValueError): - msg = f"Keeping {source_result} for {klassed_record.id} on {klassed_record.date_measured} for time series data" - self.warn(msg) - converted_result = source_result - - if warning_msg == "": - klassed_record.update(conversion_factor=conversion_factor) - klassed_record.update(parameter_value=converted_result) - else: - klassed_record = None - - return klassed_record + if warning_msg: + self.warn(f"{warning_msg} for {klassed_record.id}") + except (TypeError, ValueError): + self.warn(f"Keeping {source_result} for {klassed_record.id} on {dt} for time series data") + converted_result = source_result + if warning_msg == "": + klassed_record.update(conversion_factor=conversion_factor, parameter_value=converted_result) + return klassed_record + return None def in_nm(self, lng: float | int | str, lat: float | int | str) -> bool: """ @@ -652,6 +444,9 @@ def _post_transform(self, *args, **kw): def _get_record_klass(self): raise NotImplementedError + def _get_record_type(self) -> str | None: + return None + class SiteTransformer(BaseTransformer): def _get_record_klass(self) -> type[SiteRecord]: @@ -716,22 +511,8 @@ def _transform(self, record, site_record): rec.update(source_id) return rec - def _transform_terminal_record(self, record, site_id, bookend): - """ - Convert either the earliest or latest record to the standard format. - - Parameters - -------- - record: dict - The record to convert - - site_id: str - The site ID for the record - - bookend: str - The bookend of the record to convert. Either "earliest" or "latest" - """ - if bookend == EARLIEST: + def _transform_terminal_record(self, record, site_id, position): + if position == EARLIEST: datetime_key = "earliest_datetime" date_key = "earliest_date" time_key = "earliest_time" @@ -739,7 +520,7 @@ def _transform_terminal_record(self, record, site_id, bookend): unit_key = "earliest_units" source_units_key = "earliest_source_units" source_name_key = "earliest_source_name" - elif bookend == LATEST: + elif position == LATEST: datetime_key = "latest_datetime" date_key = "latest_date" time_key = "latest_time" @@ -750,7 +531,7 @@ def _transform_terminal_record(self, record, site_id, bookend): dt, tt = standardize_datetime(record[datetime_key], site_id) parameter_name, unit = self._get_parameter_name_and_units() - converted_value, conversion_factor, warning_msg = convert_units( + converted_value, conversion_factor, warning_msg = self.converter.convert( record[value_key], record[source_units_key], unit, @@ -773,23 +554,11 @@ def _transform_latest_record(self, record, site_id): class WaterLevelTransformer(ParameterTransformer): - def _get_record_klass( - self, - ) -> type[WaterLevelRecord] | type[WaterLevelSummaryRecord]: - """ - Returns the WaterLevelRecord class to use for the transformer for - water level records if config.output_summary is False, otherwise - returns the WaterLevelSummaryRecord class + def _get_record_klass(self) -> type[ParameterRecord] | type[SummaryRecord]: + return SummaryRecord if self.config.output_summary else ParameterRecord - Returns - -------- - WaterLevelRecord | WaterLevelSummaryRecord - The record class to use for the transformer - """ - if self.config.output_summary: - return WaterLevelSummaryRecord - else: - return WaterLevelRecord + def _get_record_type(self) -> str: + return "waterlevels" def _get_parameter_name_and_units(self) -> tuple: """ @@ -804,21 +573,11 @@ def _get_parameter_name_and_units(self) -> tuple: class AnalyteTransformer(ParameterTransformer): - def _get_record_klass(self) -> type[AnalyteRecord] | type[AnalyteSummaryRecord]: - """ - Returns the AnalyteRecord class to use for the transformer for - water level records if config.output_summary is False, otherwise - returns the AnalyteSummaryRecord class + def _get_record_klass(self) -> type[ParameterRecord] | type[SummaryRecord]: + return SummaryRecord if self.config.output_summary else ParameterRecord - Returns - -------- - AnalyteRecord | AnalyteSummaryRecord - The record class to use for the transformer - """ - if self.config.output_summary: - return AnalyteSummaryRecord - else: - return AnalyteRecord + def _get_record_type(self) -> str: + return "analytes" def _get_parameter_name_and_units(self) -> tuple: """ diff --git a/tests/test_persisters/test_ogc_features.py b/tests/test_persisters/test_ogc_features.py index 61413df..969b8a5 100644 --- a/tests/test_persisters/test_ogc_features.py +++ b/tests/test_persisters/test_ogc_features.py @@ -3,7 +3,7 @@ import tempfile from backend.persisters.ogc_features import dump_summary_collection, dump_timeseries_collection -from backend.record import SummaryRecord, SiteRecord, WaterLevelRecord +from backend.record import SummaryRecord, SiteRecord, ParameterRecord def _make_summary_record(source="nmbgmr_amp", rid="RA-1234", lat=35.0, lon=-106.5): @@ -58,7 +58,7 @@ def _make_site_record(source="nmbgmr_amp", rid="RA-1234", lat=35.0, lon=-106.5): def _make_wl_record(source="nmbgmr_amp", rid="RA-1234", date="2024-01-15", value=212.4): - return WaterLevelRecord({ + return ParameterRecord({ "source": source, "id": rid, "parameter_name": "waterlevels", @@ -69,6 +69,7 @@ def _make_wl_record(source="nmbgmr_amp", rid="RA-1234", date="2024-01-15", value "source_parameter_name": "depth_to_water", "source_parameter_units": "ft", "conversion_factor": 1.0, + "record_type": "waterlevels", }) From f8445456625da5aa95a9fb9b86710595bed0f7da Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 24 Jun 2026 14:43:34 -0600 Subject: [PATCH 123/126] Add Dagster+ serverless code location + deploy paths Make the repo show up in Dagster+ as a serverless code location. - dagster_cloud.yaml: location die-orchestration, module orchestration.definitions, build dir orchestration (its pyproject carries the dagster deps + nmuwd path-source). - GitHub Actions: prod deploy on push to main + per-PR branch deployments, PEX fast deploy (no system GDAL needed; only shapely, which ships a bundled-GEOS wheel). - orchestration/deploy_serverless.sh: manual deploy via dagster-cloud serverless deploy-python-executable, same build definition as CI. Co-Authored-By: Claude Opus 4.8 --- .../dagster-cloud-branch-deployments.yml | 75 ++++++++++++++++++ .github/workflows/dagster-cloud-deploy.yml | 78 +++++++++++++++++++ dagster_cloud.yaml | 9 +++ orchestration/deploy_serverless.sh | 41 ++++++++++ 4 files changed, 203 insertions(+) create mode 100644 .github/workflows/dagster-cloud-branch-deployments.yml create mode 100644 .github/workflows/dagster-cloud-deploy.yml create mode 100644 dagster_cloud.yaml create mode 100755 orchestration/deploy_serverless.sh diff --git a/.github/workflows/dagster-cloud-branch-deployments.yml b/.github/workflows/dagster-cloud-branch-deployments.yml new file mode 100644 index 0000000..34f38e3 --- /dev/null +++ b/.github/workflows/dagster-cloud-branch-deployments.yml @@ -0,0 +1,75 @@ +name: Dagster+ Serverless Branch Deployments + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +concurrency: + group: ${{ github.ref }}/branch_deployments + cancel-in-progress: true + +env: + DAGSTER_CLOUD_URL: ${{ secrets.DAGSTER_CLOUD_URL }} + DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }} + ENABLE_FAST_DEPLOYS: 'true' + PYTHON_VERSION: '3.10' + DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml' + +jobs: + dagster_cloud_default_deploy: + name: Dagster Serverless Deploy + runs-on: ubuntu-22.04 + outputs: + build_info: ${{ steps.parse-workspace.outputs.build_info }} + steps: + - name: Prerun Checks + id: prerun + uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1 + + - name: Launch Docker Deploy + if: steps.prerun.outputs.result == 'docker-deploy' + id: parse-workspace + uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v0.1 + with: + dagster_cloud_file: $DAGSTER_CLOUD_FILE + + - name: Checkout for Python Executable Deploy + if: steps.prerun.outputs.result == 'pex-deploy' + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + path: project-repo + + - name: Python Executable Deploy + if: steps.prerun.outputs.result == 'pex-deploy' + uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v0.1 + with: + dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE" + build_output_dir: "$GITHUB_WORKSPACE/build" + python_version: "${{ env.PYTHON_VERSION }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dagster_cloud_docker_deploy: + name: Docker Deploy + runs-on: ubuntu-22.04 + if: needs.dagster_cloud_default_deploy.outputs.build_info + needs: dagster_cloud_default_deploy + strategy: + fail-fast: false + matrix: + location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + - name: Build and deploy to Dagster+ serverless + uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v0.1 + with: + dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }} + location: ${{ toJson(matrix.location) }} + base_image: "python:${{ env.PYTHON_VERSION }}-slim" + organization_id: ${{ secrets.ORGANIZATION_ID }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dagster-cloud-deploy.yml b/.github/workflows/dagster-cloud-deploy.yml new file mode 100644 index 0000000..7dd39ad --- /dev/null +++ b/.github/workflows/dagster-cloud-deploy.yml @@ -0,0 +1,78 @@ +name: Dagster+ Serverless Deploy + +on: + push: + branches: + - "main" + +concurrency: + group: ${{ github.ref }}/deploy + cancel-in-progress: true + +env: + DAGSTER_CLOUD_URL: ${{ secrets.DAGSTER_CLOUD_URL }} + DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }} + # PEX fast deploy. No system GDAL needed (only shapely, which ships a wheel + # with bundled GEOS), so a Docker build would be pure overhead. + ENABLE_FAST_DEPLOYS: 'true' + PYTHON_VERSION: '3.10' + DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml' + +jobs: + dagster_cloud_default_deploy: + name: Dagster Serverless Deploy + runs-on: ubuntu-22.04 + outputs: + build_info: ${{ steps.parse-workspace.outputs.build_info }} + steps: + - name: Prerun Checks + id: prerun + uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1 + + - name: Launch Docker Deploy + if: steps.prerun.outputs.result == 'docker-deploy' + id: parse-workspace + uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v0.1 + with: + dagster_cloud_file: $DAGSTER_CLOUD_FILE + + - name: Checkout for Python Executable Deploy + if: steps.prerun.outputs.result == 'pex-deploy' + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + path: project-repo + + - name: Python Executable Deploy + if: steps.prerun.outputs.result == 'pex-deploy' + uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v0.1 + with: + dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE" + build_output_dir: "$GITHUB_WORKSPACE/build" + python_version: "${{ env.PYTHON_VERSION }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dagster_cloud_docker_deploy: + name: Docker Deploy + runs-on: ubuntu-22.04 + if: needs.dagster_cloud_default_deploy.outputs.build_info + needs: dagster_cloud_default_deploy + strategy: + fail-fast: false + matrix: + location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + - name: Build and deploy to Dagster+ serverless + uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v0.1 + with: + dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }} + location: ${{ toJson(matrix.location) }} + base_image: "python:${{ env.PYTHON_VERSION }}-slim" + organization_id: ${{ secrets.ORGANIZATION_ID }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/dagster_cloud.yaml b/dagster_cloud.yaml new file mode 100644 index 0000000..7896743 --- /dev/null +++ b/dagster_cloud.yaml @@ -0,0 +1,9 @@ +locations: + - location_name: die-orchestration + code_source: + module_name: orchestration.definitions + build: + # PEX fast-deploy build root. orchestration/pyproject.toml declares the + # dagster deps and the `nmuwd` path-dependency (tool.uv.sources); the + # builder recurses into the repo root to collect backend/ + its deps. + directory: orchestration diff --git a/orchestration/deploy_serverless.sh b/orchestration/deploy_serverless.sh new file mode 100755 index 0000000..bd76476 --- /dev/null +++ b/orchestration/deploy_serverless.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Manual deploy of the DIE code location to Dagster+ Serverless. +# +# Same target as the GitHub Action (.github/workflows/dagster-cloud-deploy.yml): +# a PEX fast-deploy built from dagster_cloud.yaml. Use this to push from a laptop +# without going through CI. +# +# Prerequisites: +# - Docker running locally (build-method=docker builds manylinux-compatible +# wheels so the deploy works regardless of host OS). +# - uv installed (the dagster-cloud CLI is pulled in on the fly via `uv run`). +# +# Required env vars: +# DAGSTER_CLOUD_ORGANIZATION your Dagster+ org name (e.g. "nmwd") +# DAGSTER_CLOUD_API_TOKEN a Dagster+ user/agent token +# Optional: +# DEPLOYMENT target deployment (default: prod) +# +# Usage: +# DAGSTER_CLOUD_ORGANIZATION=nmwd DAGSTER_CLOUD_API_TOKEN=*** \ +# orchestration/deploy_serverless.sh +set -euo pipefail + +: "${DAGSTER_CLOUD_ORGANIZATION:?set DAGSTER_CLOUD_ORGANIZATION to your Dagster+ org}" +: "${DAGSTER_CLOUD_API_TOKEN:?set DAGSTER_CLOUD_API_TOKEN to a Dagster+ token}" +DEPLOYMENT="${DEPLOYMENT:-prod}" + +# Run from repo root so dagster_cloud.yaml + the orchestration build dir resolve. +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +echo "Deploying location 'die-orchestration' to org '$DAGSTER_CLOUD_ORGANIZATION' deployment '$DEPLOYMENT'..." + +uv run --with dagster-cloud -- \ + dagster-cloud serverless deploy-python-executable \ + --organization "$DAGSTER_CLOUD_ORGANIZATION" \ + --deployment "$DEPLOYMENT" \ + --location-file dagster_cloud.yaml \ + --location-name die-orchestration \ + --python-version 3.10 \ + --build-method docker From c2ca243ab3ed562f45dac5f48c1d276541e0f114 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 24 Jun 2026 15:20:00 -0600 Subject: [PATCH 124/126] Fix CI build: flake8 .venv exclude + resolve mypy type errors The build job failed at the flake8 step (linting .venv site-packages), which masked mypy errors the refactor introduced. Fix the whole gate: - .flake8: exclude .venv + output dirs (extend-exclude keeps defaults). - logger.py: drop dead `global _managed_handlers`, annotate the list. - mypy fixes (0 errors, was 33): - source.py: Optional defaults for validator/transformer, widen _extract_terminal_record position to str, annotate RecordValidator .config, cast site records in _transform_sites, cast read() returns (List is invariant). - record.py: declare BaseRecord.chunk_size. - persister.py: add_extension extension arg is str (callers pass .value). - transformer.py: coerce convert() input to float. - geo_utils.py / geoserver.py / factory.py / st_connector.py: type annotations for module globals, declarative Base, optional import, and Optional url attrs. - pyproject.toml: exclude tests/archived from mypy (already excluded from pytest; references an old CLI API). flake8 + mypy + pytest (427 tests) all green locally with the CI env (uv sync --extra dev). Co-Authored-By: Claude Opus 4.8 --- .flake8 | 7 +++++++ backend/connectors/st_connector.py | 7 ++++--- backend/geo_utils.py | 4 ++-- backend/logger.py | 6 +++--- backend/persister.py | 2 +- backend/persisters/factory.py | 2 +- backend/persisters/geoserver.py | 6 ++++-- backend/record.py | 3 +++ backend/source.py | 27 +++++++++++++++------------ backend/transformer.py | 2 +- pyproject.toml | 3 +++ 11 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..bab42bf --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +# Don't lint the virtualenv or generated output dirs. extend-exclude keeps +# flake8's built-in defaults (.git, __pycache__, build, dist, ...). +extend-exclude = + .venv, + output, + output_1 diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index 3c6e914..38aeeb3 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== from datetime import datetime +from typing import Optional import frost_sta_client as fsc from shapely import MultiPolygon, unary_union @@ -68,7 +69,7 @@ def make_dt_filter(tag, start, end): class STSiteSource(BaseSiteSource): - url: str = None + url: Optional[str] = None def __init__(self, transformer=None): super().__init__(transformer=transformer) @@ -126,7 +127,7 @@ def _get_filters(self): class STWaterLevelSource(BaseWaterLevelSource): - url: str = None + url: Optional[str] = None def __init__(self, transformer=None): super().__init__(transformer=transformer) @@ -148,7 +149,7 @@ def _extract_terminal_record(self, records, position): class STAnalyteSource(BaseAnalyteSource): - url: str = None + url: Optional[str] = None def __init__(self, transformer=None): super().__init__(transformer=transformer) diff --git a/backend/geo_utils.py b/backend/geo_utils.py index 4484ee9..43b81dd 100644 --- a/backend/geo_utils.py +++ b/backend/geo_utils.py @@ -16,8 +16,8 @@ import pyproj from shapely.ops import transform -PROJECTIONS = {} -TRANSFORMS = {} +PROJECTIONS: dict = {} +TRANSFORMS: dict = {} ALLOWED_DATUMS = ["NAD27", "NAD83", "WGS84"] diff --git a/backend/logger.py b/backend/logger.py index 71cb429..b07a396 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -21,7 +21,7 @@ # Track handlers created by this module to avoid closing unrelated handlers -_managed_handlers = [] +_managed_handlers: list = [] class Logger: @@ -69,8 +69,8 @@ def debug(self, msg): def setup_logging(level=None, log_format=None, path=None): - global _managed_handlers - + # _managed_handlers is mutated in place (clear/append), never reassigned, + # so no `global` declaration is needed. if level is None: level = logging.DEBUG if log_format is None: diff --git a/backend/persister.py b/backend/persister.py index a4453b4..6bfc614 100644 --- a/backend/persister.py +++ b/backend/persister.py @@ -148,7 +148,7 @@ def dump_timeseries_separated(self, path: str): self.warn(f"failed to dump separated timeseries: {e}", exc_info=True) raise - def add_extension(self, path: str, extension: OutputFormat): + def add_extension(self, path: str, extension: str): if not extension: raise NotImplementedError else: diff --git a/backend/persisters/factory.py b/backend/persisters/factory.py index 3d06510..d179b15 100644 --- a/backend/persisters/factory.py +++ b/backend/persisters/factory.py @@ -7,7 +7,7 @@ def make_persister(config) -> BasePersister: try: from backend.persisters.geoserver import GeoServerPersister except ImportError: - GeoServerPersister = None + GeoServerPersister = None # type: ignore[assignment,misc] if config.output_format == OutputFormat.GEOSERVER: if GeoServerPersister is None: diff --git a/backend/persisters/geoserver.py b/backend/persisters/geoserver.py index d8c07fc..c76fa14 100644 --- a/backend/persisters/geoserver.py +++ b/backend/persisters/geoserver.py @@ -9,7 +9,7 @@ import os import time from itertools import groupby -from typing import Type +from typing import Any, Type from shapely.geometry.multipoint import MultiPoint from shapely.geometry.point import Point from sqlalchemy.dialects.postgresql import JSONB, insert @@ -31,7 +31,9 @@ ) from geoalchemy2 import Geometry -Base = declarative_base() +# declarative_base() returns a dynamic class; annotate as Any so mypy accepts +# it as a base for the ORM models below. +Base: Any = declarative_base() def session_factory(connection: dict): diff --git a/backend/record.py b/backend/record.py index fc50585..b9a913f 100644 --- a/backend/record.py +++ b/backend/record.py @@ -25,6 +25,9 @@ class BaseRecord: + # Set by the source after transform; not all record types use it. + chunk_size: int | None = None + def to_csv(self): raise NotImplementedError diff --git a/backend/source.py b/backend/source.py index 40085f1..d54a495 100644 --- a/backend/source.py +++ b/backend/source.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from json import JSONDecodeError -from typing import Literal, Union, List, Callable, Dict +from typing import Any, Literal, Optional, Union, List, Callable, Dict, cast import httpx import shapely.wkt @@ -45,7 +45,7 @@ # ============================================================================= class RecordValidator: - config = None + config: Any = None def set_config(self, config) -> None: self.config = config @@ -190,7 +190,7 @@ def get_analyte_search_param(parameter: str, mapping: dict) -> str: class BaseSource: transformer_klass = BaseTransformer # deprecated: pass transformer= to __init__ - def __init__(self, transformer: BaseTransformer = None, http_client: httpx.Client | None = None): + def __init__(self, transformer: Optional[BaseTransformer] = None, http_client: httpx.Client | None = None): self.transformer = transformer if transformer is not None else self.transformer_klass() self._http_client = http_client if http_client is not None else httpx.Client(timeout=900) _l = make_logger(self.__class__.__name__) @@ -318,12 +318,13 @@ def read(self, *args, **kw) -> List[SiteRecord] | None: return None def _transform_sites(self, records: list) -> List[SiteRecord]: - transformed_records = [] + transformed_records: List[SiteRecord] = [] for record in records: - record = self.transformer.do_transform(record) - if record: - record.chunk_size = self.chunk_size - transformed_records.append(record) + transformed = self.transformer.do_transform(record) + if transformed: + site_record = cast(SiteRecord, transformed) + site_record.chunk_size = self.chunk_size + transformed_records.append(site_record) self.log(f"processed nrecords={len(transformed_records)}") return transformed_records @@ -338,7 +339,7 @@ def chunks(self, records: list, chunk_size: int | None = None) -> list: class BaseParameterSource(BaseSource): name = "" - def __init__(self, transformer=None, validator: RecordValidator = None, http_client: httpx.Client | None = None): + def __init__(self, transformer=None, validator: Optional[RecordValidator] = None, http_client: httpx.Client | None = None): super().__init__(transformer=transformer, http_client=http_client) self._validator = validator if validator is not None else _SubclassValidatorShim(self) self._summarizer = RecordSummarizer(self) @@ -350,9 +351,11 @@ def _extract_latest_record(self, records: list) -> dict: return self._extract_terminal_record(records, position=LATEST) def read(self, site_record: SiteRecord | list, use_summarize: bool, start_ind: int, end_ind: int) -> List[ParameterRecord | SummaryRecord] | None: + # read_summary/read_timeseries return homogeneous lists; cast to the + # mixed-element list type the signature advertises (List is invariant). if use_summarize: - return self.read_summary(site_record, start_ind, end_ind) - return self.read_timeseries(site_record) + return cast("List[ParameterRecord | SummaryRecord] | None", self.read_summary(site_record, start_ind, end_ind)) + return cast("List[ParameterRecord | SummaryRecord] | None", self.read_timeseries(site_record)) def read_summary(self, site_record: SiteRecord | list, start_ind: int, end_ind: int) -> List[SummaryRecord] | None: if isinstance(site_record, list): @@ -439,7 +442,7 @@ def _extract_site_records(self, records: list[dict], site_record) -> list: def _clean_records(self, records: list) -> list: return records - def _extract_terminal_record(self, records, position: Literal["earliest", "latest"]): + def _extract_terminal_record(self, records, position: str): raise NotImplementedError(f"{self.__class__.__name__} Must implement _extract_terminal_record") def _extract_source_parameter_units(self, records: list) -> list: diff --git a/backend/transformer.py b/backend/transformer.py index 38a1bb0..646bd2d 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -130,7 +130,7 @@ def convert_units( ) -> tuple[float, float | None, str]: """Deprecated: use StandardUnitConverter().convert() instead.""" from backend.converter import StandardUnitConverter - return StandardUnitConverter().convert(input_value, input_units, output_units, source_parameter_name, die_parameter_name, dt) + return StandardUnitConverter().convert(float(input_value), input_units, output_units, source_parameter_name, die_parameter_name, dt) def standardize_datetime(dt, record_id): diff --git a/pyproject.toml b/pyproject.toml index 02dc69c..a35c373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,3 +46,6 @@ norecursedirs = ["tests/archived"] [tool.mypy] ignore_missing_imports = true +# Archived tests are excluded from pytest (norecursedirs) and reference an old +# CLI API; don't type-check them either. +exclude = ["tests/archived/"] From 1cd29cb212ed375cedf43d050e4da05833839937 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 24 Jun 2026 15:49:15 -0600 Subject: [PATCH 125/126] Remove dead code: unused imports, functions, classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified via cross-reference (grep + framework-entry-point check) before removal; kept Flask/FastAPI route handlers, click commands, and inherited base-class slots that vulture flags as "unused". Removed: - Dead functions/classes: bounding_polygons get_* helpers (6), geo_utils utm/lonlat converters (+ orphaned PROJECTIONS), converter UnitConverter Protocol, logger Loggable, strategies OutputStrategy, source BaseContainerSource/BaseFileSource, transformer convert_units (deprecated), unifier get_sources_in_polygon/generate_site_bounds/ *_unification_test/get_datastream(s) + stale commented call block. - orchestration/assets/wells.py (build_wells_asset, imported nowhere). - 63 unused imports across backend/frontend/orchestration (autoflake). flake8 (E9,F63,F7,F82) + mypy clean; 427 tests collect with no import errors; local persister tests pass. ckan connector appears entirely unwired (config uses st2 instead) — flagged separately, not removed here. Co-Authored-By: Claude Opus 4.8 --- backend/bounding_polygons.py | 99 ------------------- backend/config.py | 1 - backend/connectors/bor/source.py | 6 -- backend/connectors/bor/transformer.py | 5 - backend/connectors/ckan/source.py | 3 - backend/connectors/ckan/transformer.py | 5 +- backend/connectors/isc_seven_rivers/source.py | 4 - .../isc_seven_rivers/transformer.py | 4 - backend/connectors/nmbgmr/source.py | 2 - backend/connectors/nmbgmr/transformer.py | 3 - backend/connectors/nmenv/source.py | 1 - backend/connectors/nmenv/transformer.py | 2 +- backend/connectors/nmose/transformer.py | 2 +- backend/connectors/st2/source.py | 3 - backend/connectors/st2/transformer.py | 5 - backend/connectors/st_connector.py | 1 - backend/connectors/usgs/transformer.py | 3 +- backend/connectors/wqp/source.py | 4 - backend/connectors/wqp/transformer.py | 4 - backend/converter.py | 15 --- backend/geo_utils.py | 53 ---------- backend/logger.py | 20 ---- backend/persisters/geoserver.py | 5 +- backend/persisters/strategies.py | 6 -- backend/source.py | 20 +--- backend/transformer.py | 20 ---- backend/unifier.py | 95 +----------------- frontend/api/app.py | 2 - orchestration/assets/wells.py | 47 --------- orchestration/resources/gcs.py | 1 - 30 files changed, 8 insertions(+), 433 deletions(-) delete mode 100644 orchestration/assets/wells.py diff --git a/backend/bounding_polygons.py b/backend/bounding_polygons.py index 8a9ccd6..1bd53a6 100644 --- a/backend/bounding_polygons.py +++ b/backend/bounding_polygons.py @@ -15,113 +15,14 @@ # =============================================================================== import json import os -from pprint import pprint import click import httpx -from shapely import Polygon, box from shapely.geometry import shape from backend.geo_utils import transform_srid, SRID_WGS84, SRID_UTM_ZONE_13N -# polygon retrivial functions -# multiple polygons -def get_congressional_district_boundaries(state, district): - pass - - -def get_tribal_boundaries(state=None): - state, statefp = _get_statefp(state) - - # use the processes service to get all tribal boundaries that intersect the state - def func(): - payload = { - "inputs": { - "collection": f"aiannh", - "url": f"https://geoconnex.us/ref/states/{statefp}", - } - } - resp = httpx.post( - "https://reference.geoconnex.us/processes/intersector/execution", - json=payload, - ) - return resp.json() - - obj = _get_cached_object(f"{state}.aiannh", f"{state} AIANNH", func) - - return obj - - -def get_state_hucs_boundaries(state=None, level=8): - state, statefp = _get_statefp(state) - - # use the processes service to get all hucs from this level that intersect the state of NM - def func(): - payload = { - "inputs": { - "collection": f"hu{level:02n}", - "url": f"https://geoconnex.us/ref/states/{statefp}", - } - } - resp = httpx.post( - "https://reference.geoconnex.us/processes/intersector/execution", - json=payload, - ) - return resp.json() - - obj = _get_cached_object(f"{state}.hucs.{level}", f"{state} HU{level:02n}", func) - - return obj - - -def get_state_pwss_boundaries(state=None): - state, statefp = _get_statefp(state) - obj = _get_cached_object( - f"{state}.pws", - f"{state} PWSs", - f"https://reference.geoconnex.us/collections/pws/items?f=json&state_code={state}", - ) - - return obj - - -# single polygons - - -def get_pws_polygon(pwsid, as_wkt=True): - obj = _get_cached_object( - pwsid, - pwsid, - f"https://reference.geoconnex.us/collections/pws/items/{pwsid}?f=json", - ) - return _make_shape(obj, as_wkt) - - -def get_huc_polygon(huc, as_wkt=True): - if len(huc) == 2: - collection = "hu02" - elif len(huc) == 4: - collection = "hu04" - elif len(huc) == 6: - collection = "hu06" - elif len(huc) == 8: - collection = "hu08" - elif len(huc) == 10: - collection = "hu10" - else: - _warning(f"Invalid HUC {huc}. length must be 2, 4, 6, 8, or 10") - return - - obj = _get_cached_object( - huc, - huc, - f"https://reference.geoconnex.us/collections/{collection}/items/{huc}?f=json", - ) - - return _make_shape(obj, as_wkt) - - def get_county_polygon(name, as_wkt=True): if ":" in name: state, county = name.split(":") diff --git a/backend/config.py b/backend/config.py index 1709b55..d349881 100644 --- a/backend/config.py +++ b/backend/config.py @@ -16,7 +16,6 @@ import os import sys from datetime import datetime, timedelta -from enum import Enum import shapely.wkt import yaml diff --git a/backend/connectors/bor/source.py b/backend/connectors/bor/source.py index 7ef532e..506da1c 100644 --- a/backend/connectors/bor/source.py +++ b/backend/connectors/bor/source.py @@ -13,10 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -from json import JSONDecodeError -import httpx from backend.connectors.bor.transformer import BORSiteTransformer, BORAnalyteTransformer from backend.connectors.mappings import BOR_ANALYTE_MAPPING @@ -27,12 +24,9 @@ SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, DT_MEASURED, - EARLIEST, - LATEST, ) from backend.source import ( - BaseSource, BaseSiteSource, BaseAnalyteSource, get_terminal_record, diff --git a/backend/connectors/bor/transformer.py b/backend/connectors/bor/transformer.py index 8692b52..4540069 100644 --- a/backend/connectors/bor/transformer.py +++ b/backend/connectors/bor/transformer.py @@ -13,13 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -import json -from backend.record import SiteRecord from backend.transformer import ( - BaseTransformer, - WaterLevelTransformer, SiteTransformer, AnalyteTransformer, ) diff --git a/backend/connectors/ckan/source.py b/backend/connectors/ckan/source.py index 680c914..4297f63 100644 --- a/backend/connectors/ckan/source.py +++ b/backend/connectors/ckan/source.py @@ -15,7 +15,6 @@ # =============================================================================== from itertools import groupby -import httpx from backend.connectors import ( OSE_ROSWELL_HONDO_BOUNDING_POLYGON, @@ -34,7 +33,6 @@ from backend.constants import ( FEET, DTW, - DTW_UNITS, DT_MEASURED, PARAMETER_NAME, PARAMETER_UNITS, @@ -43,7 +41,6 @@ SOURCE_PARAMETER_UNITS, ) from backend.source import ( - BaseSource, BaseSiteSource, BaseWaterLevelSource, get_terminal_record, diff --git a/backend/connectors/ckan/transformer.py b/backend/connectors/ckan/transformer.py index e9ba1ee..48a1c1d 100644 --- a/backend/connectors/ckan/transformer.py +++ b/backend/connectors/ckan/transformer.py @@ -13,15 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -from backend.record import SiteRecord -from backend.transformer import BaseTransformer, WaterLevelTransformer, SiteTransformer +from backend.transformer import WaterLevelTransformer, SiteTransformer class OSERoswellSiteTransformer(SiteTransformer): def _transform(self, record): - # pprint.pprint(record) lat = float(record["DD_lat"]) lng = float(record["DD_lon"]) # if not self.contained(lng, lat): diff --git a/backend/connectors/isc_seven_rivers/source.py b/backend/connectors/isc_seven_rivers/source.py index d456c18..c6f5dce 100644 --- a/backend/connectors/isc_seven_rivers/source.py +++ b/backend/connectors/isc_seven_rivers/source.py @@ -15,7 +15,6 @@ # =============================================================================== from datetime import datetime -import httpx from backend.connectors import ISC_SEVEN_RIVERS_BOUNDING_POLYGON from backend.connectors.mappings import ISC_SEVEN_RIVERS_ANALYTE_MAPPING @@ -28,8 +27,6 @@ PARAMETER_UNITS, SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, - EARLIEST, - LATEST, ) from backend.connectors.isc_seven_rivers.transformer import ( ISCSevenRiversSiteTransformer, @@ -37,7 +34,6 @@ ISCSevenRiversAnalyteTransformer, ) from backend.source import ( - BaseSource, BaseSiteSource, BaseWaterLevelSource, BaseAnalyteSource, diff --git a/backend/connectors/isc_seven_rivers/transformer.py b/backend/connectors/isc_seven_rivers/transformer.py index dddbe9d..3340651 100644 --- a/backend/connectors/isc_seven_rivers/transformer.py +++ b/backend/connectors/isc_seven_rivers/transformer.py @@ -13,12 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import shapely.wkt -from shapely import Point -from backend.record import SiteRecord from backend.transformer import ( - BaseTransformer, WaterLevelTransformer, SiteTransformer, AnalyteTransformer, diff --git a/backend/connectors/nmbgmr/source.py b/backend/connectors/nmbgmr/source.py index 5823bee..766bba5 100644 --- a/backend/connectors/nmbgmr/source.py +++ b/backend/connectors/nmbgmr/source.py @@ -32,8 +32,6 @@ PARAMETER_VALUE, SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, - EARLIEST, - LATEST, ) from backend.source import ( BaseWaterLevelSource, diff --git a/backend/connectors/nmbgmr/transformer.py b/backend/connectors/nmbgmr/transformer.py index db7bf67..8b32c70 100644 --- a/backend/connectors/nmbgmr/transformer.py +++ b/backend/connectors/nmbgmr/transformer.py @@ -13,10 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from backend.constants import DTW -from backend.record import SiteRecord from backend.transformer import ( - BaseTransformer, WaterLevelTransformer, SiteTransformer, AnalyteTransformer, diff --git a/backend/connectors/nmenv/source.py b/backend/connectors/nmenv/source.py index 3ae2170..7252551 100644 --- a/backend/connectors/nmenv/source.py +++ b/backend/connectors/nmenv/source.py @@ -33,7 +33,6 @@ URL = "https://nmenv.newmexicowaterdata.org/FROST-Server/v1.1/" -import sys class DWBSiteSource(STSiteSource): diff --git a/backend/connectors/nmenv/transformer.py b/backend/connectors/nmenv/transformer.py index 80f534a..dde755f 100644 --- a/backend/connectors/nmenv/transformer.py +++ b/backend/connectors/nmenv/transformer.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from backend.connectors.st_connector import STSiteTransformer -from backend.transformer import SiteTransformer, AnalyteTransformer +from backend.transformer import AnalyteTransformer class DWBSiteTransformer(STSiteTransformer): diff --git a/backend/connectors/nmose/transformer.py b/backend/connectors/nmose/transformer.py index 8f26ebb..b519758 100644 --- a/backend/connectors/nmose/transformer.py +++ b/backend/connectors/nmose/transformer.py @@ -1,4 +1,4 @@ -from backend.transformer import BaseTransformer, SiteTransformer +from backend.transformer import SiteTransformer class NMOSEPODSiteTransformer(SiteTransformer): diff --git a/backend/connectors/st2/source.py b/backend/connectors/st2/source.py index ee947f9..3467a6d 100644 --- a/backend/connectors/st2/source.py +++ b/backend/connectors/st2/source.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import datetime from functools import partial from backend.connectors import ( @@ -41,7 +40,6 @@ ) from backend.constants import ( DTW, - DTW_UNITS, DT_MEASURED, PARAMETER_NAME, PARAMETER_VALUE, @@ -49,7 +47,6 @@ SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, ) -from backend.source import BaseSiteSource, BaseWaterLevelSource, get_terminal_record URL = "https://st2.newmexicowaterdata.org/FROST-Server/v1.1" diff --git a/backend/connectors/st2/transformer.py b/backend/connectors/st2/transformer.py index 5417306..4a2a1ca 100644 --- a/backend/connectors/st2/transformer.py +++ b/backend/connectors/st2/transformer.py @@ -13,16 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -import sys from backend.connectors.st_connector import STSiteTransformer -from backend.record import SiteRecord from backend.converter import StandardUnitConverter from backend.transformer import ( - BaseTransformer, WaterLevelTransformer, - SiteTransformer, ) diff --git a/backend/connectors/st_connector.py b/backend/connectors/st_connector.py index 38aeeb3..ed9f10e 100644 --- a/backend/connectors/st_connector.py +++ b/backend/connectors/st_connector.py @@ -20,7 +20,6 @@ from shapely import MultiPolygon, unary_union from backend.bounding_polygons import get_state_polygon -from backend.constants import EARLIEST, LATEST from backend.source import ( BaseSiteSource, BaseWaterLevelSource, diff --git a/backend/connectors/usgs/transformer.py b/backend/connectors/usgs/transformer.py index 35a5ee0..41fa8d5 100644 --- a/backend/connectors/usgs/transformer.py +++ b/backend/connectors/usgs/transformer.py @@ -13,8 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from backend.record import SiteRecord -from backend.transformer import BaseTransformer, WaterLevelTransformer, SiteTransformer +from backend.transformer import WaterLevelTransformer, SiteTransformer class NWISSiteTransformer(SiteTransformer): diff --git a/backend/connectors/wqp/source.py b/backend/connectors/wqp/source.py index 63fc74f..e324fef 100644 --- a/backend/connectors/wqp/source.py +++ b/backend/connectors/wqp/source.py @@ -13,9 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -import httpx from backend.connectors import NM_STATE_BOUNDING_POLYGON from backend.connectors.mappings import WQP_ANALYTE_MAPPING @@ -26,8 +24,6 @@ SOURCE_PARAMETER_NAME, SOURCE_PARAMETER_UNITS, DT_MEASURED, - EARLIEST, - LATEST, TDS, WATERLEVELS, SPECIFIC_CONDUCTANCE, diff --git a/backend/connectors/wqp/transformer.py b/backend/connectors/wqp/transformer.py index a2cf623..9acffbe 100644 --- a/backend/connectors/wqp/transformer.py +++ b/backend/connectors/wqp/transformer.py @@ -13,11 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pprint -from backend.record import SiteRecord from backend.transformer import ( - BaseTransformer, SiteTransformer, AnalyteTransformer, WaterLevelTransformer, @@ -26,7 +23,6 @@ class WQPSiteTransformer(SiteTransformer): def _transform(self, record): - # pprint.pprint(record) provider = record["ProviderName"] rec = { "source": f"WQP/{provider}", diff --git a/backend/converter.py b/backend/converter.py index 42337b7..3c404f1 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import Protocol, runtime_checkable - from backend.constants import ( MILLIGRAMS_PER_LITER, PARTS_PER_MILLION, @@ -26,19 +24,6 @@ ) -@runtime_checkable -class UnitConverter(Protocol): - def convert( - self, - input_value: float, - input_units: str, - output_units: str, - source_parameter_name: str, - die_parameter_name: str, - dt: str | None = None, - ) -> tuple[float, float | None, str]: ... - - class StandardUnitConverter: def convert( self, diff --git a/backend/geo_utils.py b/backend/geo_utils.py index 43b81dd..eb6e850 100644 --- a/backend/geo_utils.py +++ b/backend/geo_utils.py @@ -16,7 +16,6 @@ import pyproj from shapely.ops import transform -PROJECTIONS: dict = {} TRANSFORMS: dict = {} ALLOWED_DATUMS = ["NAD27", "NAD83", "WGS84"] @@ -75,56 +74,4 @@ def datum_transform(x, y, in_datum, out_datum): return lng, lat -def utm_to_lonlat(e, n, zone=13): - """ - Converts easting and northing into longitude and latitude - - Parameters - -------- - e: float - easting - n: float - northing - - Returns - -------- - tuple - (longitude, latitude) - """ - name = f"utm{zone}" - if name not in PROJECTIONS: - pr = pyproj.Proj(proj="utm", zone=int(zone), ellps="WGS84") - PROJECTIONS[name] = pr - pr = PROJECTIONS[name] - lonlat = pr(e, n, inverse=True) - return lonlat - - -def lonlat_to_utm(lon, lat, zone=13): - """ - Converts longitude and latitude into easting and northing - - Parameters - -------- - lon: float - longitude in decimal degrees - lat: float - latitude in decimal degrees - - - Returns - -------- - tuple - (easting, northing) - """ - name = "lonlat" - if name not in PROJECTIONS: - pr = pyproj.Proj(proj="utm", ellps="WGS84", zone=zone) - PROJECTIONS[name] = pr - - pr = PROJECTIONS[name] - easting_northing = pr(lon, lat) - return easting_northing - - # ============= EOF ============================================= diff --git a/backend/logger.py b/backend/logger.py index b07a396..ae23cf0 100644 --- a/backend/logger.py +++ b/backend/logger.py @@ -48,26 +48,6 @@ def make_logger(name: str) -> Logger: return Logger(name) -class Loggable: - """Deprecated — do not subclass. Use make_logger() instead.""" - - def __init__(self): - self.logger = logging.getLogger(self.__class__.__name__) - - def log(self, msg, level=None, fg="yellow", **kwargs): - if level is None: - level = logging.INFO - - click.secho(f"{self.__class__.__name__:40s}{msg}", fg=fg) - self.logger.log(level, msg, **kwargs) - - def warn(self, msg, fg="red", **kwargs): - self.log(msg, fg=fg, level=logging.WARNING, **kwargs) - - def debug(self, msg): - self.log(msg, level=logging.DEBUG, fg="blue") - - def setup_logging(level=None, log_format=None, path=None): # _managed_handlers is mutated in place (clear/append), never reassigned, # so no `global` declaration is needed. diff --git a/backend/persisters/geoserver.py b/backend/persisters/geoserver.py index c76fa14..bd73a88 100644 --- a/backend/persisters/geoserver.py +++ b/backend/persisters/geoserver.py @@ -5,11 +5,9 @@ # You may not use this file except in compliance with the License. # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # =============================================================================== -import json -import os import time from itertools import groupby -from typing import Any, Type +from typing import Any from shapely.geometry.multipoint import MultiPoint from shapely.geometry.point import Point from sqlalchemy.dialects.postgresql import JSONB, insert @@ -22,7 +20,6 @@ Column, ForeignKey, create_engine, - UUID, String, Integer, Float, diff --git a/backend/persisters/strategies.py b/backend/persisters/strategies.py index c0dff2b..fe40296 100644 --- a/backend/persisters/strategies.py +++ b/backend/persisters/strategies.py @@ -1,11 +1,5 @@ import io import os -from typing import Protocol - - -class OutputStrategy(Protocol): - def write_bytes(self, path: str, content: bytes) -> None: ... - def make_directory(self, path: str) -> None: ... class LocalFileStrategy: diff --git a/backend/source.py b/backend/source.py index d54a495..cbfe158 100644 --- a/backend/source.py +++ b/backend/source.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from json import JSONDecodeError -from typing import Any, Literal, Optional, Union, List, Callable, Dict, cast +from typing import Any, Optional, Union, List, Callable, Dict, cast import httpx import shapely.wkt @@ -274,20 +274,6 @@ def health(self) -> bool: raise NotImplementedError(f"test not implemented by {self.__class__.__name__}") -class BaseContainerSource(BaseSource): - def __init__(self, *args, **kw): - super().__init__(*args, **kw) - - def check(self): - pass - - def discover(self, *args, **kw): - pass - - def read(self, *args, **kw): - pass - - class BaseSiteSource(BaseSource): chunk_size = 1 bounding_polygon = None @@ -496,8 +482,4 @@ def _extract_source_parameter_units(self, records): return [FEET for _ in records] -class BaseFileSource(BaseSource): - name = "files" - - # ============= EOF ============================================= diff --git a/backend/transformer.py b/backend/transformer.py index 646bd2d..32c89c5 100644 --- a/backend/transformer.py +++ b/backend/transformer.py @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import click -import pprint from datetime import datetime, date, timedelta import shapely @@ -22,13 +20,8 @@ from backend.bounding_polygons import NM_BOUNDARY_BUFFERED from backend.constants import ( - MILLIGRAMS_PER_LITER, - PARTS_PER_MILLION, - PARTS_PER_BILLION, FEET, METERS, - TONS_PER_ACRE_FOOT, - MICROGRAMS_PER_LITER, DT_MEASURED, DTW, EARLIEST, @@ -120,19 +113,6 @@ def transform_length_units( return value, out_unit -def convert_units( - input_value: int | float | str, - input_units: str, - output_units: str, - source_parameter_name: str, - die_parameter_name: str, - dt: str | None = None, -) -> tuple[float, float | None, str]: - """Deprecated: use StandardUnitConverter().convert() instead.""" - from backend.converter import StandardUnitConverter - return StandardUnitConverter().convert(float(input_value), input_units, output_units, source_parameter_name, die_parameter_name, dt) - - def standardize_datetime(dt, record_id): if isinstance(dt, tuple): dt = [di for di in dt if di is not None] diff --git a/backend/unifier.py b/backend/unifier.py index 739f6a7..a52f0c6 100644 --- a/backend/unifier.py +++ b/backend/unifier.py @@ -15,8 +15,8 @@ # =============================================================================== import shapely -from backend.config import Config, get_source, OutputFormat -from backend.logger import make_logger, setup_logging +from backend.config import Config, get_source +from backend.logger import make_logger _log = make_logger("unifier") from backend.constants import WATERLEVELS @@ -270,17 +270,6 @@ def _unify_parameter( persister.finalize(config.output_name) -def get_sources_in_polygon(polygon): - # polygon = shapely.wkt.loads(polygon) - sources = get_sources() - rets = [] - for source in sources: - _log.log(str(source)) - if source.intersects(polygon): - rets.append(source.tag) - return rets - - def get_county_bounds(county): config = Config() config.county = county @@ -330,84 +319,4 @@ def get_sources(config=None): return sources -def generate_site_bounds(): - source = get_source("bernco") - source.generate_bounding_polygon() - - -def analyte_unification_test(): - cfg = Config() - cfg.county = "chaves" - cfg.county = "eddy" - - cfg.analyte = "TDS" - cfg.output_summary = True - - # analyte testing - cfg.use_source_wqp = False - # cfg.use_source_nmbgmr = False - cfg.use_source_iscsevenrivers = False - cfg.use_source_bor = False - cfg.use_source_dwb = False - cfg.site_limit = 10 - - unify_analytes(cfg) - - -def waterlevel_unification_test(): - cfg = Config() - cfg.county = "chaves" - # cfg.county = "eddy" - # cfg.bbox = "-104.5 32.5,-104 33" - # cfg.start_date = "2020-01-01" - # cfg.end_date = "2020-5-01" - cfg.output_summary = False - cfg.output_name = "test00112233" - # cfg.output_summary = True - cfg.output_single_timeseries = True - - cfg.use_source_nwis = False - cfg.use_source_nmbgmr = False - cfg.use_source_iscsevenrivers = False - cfg.use_source_pvacd = False - # cfg.use_source_oseroswell = False - cfg.use_source_bernco = False - cfg.use_source_iscsevenrivers = False - cfg.use_source_nmose_isc_seven_rivers = False - cfg.use_source_ebid = False - # cfg.site_limit = 10 - - unify_waterlevels(cfg) - - -def get_datastream(siteid): - import httpx - - resp = httpx.get( - f"https://st2.newmexicowaterdata.org/FROST-Server/v1.1/Locations({siteid})?$expand=Things/Datastreams" - ) - obj = resp.json() - return obj["Things"][0]["Datastreams"][0] - - -def get_datastreams(): - s = get_source("pvacd") - for si in s.read_sites(): - ds = get_datastream(si.id) - _log.log(f"{si} {si.id} {ds['@iot.id']}") - - -# if __name__ == "__main__": -# test_waterlevel_unification() -# root = logging.getLogger() -# root.setLevel(logging.DEBUG) -# shandler = logging.StreamHandler() -# get_sources(Config()) -# setup_logging() -# site_unification_test() -# waterlevel_unification_test() -# analyte_unification_test() -# print(health_check("nwis")) -# generate_site_bounds() - # ============= EOF ============================================= diff --git a/frontend/api/app.py b/frontend/api/app.py index bddd34d..5b7a06e 100644 --- a/frontend/api/app.py +++ b/frontend/api/app.py @@ -15,9 +15,7 @@ # =============================================================================== import hashlib import json -import multiprocessing import os -import time from typing import Optional from fastapi import FastAPI, HTTPException diff --git a/orchestration/assets/wells.py b/orchestration/assets/wells.py deleted file mode 100644 index e62acca..0000000 --- a/orchestration/assets/wells.py +++ /dev/null @@ -1,47 +0,0 @@ -import tempfile -from pathlib import Path - -import dagster as dg - -from backend.unifier import unify_sites -from backend.persisters.ogc_features import dump_summary_collection -from orchestration.resources.die_config import DIEConfigResource -from orchestration.resources.gcs import GCSResource - - -def build_wells_asset(product: dict): - @dg.asset(name=product["id"], group_name="wells") - def _wells_asset( - die_config: DIEConfigResource, - gcs: GCSResource, - ) -> dg.MaterializeResult: - config = die_config.get_config(product) - config.sites_only = True - - with tempfile.TemporaryDirectory() as tmpdir: - unify_sites(config) - - # Collect sites from persister (set by unify_sites via _unify_parameter) - from backend.persister import BasePersister - sites = config._persister.sites if hasattr(config, "_persister") else [] - - out = Path(tmpdir) / "collection.geojson" - meta = { - "id": product["id"], - "title": product.get("title", product["id"]), - "description": product.get("description", ""), - } - dump_summary_collection(str(out), sites, meta) - - info = gcs.upload_product(str(out), product["id"]) - - return dg.MaterializeResult( - metadata={ - "feature_count": dg.MetadataValue.int(info["feature_count"]), - "dated_uri": dg.MetadataValue.url(info["dated_uri"]), - "latest_uri": dg.MetadataValue.url(info["latest_uri"]), - "file_size_bytes": dg.MetadataValue.int(info["file_size_bytes"]), - } - ) - - return _wells_asset diff --git a/orchestration/resources/gcs.py b/orchestration/resources/gcs.py index 0400033..f9a1ae9 100644 --- a/orchestration/resources/gcs.py +++ b/orchestration/resources/gcs.py @@ -1,4 +1,3 @@ -import os from datetime import datetime, timezone from pathlib import Path from typing import Optional From 6de6df3caaa248e3cdf79408f5c814427ae76d3f Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 24 Jun 2026 16:00:45 -0600 Subject: [PATCH 126/126] Gate Dagster+ deploy on credential presence The serverless deploy failed hard (Invalid URL '/graphql') whenever the DAGSTER_CLOUD_URL / DAGSTER_CLOUD_API_TOKEN secrets are unset, blocking every PR. Add a guard step so the deploy is skipped (green) when the secrets are absent, and only runs once they are configured. Co-Authored-By: Claude Opus 4.8 --- .../workflows/dagster-cloud-branch-deployments.yml | 11 +++++++++++ .github/workflows/dagster-cloud-deploy.yml | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/dagster-cloud-branch-deployments.yml b/.github/workflows/dagster-cloud-branch-deployments.yml index 34f38e3..005a063 100644 --- a/.github/workflows/dagster-cloud-branch-deployments.yml +++ b/.github/workflows/dagster-cloud-branch-deployments.yml @@ -22,8 +22,19 @@ jobs: outputs: build_info: ${{ steps.parse-workspace.outputs.build_info }} steps: + - name: Check Dagster+ credentials present + id: guard + run: | + if [ -n "${{ secrets.DAGSTER_CLOUD_URL }}" ] && [ -n "${{ secrets.DAGSTER_CLOUD_API_TOKEN }}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Dagster+ secrets (DAGSTER_CLOUD_URL / DAGSTER_CLOUD_API_TOKEN) not set; skipping deploy." + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Prerun Checks id: prerun + if: steps.guard.outputs.enabled == 'true' uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1 - name: Launch Docker Deploy diff --git a/.github/workflows/dagster-cloud-deploy.yml b/.github/workflows/dagster-cloud-deploy.yml index 7dd39ad..6a9fd6f 100644 --- a/.github/workflows/dagster-cloud-deploy.yml +++ b/.github/workflows/dagster-cloud-deploy.yml @@ -25,8 +25,19 @@ jobs: outputs: build_info: ${{ steps.parse-workspace.outputs.build_info }} steps: + - name: Check Dagster+ credentials present + id: guard + run: | + if [ -n "${{ secrets.DAGSTER_CLOUD_URL }}" ] && [ -n "${{ secrets.DAGSTER_CLOUD_API_TOKEN }}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Dagster+ secrets (DAGSTER_CLOUD_URL / DAGSTER_CLOUD_API_TOKEN) not set; skipping deploy." + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Prerun Checks id: prerun + if: steps.guard.outputs.enabled == 'true' uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1 - name: Launch Docker Deploy