Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
### 2026-01-22 v1.2.1

Enhancement:

* Added check for expired API token where tokens are used

Updates to align with NEON data updates:

* Updates to variables file for frame file data products
* Update aquatic meteorological redirect to account for Dead Lake site decommissioning


### 2025-10-27 v1.2.0

Bug fixes:
Expand Down
Binary file added dist/neonutilities-1.2.1-py3-none-any.whl
Binary file not shown.
Binary file added dist/neonutilities-1.2.1.tar.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "neonutilities"
version = "1.2.0"
version = "1.2.1"
authors = [
{name="Claire Lunch", email="clunch@battelleecology.org"},
{name="Bridget Hass", email="bhass@battelleecology.org"},
Expand Down
1 change: 1 addition & 0 deletions src/neonutilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .tabular_download import zips_by_product
from .get_issue_log import get_issue_log
from .read_table_neon import read_table_neon
from .helper_mods.api_helpers import token_date
from .unzip_and_stack import (
stack_by_table,
load_by_product,
Expand Down
162 changes: 87 additions & 75 deletions src/neonutilities/__resources__/frame_file_variables.csv

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/neonutilities/aop_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
# local imports
from . import __resources__
from .helper_mods.api_helpers import get_api
from .helper_mods.api_helpers import token_check
from .helper_mods.api_helpers import download_file, calculate_crc32c
from .helper_mods.metadata_helpers import convert_byte_size
from .get_issue_log import get_issue_log
Expand Down Expand Up @@ -875,6 +876,10 @@ def by_file_aop(
if token == "":
token = None

# check for expired token
if token is not None:
token = token_check(token)

# query the products endpoint for the product requested
response = get_api("https://data.neonscience.org/api/v0/products/" + dpid, token)

Expand Down Expand Up @@ -1395,6 +1400,10 @@ def by_tile_aop(
if token == "":
token = None

# check for expired token
if token is not None:
token = token_check(token)

# query the products endpoint for the product requested
response = get_api("https://data.neonscience.org/api/v0/products/" + dpid, token)

Expand Down
2 changes: 2 additions & 0 deletions src/neonutilities/helper_mods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
from .api_helpers import get_zip_urls
from .api_helpers import download_urls
from .api_helpers import download_file
from .api_helpers import token_date
from .api_helpers import token_check
from .metadata_helpers import get_recent
74 changes: 72 additions & 2 deletions src/neonutilities/helper_mods/api_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import re
import os
import time
import base64
import ast
import platform
import importlib.metadata
import logging
Expand All @@ -25,15 +27,83 @@
usera = f"neonutilities/{vers} Python/{plat} {osplat}"


def token_date(token, rval="string"):
"""

Find the expiration date of a user-specific API token generated within data.neonscience.org user accounts.

Parameters
--------
token: User specific API token (generated within data.neonscience.org user accounts).
rval: Should returned value be a string or a time value? Defaults to string.

Return
--------
Date of expiration in local time

Created on Jan 13 2026

@author: Claire Lunch
"""

splittoken = token.split(".")
# + "===" added here as "padding" required to make all tokens interpretable
dubsplit = base64.urlsafe_b64decode(splittoken[1] + "===")
dictsplit = ast.literal_eval(dubsplit.decode("utf-8"))
if "exp" in dictsplit.keys():
expsplit = dictsplit["exp"]
if rval=="string":
expdate = time.asctime(time.localtime(expsplit))
return(expdate)
else:
return(expsplit)
else:
logging.info("No expiration date found for API token.")
return None


def token_check(token):
"""

Check whether a NEON API token has expired.

Parameters
--------
token: User specific API token (generated within data.neonscience.org user accounts).

Return
--------
The original token, if unexpired. If expired, return None.

Created on Jan 13 2026

@author: Claire Lunch
"""

try:
expdate = token_date(token, rval="time")
except Exception:
logging.info("API token expiration date could not be determined.")
return(token)

if(expdate is None):
return(token)
else:
if time.time() > expdate:
logging.info("API token has expired. Function will proceed using public access rate. Go to your NEON user account to generate a new token.")
token = None
return(token)


def get_api(api_url, token=None):
"""

Accesses the API with options to use the user-specific API token generated within neon.datascience user accounts.
Accesses the API with options to use the user-specific API token generated within data.neonscience.org user accounts.

Parameters
--------
api_url: The API endpoint URL.
token: User specific API token (generated within neon.datascience user accounts). Optional.
token: User specific API token (generated within data.neonscience.org user accounts). Optional.

Return
--------
Expand Down
13 changes: 8 additions & 5 deletions src/neonutilities/read_table_neon.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ def get_variables(v):
]:
typ = pa.timestamp("s", tz="UTC")
else:
if v.pubFormat[i] in ["yyyy-MM-dd(floor)", "yyyy-MM-dd"]:
typ = pa.date64()
if v.pubFormat[i] in ["yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]:
typ = pa.timestamp("ms", tz="UTC")
else:
if v.pubFormat[i] in ["yyyy(floor)", "yyyy(round)"]:
typ = pa.int64()
if v.pubFormat[i] in ["yyyy-MM-dd(floor)", "yyyy-MM-dd"]:
typ = pa.date64()
else:
typ = pa.string()
if v.pubFormat[i] in ["yyyy(floor)", "yyyy(round)"]:
typ = pa.int64()
else:
typ = pa.string()
if i == 0:
vschema = pa.schema([(nm, typ)])
else:
Expand Down
35 changes: 27 additions & 8 deletions src/neonutilities/tabular_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pandas as pd
import logging
from .helper_mods.api_helpers import get_api
from .helper_mods.api_helpers import token_check
from .helper_mods.api_helpers import get_zip_urls
from .helper_mods.api_helpers import get_tab_urls
from .helper_mods.api_helpers import download_urls
Expand Down Expand Up @@ -57,6 +58,10 @@ def query_files(

adict = lst["data"]["siteCodes"]
releasedict = {}

# check for expired token
if token is not None:
token = token_check(token)

# check expanded package status
if package == "expanded":
Expand Down Expand Up @@ -372,18 +377,28 @@ def zips_by_product(
indx = 0
for s in site:
if s in shared_aquatic_df.index:
ss = shared_aquatic_df.loc[s]
if dpid in list(ss["product"]):
if s in ["BLWA"] and release not in ["RELEASE-2021","RELEASE-2022","RELEASE-2023","RELEASE-2024","RELEASE-2025"]:
indx = indx + 1
sx = list(ss["towerSite"][ss["product"] == dpid])
siter.append(sx)
if "DELA" not in site:
siter.append(["DELA"])
if indx == 1:
logging.info(
f"Some NEON sites in your download request are aquatic sites where {dpid} is collected at a nearby terrestrial site. The sites you requested, and the sites that will be accessed instead, are listed below."
)
logging.info(f"{s} -> {''.join(sx)}")
)
logging.info("Until the fall of 2025, meteorological data for BLWA were collected at DELA. Data collection at DELA ended in late 2025 and the meteorological station was relocated to BLWA. If your download request crosses this time period, data will be downloaded from each site for the time period when they are available.")
else:
siter.append([s])
ss = shared_aquatic_df.loc[s]
if dpid in list(ss["product"]):
indx = indx + 1
sx = list(ss["towerSite"][ss["product"] == dpid])
siter.append(sx)
if indx == 1:
logging.info(
f"Some NEON sites in your download request are aquatic sites where {dpid} is collected at a nearby terrestrial site. The sites you requested, and the sites that will be accessed instead, are listed below."
)
logging.info(f"{s} -> {''.join(sx)}")
else:
siter.append([s])
else:
siter.append([s])
siter = sum(siter, [])
Expand Down Expand Up @@ -421,6 +436,10 @@ def zips_by_product(
f"In all NEON releases after {bundle_release}, {''.join(dpid)} has been bundled with {''.join(newDPID)} and is not available independently. Please download {''.join(newDPID)}."
)

# check for expired token
if token is not None:
token = token_check(token)

# end of error and exception handling, start the work
# query the /products endpoint for the product requested
if release == "current" or release == "PROVISIONAL":
Expand Down Expand Up @@ -488,7 +507,7 @@ def zips_by_product(
fls = query_files(
lst=avail,
dpid=dpid,
site=site,
site=siter,
startdate=startdate,
enddate=enddate,
package=package,
Expand Down
5 changes: 5 additions & 0 deletions src/neonutilities/unzip_and_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,11 @@ def dataset_query(
"Table name (tabl=) is a required input to this function."
)

if pubtype in ["TOS Data Product Type","TOS-structured TIS Data Product Type"]:
raise ValueError(
f"{dpid} is an observational data product. hor and ver are not valid inputs for this data product type."
)

if pubtype in ["TIS Data Product Type","AIS Data Product Type"]:
if dpid in ["DP4.00200.001",
"DP1.00007.001","DP1.00010.001","DP1.00034.001","DP1.00035.001",
Expand Down
5 changes: 4 additions & 1 deletion tests/test_get_citation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# import required packages
import os
from datetime import date
from src.neonutilities.citation import get_citation

# read in token from os.environ
Expand All @@ -25,7 +26,9 @@ def test_get_citation_provisional():
Test that the get_citation() function returns the expected citation for provisional data
"""
cit = get_citation(dpid="DP1.10003.001", release="PROVISIONAL")
citexp = "@misc{DP1.10003.001/provisional,\n doi = {},\n url = {https://data.neonscience.org/data-products/DP1.10003.001},\n author = {{National Ecological Observatory Network (NEON)}},\n language = {en},\n title = {Breeding landbird point counts (DP1.10003.001)},\n publisher = {National Ecological Observatory Network (NEON)},\n year = {2025}\n}"
dt = date.today()
year = dt.year
citexp = "@misc{DP1.10003.001/provisional,\n doi = {},\n url = {https://data.neonscience.org/data-products/DP1.10003.001},\n author = {{National Ecological Observatory Network (NEON)}},\n language = {en},\n title = {Breeding landbird point counts (DP1.10003.001)},\n publisher = {National Ecological Observatory Network (NEON)},\n year = {" + str(year) + "}\n}"
assert cit == citexp


Expand Down