Skip to content

Generalized functions for getting, setting, and finding environment variables - #798

Merged
elenya-grant merged 38 commits into
NatLabRockies:developfrom
elenya-grant:generalize_api
Jul 24, 2026
Merged

Generalized functions for getting, setting, and finding environment variables#798
elenya-grant merged 38 commits into
NatLabRockies:developfrom
elenya-grant:generalize_api

Conversation

@elenya-grant

@elenya-grant elenya-grant commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Generalized functions for getting, setting, and finding environment variables

Generalized the functions in h2integrate/resource/utilities/nlr_developer_api_keys.py for getting, finding, and setting environment variables. The functions in nlr_developer_api_keys.py were only usable for getting, finding, and setting environment variables for downloading resource data from NLR API calls.

The generalized functions now live in h2integrate/core/env_tools.py. The functions for NLR API calls have been updated to use these generalized functions.

This PR will make it easier for new environment variables to be introduced and used across models and tools without having to duplicate a bunch of code. Another benefit of this PR is that is has introduced more thorough testing for these methods.

A future PR could utilize this functionality to check if RESOURCE_DIR is specified as an environment variable.

Major shoutout to @RHammond2 who helped a lot with making this code an improvement on the existing API functionality and gave really great feedback throughout!

Section 1: Type of Contribution

  • Feature Enhancement
    • Framework
    • New Model
    • Updated Model
    • Tools/Utilities
    • Other (please describe):
  • Bug Fix
  • Documentation Update
  • CI Changes
  • Other (please describe):

Section 2: Draft PR Checklist

  • Open draft PR
  • Describe the feature that will be added
  • Fill out TODO list steps
  • Describe requested feedback from reviewers on draft PR
  • [-] Complete Section 7: New Model Checklist (if applicable)

TODO:

  • Add tests for cases where some environment variables are set as environment variables and some need to be loaded from a file
  • finish making changes based on Rob's feedback

Type of Reviewer Feedback Requested (on Draft PR)

Structural feedback:

Implementation feedback:

  • Is there a case where an environment variable will not be a string or wouldn't be usable in string format? All the methods have logic checking if the length of the environment variable value is zero, but this logic would break if the environment variable is set to a float, integer, or boolean. I think that I'd prefer to keep the assumption that environment variables are strings because most of the loading or getting environment variable methods will return the environment variable as a string. (Depending on the responses to this, I may make a follow-on issue but will not integrate the ability for these methods to handle non-strings in this PR)

Section 3: General PR Checklist

  • PR description thoroughly describes the new feature, bug fix, etc.
  • Added tests for new functionality or bug fixes
  • Tests pass (If not, and this is expected, please elaborate in the Section 6: Test Results)
  • Documentation
    • Docstrings are up-to-date
    • Related docs/ files are up-to-date, or added when necessary
    • Documentation has been rebuilt successfully
    • [-] Examples have been updated (if applicable)
  • CHANGELOG.md
    • At least one complete sentence has been provided to describe the changes made in this PR
    • After the above, a hyperlink has been provided to the PR using the following format:
      "A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where
      XYZ should be replaced with the actual number.

Section 4: Related Issues

This is intended to resolve Issue #765

Issue #802 was created to note possible follow-on work that could be done once this PR is merged in!

Issue #811 was created to document some broader reaching reviewer feedback that could be resolved in a follow-on PR and should be discussed within the team.

Section 5: Impacted Areas of the Software

Section 5.1: New Files

  • docs/misc_resources/debugging_environment_variables.md: new doc page that outlines ways to debug environment variables or set them prior to running H2I
  • h2integrate/core/env_tools.py
    • get_environment_variables(): main method that is used to return the key/value pairs of specified environment variables where the environment variable value was found (and set any environment variables if specified).
    • load_env_vars_from_file(): loads all environment variables from a file, flexible to different separators
    • set_env_var(): method that sets values of environment variables from a dictionary
  • h2integrate/core/test/test_env_tools.py: new test file for the functions in h2integrate/core/env_tools.py

Section 5.2: Modified Files

  • h2integrate/resource/utilities/nlr_developer_api_keys.py: removed old getter/setter methods and global variables, totally updated to use get_environment_variables()
    • get_nlr_developer_api_credential: general method to get either nlr api key or email
    • get_nlr_developer_api_email: updated to wrap get_nlr_developer_api_credential to get API email
    • get_nlr_developer_api_key: updated to wrap get_nlr_developer_api_credential` to get API key
  • docs/getting_started/environment_variables.md: reformatted/updated to include the EIA API info.

Section 6: Additional Supporting Information

Section 7: Test Results, if applicable

@johnjasa
johnjasa self-requested a review July 7, 2026 19:52
@elenya-grant elenya-grant added the ready for review This PR is ready for input from folks label Jul 8, 2026
@elenya-grant
elenya-grant requested a review from RHammond2 July 9, 2026 18:23

@RHammond2 RHammond2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elenya-grant I think this is a great step in the right direction, especially with moving much of the functionality out of the resource subpackage!

I have two significant hangups with the current draft: 1) we should generally avoid using global variables as a best practice, and 2) much of the refactored code is only lightly refactored, leaving it still highly geared towards an NLR credential getting/setting process. The second point results in this solution still being overly complex for general use.

I think that the core code could be something more like the following example that largely mirrors your setup (I've got a few mismatched names for expediency), but is more flexible to non-NLR credential getting and setting in addition to simplifying the NLR setup. I would also imagine using the module as from h2integrate.core.env_tools as api so we could invoke usage such as api.get_credentials("NLR_API_EMAIL", "NLR_API_KEY").

Another added benefit is that instead of a developer being required to do 3 steps to interact with API settings, the below reduces is it to simply calling api.set_env_var_from_file(".newapirc", "NEW_EMAIL", "NEW_KEY"). Later, if needed the credentials could be extracted as new_email = os.environ["NEW_EMAIL"] or new_email = api.get_credentials("NEW_EMAIL"). Alternatively, they could simply be retrieved as part of the module setup.

To be clear, I don't believe my code needs to be taken exactly as written as it's a prototype, but the straightforwardness
of the solution should be considered to resolve the heart of #765.

import os
import warnings
from pathlib import Path
from h2integrate import ROOT_DIR

HOME_DIR = Path.home()

NREL_DEPRECATION_MSG = (
    "The '{old}' environment variable is deprecated and will be removed in a future release. "
    "Please use '{new}' instead. The nrel.gov API domain has moved to nlr.gov."
)


def read_config(file_path: Path) -> dict:
    """Load any dictionary-like key, value pairs from a configuration file (e.g. .env or .cdsapirc)
    that uses either a ``key:value` or `key=value` format for storing data.

    Args:
        file_path (Path): The full file path and name containing configuration details to be
            extracted.

    Returns:
        dict: Dictionary of key, value pairs found in :py:attr:`file_path`.
    """
    if isinstance(file_path, str):
        file_path = Path(file_path).resolve()
    config = {}
    with file_path.open("r") as f:
        for line in f.readlines():
            if ":" in line:
                sep = ":"
            elif "=" in line:
                sep = "="
            k, v = line.strip().split(sep, 1)
            config[k.strip()] = v.strip()
    return config


def get_credentials(*args: str, file_name: str | None = None) -> dict:
    """Retrieve a series of credentials from a :py:attr:`file_name` in either the home directory
    or H2Integrate root directory.

    Args:
        args (str): Name(s) of the credential(s) that should be retrieved from either
            :py:attr:`file_name` or environment variables.
        file_name (str, optional): The name of a configuration file found in either the H2Integrate
            root directory or the user's home directory that should contain the credential(s) in
            :py:attr:`args`.

    Returns:
        dict: Dictionary of all :py:attr:`args` with values of either a found value or None.
    """
    if file_name is not None:
        if (file_path := (ROOT_DIR / file_name)).is_file():
            config = read_config(file_path)
        elif (file_path := (HOME_DIR / file_name)).is_file():
            config = read_config(file_path)
        return {name: config.get(name) for name in args}
    return {name: os.environ.get(name) for name in args}


def set_env_var_from_file(file_name: str, *args: str):
    """Sets a series of environment variables from a file base in the H2Integrate root directory
    or the user's home directory.

    Args:
        file_name (str): Name of the file found in either the H2Integrate root directory or the
            user's home directory that contains the variables to be set for the computational
            environment.
        args (str): Name(s) of the credentials to retrieve from :py:attr:`file_name`.

    Raises:
        KeyError: Raised if any of :py:attr:`args` wasn't found in :py:attr:`file_name`.
    """
    credentials = get_credentials(*args, file_name=file_name)
    for name, value in credentials.items():
        if value is None and os.environ.get(name) is None:
            raise KeyError(f"No credential for {name} was found in '{file_name}'")
        os.environ[name] = value


def get_nlr_api_credential(which: str) -> str:
    """Get either the NLR API email or key with a fallback for the NREL credentials.

    Args:
        which (str): One of "email" or "key" to indicate which NLR API credential should be
            retrieved.

    Raises:
        ValueError: Raised if an invalid value was passed to :py:attr:`which`.
        KeyError: Raised if neither of the NLR or NREL credentials could be found.
    """
    if which not in ("email", "key"):
        raise ValueError("`which` must be one of 'email' or 'key'.")
    new_name = f"NLR_API_{which.upper()}"
    old_name = f"NREL_API_{which.upper()}"
    credentials = get_credentials(new_name, old_name, file_name=".env")
    if (email := credentials[new_name]) is None:
        if (email := credentials[old_name]) is None:
            msg = (
                f"No credential matching {new_name} or {old_name} found as an environment variable"
                " nor the '.env' configuration file in the user home directory or H2Integrate root"
                " directory."
            )
            raise KeyError(msg)
        warnings.warn(
            NREL_DEPRECATION_MSG.format(old=old_name, new=new_name),
            FutureWarning,
            stacklevel=3,
        )
    return email


def set_nlr_api_credential(which: str):
    """Set an NLR API email or key with a fallback for the NREL credentials.

    Args:
        which (str): One of "email" or "key" to indicate which NLR API credential should be
            retrieved.

    Raises:
        ValueError: Raised if an invalid value was passed to :py:attr:`which`.
    """
    if which not in ("email", "key"):
        raise ValueError("`which` must be one of 'email' or 'key'.")
    os.environ[f"NLR_API_{which.upper()}"] = get_nlr_api_credential(which=which)

To answer one of your questions, I think the env_tools.py should be api_credentials.py or something with either "api" or "credentials" or similar in the name to indicate we're working specifically with API credentials. I think "env_tools.py" would be assumed to be coding environments more often than environment variables. The environment variables is also only one piece of the puzzle API credentials puzzle.

@elenya-grant

elenya-grant commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Responding to this comment from @RHammond2:

First of all, thank you for the very helpful and thoughtful comment, this is very helpful in understanding your vision for resolving Issue #765. I like some of the added functionality that your approach introduces and I think I could get that into this PR (such as getting variables from a dictionary type formatted file). If I'm understanding your proposed code correctly, then I think that some of the existing functionality will be lost and other changes to the code may be needed (and some of this is related to the use of global variables). I'm also concerned about any lost functionality because it could mess with people's workflows. My understanding is that your proposed code would basically be used instead of the functions I have in env_tools.py - is that right?

I'm going to try to be concise and I'm sorry if it doesn't make sense! When I originally made the NLR API methods for the resource models, I was trying to make it so that finding/loading/setting/getting environment variables was as user-friendly as possible and would work for a variety of workflows and ways of setting environment variables while minimizing complexity within the resource models themselves.

Quick summary of the current (before this PR) functionality with the NLR API credentials and my understanding of it:

  • If a user sets the environment variable using the preferred method then we wouldn't need to use global variables, this is easy because it doesnt require a read-file thing. As long as a user has done this type of set-up, the environment variable value is easily accessed with os.getenv("NLR_API_KEY") and the user does not have to run set_nlr_key_dot_env() at the start of each script. So when get_nlr_developer_api_key() is called within a resource model, it returns the environment variable without ever calling set_nlr_key_dot_env()
  • If a user has the environment variables in a .env file thats in one of the possible locations (cwd/".env", ROOT_DIR/".env", or ROOT_DIR.parent/".env) and the environment variables haven't already been loaded/set (which is usually the case) then the set_nlr_key_dot_env() is called within get_nlr_developer_api_key() to first set the variable before returning it. This also wouldn't require the use of global variables.

The reason we're using global variables (and maybe we could use environment variables instead or something) is to accommodate for other user-workflow/preference cases. For example, a user is using a .env file to define the environment variables and that .env file is not in one of the "default" locations. If they were to try to run H2I with a resource model that needs the NLR_API_KEY, it would not be able to find the API key and the run would fail if their run script looked like this:

from h2integrate import H2IntegrateModel
h2i = H2IntegrateModel("config.yaml")
h2i.setup()
h2i.run()

Since the path to their ".env" file cannot be input to the resource models themselves, they need to instead call the setter method ahead of time so that the environment variables are set before the resource models call get_nlr_developer_api_key()

from h2integrate import H2IntegrateModel
from h2integrate.resource.utilities.nlr_developer_api_keys import set_nlr_key_dot_env
env_path = "Users/my/nonstandard/place/for/.env"
set_nlr_key_dot_env(path=env_path)
h2i = H2IntegrateModel("config.yaml")
h2i.setup()
h2i.run()

Basically, the get_api_key() style functions that are called in resource models are set-up so that a user doesnt have to call set_nlr_key_dot_env in every run-script if they do the preferred way of setting environment variables or they do it in the way thats instructed in the documentation (maybe except for the .yaml way - Idk how that works). But the set_nlr_key_dot_env is available so that folks can run resource models in H2I even if their workflow isn't one of the default supported ways of doing it.

Responses to other stuff:

I imagine that environment variables may be used to include more than just API credentials. Environment variables allow for folks to set a) info that shouldn't be shared and b) info that is specific to their workflow. For example, the RESOURCE_DIR can also be set as an environment variable and that'd be used instead of DEFAULT_RESOURCE_DIR (Issue #802). This is why I named things with env instead of api.

Related to above, I think that the read_config method may have issues if someone is using a Windows folder path as the environment variable value (RESOURCE_DIR = C:\\Users\whatever) because it'd split the filepath at :.

Perhaps some of my push-back is because I'm misinterpreting your comment "it still highly geared towards an NLR credential getting/setting process". Do you mean specific to the NLR_API_KEY and NLR_API_EMAIL variables used for resource data downloads OR do you mean like some workflow that is common at NLR that isn't common elsewhere? I think the only NLR-specific part of my proposed changes is the depreciation method warning.

Let me know if you want to hop on a call and chat about through some of this! I would be down to integrate a form of the read_config method you shared! Thanks again for the thoughtful comment!

@RHammond2

Copy link
Copy Markdown
Collaborator

Thanks for the all the additional context, @elenya-grant, it's quite helpful to understand where some of the naming is coming from! I'm going to respond below to all your comments/responses as encountered, so apologies if this reads a little akwardly. I'll immediately contradict that sentiment by addressing an item from the end first: let's keep you original thinking with an "env"-focused name instead of my thoughts on the "api"-based name.

The naming of functions in my example code could easily be changed, they are merely what made sense of demonstrating the level of simplicity/limited scope I meant when discussing this refactor previously.

As for global variables, I don't believe they should be used at all, particularly because they are generally discouraged as a best practice. I also didn't realize the existing implementation used them at all until I just double checked. I think if we're trying to be able to just trying to make access easier, then at the bottom of env_tools.py, we could do something similar to the following (using my prototyped code naming for consistency). However, I also think that based on a later comment about custom paths, any module needing to ensure the environment variables are set should actually just be running that code rather than tacking an initial setter onto the bottom of env_tools.py. This also means importing from env_tools.py doesn't run any setters or getters, rendering the following unnecessary, but I also think it's best if no NLR environment variables are set on import.

# env_tools.py
# all preceding code, including function definitions

set_nlr_api_credential()
NLR_API_EMAIL = get_nlr_api_credential("email")
NLR_API_KEY = get_nlr_api_credential("key")

# some other module
from h2integrate import NLR_API_EMAIL, NLR_API_KEY

My opinion is also that we can take the lead from how most other APIs enforce the placement of their API credential files (when environment variables are not set) and simply error out as opposed to allowing for arbitrary environment files without users explicitly defining this in a custom setup. As you suggested, users can easily avoid this by configuring their environments using the already provided directions, or simply setting them independently as is shown in your response.

If that's less than desirable, then I think the credential getter I proposed could simply be updated to the following.

def get_credentials(*args: str, file_name: str | None = None, file_path: str | Path | None = None) -> dict:
    """Retrieve a series of credentials from a :py:attr:`file_name` in either the home directory
    or H2Integrate root directory. If `:py:attr:`file_path` is provided, then :py:attr:`file_name`
    and already set environment variables will be ignored. If :py:attr:`file_name` is provided, then
    already set environment variables will be ignored. If neither file options are used, then an
    existing environment variable will be retrieved.

    Args:
        args (str): Name(s) of the credential(s) that should be retrieved from either
            :py:attr:`file_name` or environment variables.
        file_name (str, optional): The name of a configuration file found in either the H2Integrate
            root directory or the user's home directory that should contain the credential(s) in
            :py:attr:`args`.
        file_path (str | Path, optional): The full file path for where the configuration file can be
        found if not using the H2Integrate root directory or user home directory

    Returns:
        dict: Dictionary of all :py:attr:`args` with values of either a found value or None.
    """
    if file_path is not None:
        file_path = Path(file_path).resolve()
        if file_path.is_file():
            config = read_config(file_path)
            return {name: config.get(name) for name in args}
        raise FileNotFoundError(f"Provided `file_path` is invalid: {file_path}")
    if file_name is not None:
        if (file_path := (ROOT_DIR / file_name)).is_file():
            config = read_config(file_path)
        elif (file_path := (HOME_DIR / file_name)).is_file():
            config = read_config(file_path)
        return {name: config.get(name) for name in args}
    return {name: os.environ.get(name) for name in args}

To extend this, we could also add an overwrite flag to the environment variable setter, so that if the variable already exists, no further code will be run.

def set_env_var(*, overwrite: bool = False, **kwargs: str):
    """Set or overwrite environment variables.
    Args:
        overwrite (bool, optional): Indicator to overwrite existing environment variables provided
            in :py:attr:`kwargs`. Defaults to False.
        kwargs (str): name and value of environment variables to set. If :py:attr:`overwrite` is
            False, the value will be skipped.
    """
    for name, value in kwargs.items():
        if os.environ.get(name) is not None and not overwrite:
            continue
        os.environ[name] = value


def set_env_var_from_file(file_name: str, *args: str, *, overwrite: bool = False):
    """Sets a series of environment variables from a file base in the H2Integrate root directory
    or the user's home directory.

    Args:
        file_name (str): Name of the file found in either the H2Integrate root directory or the
            user's home directory that contains the variables to be set for the computational
            environment.
        args (str): Name(s) of the credentials to retrieve from :py:attr:`file_name`.

    Raises:
        KeyError: Raised if any of :py:attr:`args` wasn't found in :py:attr:`file_name`.
    """
    credentials = get_credentials(*args, file_name=file_name)
    for name, value in credentials.items():
        if value is None and os.environ.get(name) is None:
            raise KeyError(f"No credential for {name} was found in '{file_name}'")
    set_env_var(overwrite=overwrite, **credentials)

read_config shouldn't have any issues as Pathlib.home() is used to create the HOME_DIR, which is also showcased in this SO answer on a Windows environment.

Regarding my comment about the code being heavily geared towards NLR credentials, this is coming from the fact that
every function takes varname_new and varname_old, a use case only really needed for the NLR developer API credentials. This need was also why I used *args and **kwargs (above not in original prototype) so that the NLR-specific getters and setters could still check for both.

@elenya-grant

Copy link
Copy Markdown
Collaborator Author

Responding to this comment from @RHammond2

Thanks for the additional clarification and more context on your thoughts. Based on your feedback, I see 3-4 immediate changes that I could make to the code to align with your comments:

  1. Add the read_config function to read environment variables from a file
  2. Remove usage of global variables and use environment variables instead
  3. Utilize args and kwargs to make the code more generalized (so not as geared towards allowing multiple variable names)
  4. Integrate something like the get_credentials function

My thoughts on the generalize flow are listed below:

  • There are 2 different ways to load environment variables from a file, one uses load_dotenv() and one uses a version of read_config (basically a read file). Maybe the use of load_dotenv can be removed (load_dotenv basically reads a file and then sets the environment variables). These don't throw errors. Lets assume we just use a version of read_config() for simplicity and no longer use load_dotenv
    • Should this be called for a specific environment variable or should it load all environment variables in that file
    • Should the environment variable(s) be set by calling set_env_var() within read_config() or should read_config just return the dictionary of variables?
  • The main get_credentials() method does the following logic:
    • checks if the environment variable already exists, if it does, return the value for it
    • call read_config for "default" file locations of the environment file. If the environment variable is found, then set the environment variable if it's not set in read_config().
    • I think get_credentials() should have an input toggle to turn on/off error throwing so that it can be used for deprecated key names. For example:
api_key = get_credentials("NLR_API_KEY", strict=False) # don't throw error, just try
if api_key is None:
   api_key = get_credentials("NREL_API_KEY", strict=False) # don't throw error, just try
   if api_key is not None: 
      # the deprecated one exists, so throw depreciation warning
      warnings.warn(DEPRECIATION_MSG.format(old="NREL_API_KEY", new="NLR_API_KEY"), FutureWarning, stacklevel=3)
if api_key is None:
   # API key has not been found - have it throw an error in `get_credentials` call
   api_key = get_credentials("NLR_API_KEY", strict=True)   

Finally - if setting environment variables is done in read_config() then I don't think that get_credentials() needs a filepath input argument. If a user has the environment file in a non-default location, then they can set their environment variable(s) in their run script using read_config(). If environment variables are set in get_credentials() and not set in read_config then a user would call get_credentials() in their runscript and get_credentials() should still have the filepath inputs.

Last question: The default folders we look in for the .env file are the CWD, ROOT_DIR, and ROOT_DIR.parent. Should we also check these folders for files named .nlrrc and .h2integraterc?

@elenya-grant
elenya-grant requested a review from RHammond2 July 14, 2026 23:10
Comment thread h2integrate/core/test/test_env_tools.py Outdated
Comment on lines +48 to +79
env_path = temp_dir / ".env"
with env_path.open("w+") as file:
file.write("TEST_CREDENTIAL=my_credential_value\n")

env_vars = load_env_vars_from_file(file_path=env_path)
with subtests.test("Read using = seperator"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"

# add another variable to the file
with env_path.open("a+") as file:
file.write("TEST_CREDENTIAL_B=testing@yahoo.fake\n")

env_vars = load_env_vars_from_file(env_path)
with subtests.test("Two credential with = seperator (TEST_CREDENTIAL)"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
with subtests.test("Two credential with = seperator (TEST_CREDENTIAL_B)"):
assert env_vars["TEST_CREDENTIAL_B"] == "testing@yahoo.fake"

# add a line without a separator to the file and a line using a different separator
with env_path.open("a+") as file:
file.write("TEST_CREDENTIAL_C_IS_A_SENTENCE\nTEST_CREDENTIAL_D : testingValue\n")

env_vars = load_env_vars_from_file(env_path)
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL)"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL_B)"):
assert env_vars["TEST_CREDENTIAL_B"] == "testing@yahoo.fake"
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL_D)"):
assert env_vars["TEST_CREDENTIAL_D"] == "testingValue"
with subtests.test("Empty line and mixed separators (skipped sentence line)"):
extra_vars = set(env_vars) - {"TEST_CREDENTIAL", "TEST_CREDENTIAL_B", "TEST_CREDENTIAL_D"}
assert len(extra_vars) == 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
env_path = temp_dir / ".env"
with env_path.open("w+") as file:
file.write("TEST_CREDENTIAL=my_credential_value\n")
env_vars = load_env_vars_from_file(file_path=env_path)
with subtests.test("Read using = seperator"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
# add another variable to the file
with env_path.open("a+") as file:
file.write("TEST_CREDENTIAL_B=testing@yahoo.fake\n")
env_vars = load_env_vars_from_file(env_path)
with subtests.test("Two credential with = seperator (TEST_CREDENTIAL)"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
with subtests.test("Two credential with = seperator (TEST_CREDENTIAL_B)"):
assert env_vars["TEST_CREDENTIAL_B"] == "testing@yahoo.fake"
# add a line without a separator to the file and a line using a different separator
with env_path.open("a+") as file:
file.write("TEST_CREDENTIAL_C_IS_A_SENTENCE\nTEST_CREDENTIAL_D : testingValue\n")
env_vars = load_env_vars_from_file(env_path)
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL)"):
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL_B)"):
assert env_vars["TEST_CREDENTIAL_B"] == "testing@yahoo.fake"
with subtests.test("Empty line and mixed separators (TEST_CREDENTIAL_D)"):
assert env_vars["TEST_CREDENTIAL_D"] == "testingValue"
with subtests.test("Empty line and mixed separators (skipped sentence line)"):
extra_vars = set(env_vars) - {"TEST_CREDENTIAL", "TEST_CREDENTIAL_B", "TEST_CREDENTIAL_D"}
assert len(extra_vars) == 0
env_path = temp_dir / ".env"
with env_path.open("w+") as file:
file.write("TEST_CREDENTIAL=my_credential_value\n") # = with no spaces
file.write("TEST_CREDENTIAL_B=testing@yahoo.fake \n") # = with spaces
file.write("TEST_CREDENTIAL_C_IS_A_SENTENCE\n") # should be skipped
file.write("TEST_CREDENTIAL_D : testingValue\n") # : with spaces
env_vars = load_env_vars_from_file(file_path=env_path)
assert len(env_vars) == 3
assert env_vars["TEST_CREDENTIAL"] == "my_credential_value"
assert env_vars["TEST_CREDENTIAL_B"] == "testing@yahoo.fake"
assert env_vars["TEST_CREDENTIAL_D"] == "testingValue"

The test as written covers all the bases well, but is a bit too verbose and hard to follow for what is a pretty straightforward test.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could even be worth adding a consecutive delimiter example like the below modification to highlight what happens when users don't pay attention.

file.write("TEST_CREDENTIAL_E :: another_credential\n")
assert len(env_vars) == 4
assert env_vars["TEST_CREDENTIAL_E"] == ": another_credential"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Comment thread h2integrate/core/test/test_env_tools.py
Comment on lines +164 to +165
env_file_txt = f"TEST_CREDENTIAL_A={temp_dir}\nTEST_CREDENTIAL_B=howdyFolks\n"
env_file_txt += "TEST_CREDENTIAL_C=i<3H2I\n"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol

Comment thread h2integrate/core/test/test_env_tools.py Outdated


@pytest.mark.unit
def test_get_environment_variables_from_default_folder(subtests, temp_dir):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about grouping subtests applies here, though there are more categories.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same response about I hear ya but I kept the overly verbose subtests and tried to add in comments to make it more clear.

Comment thread h2integrate/core/test/test_env_tools.py Outdated


@pytest.mark.unit
def test_get_environment_variables_from_default_cwd(subtests, temp_dir):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about grouping subtests.

Comment thread h2integrate/resource/test/conftest.py Outdated
Comment thread test/conftest.py Outdated
@elenya-grant
elenya-grant requested a review from RHammond2 July 23, 2026 15:28
Comment thread h2integrate/core/test/test_env_tools.py Outdated
Comment on lines +77 to +79
with subtests.test("Empty line and mixed separators (skipped sentence line)"):
extra_vars = set(env_vars) - {"TEST_CREDENTIAL", "TEST_CREDENTIAL_B", "TEST_CREDENTIAL_D"}
assert len(extra_vars) == 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible the changes in my below comment weren't pushed, but I do think this should just be a basic test at the beginning that simply checks the length of the returned env_vars as this is a bit of a convoluted way to do that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these changes should be pushed up. In test_load_env_vars_from_file is has assert len(env_vars)=4 (because I added another environment variable to the test) instead of the weird version I had before.

@RHammond2 RHammond2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, @elenya-grant! I think once you've addressed everything we've talked about, this is good from my end since we cleared everything up in a discussion offline.

@johnjasa johnjasa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fantastic, thank you for this @elenya-grant and for iterating with @RHammond2!

@johnjasa
johnjasa marked this pull request as draft July 24, 2026 22:30
@johnjasa
johnjasa marked this pull request as ready for review July 24, 2026 22:30
@johnjasa
johnjasa enabled auto-merge (squash) July 24, 2026 22:30
@elenya-grant
elenya-grant disabled auto-merge July 24, 2026 23:11
@elenya-grant
elenya-grant enabled auto-merge (squash) July 24, 2026 23:12
@elenya-grant
elenya-grant merged commit 7470970 into NatLabRockies:develop Jul 24, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review This PR is ready for input from folks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants