Environment Variables Store

Description of the limitation and why it is relevant to address

As a developer I’m maintaining 20 applications that are using the same Autodesk APS. I need to maintain 6 variables across all apps.

Submitter proposed design (optional)

Having one place to store variables and perhaps allocate to an app from the same source of truth. Or perhaps have an external connection to a service similar to Parameter Store.

Current workarounds

Copy/pasting environment variables across all apps.

We struggled a lot with this problem a while ago until we made a custom context manager to solve it `manually. If this helps someone else then it was worth it. (I miss Snippet Wednesday)

"""Context manager for handling VIKTOR environment variables.

Made with love (and some frustration it was necessary) by:

Enrique García
"""

import os
from typing import Any, Self

import requests

COMPANY_NAME = "YOUR-COMPANY-NAME"
VIKTOR_DEV_TOKEN = "vktr..........."


def get_viktor_headers() -> dict[str, str]:
    """Get the headers for the VIKTOR API.

    Returns a dictionary with Authorization, Accept, and Content-Type headers
    required for all VIKTOR API calls.

    Returns
    -------
    dict[str, str]
        Headers with Bearer token authorization

    Raises
    ------
    ValueError
        If the VIKTOR_DEV_TOKEN is not set.

    Examples
    --------
    >>> headers = get_viktor_headers()
    >>> import requests
    >>> response = requests.get("https://mycompany.viktor.ai/api/apps/", headers=headers)
    """
    # Check if token exists
    if not VIKTOR_DEV_TOKEN:
        raise ValueError("Viktor dev token is not set")

    # Set up headers with authentication
    return {
        "Accept": "application/json",
        "Content-Type": "application/json",
        "Authorization": f"Bearer {VIKTOR_DEV_TOKEN}",
    }

def get_all_viktor_app_ids() -> list[int]:
    """Get all VIKTOR app IDs from the server.

    Fetches the list of all application IDs available in your VIKTOR workspace.

    Returns
    -------
    list[int]
        A list of app IDs.

    Raises
    ------
    requests.HTTPError
        If the API request fails.
    ValueError
        If VIKTOR_DEV_TOKEN is not set.

    Examples
    --------
    >>> app_ids = get_all_viktor_app_ids()
    >>> print(app_ids)
    [237, 238, 240]

    >>> # Use in a loop to process all apps
    >>> for app_id in get_all_viktor_app_ids():
    ...     name = get_viktor_app_name_by_id(app_id)
    ...     print(f"App {app_id}: {name}")
    """
    # Define the API endpoint
    url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/"

    # Make the GET request
    response = requests.get(url, headers=get_viktor_headers(), data={})

    # Check if the request was successful
    response.raise_for_status()

    # Return the workspace IDs from the response JSON
    return [workspace["id"] for workspace in response.json()['results']]

def post_variable_to_viktor_app(
    app_id: int,
    key: str,
    value: str,
    is_secret: bool = True,
) -> None:
    """Post (create or update) an environment variable to a VIKTOR app.

    Parameters
    ----------
    app_id : int
        The ID of the app to post the variable to
    key : str
        The name of the variable (must be a valid Python identifier)
    value : str
        The value of the variable
    is_secret : bool, optional
        Whether to mark this as a secret (hidden in UI), by default True

    Raises
    ------
    requests.HTTPError
        If the API request fails.
    ValueError
        If VIKTOR_DEV_TOKEN is not set.

    Examples
    --------
    >>> # Add a single variable
    >>> post_variable_to_viktor_app(
    ...     app_id=237,
    ...     key="DATABASE_URL",
    ...     value="postgresql://user:pass@host/db",
    ...     is_secret=True
    ... )

    >>> # Add a public (non-secret) variable
    >>> post_variable_to_viktor_app(
    ...     app_id=237,
    ...     key="LOG_LEVEL",
    ...     value="INFO",
    ...     is_secret=False
    ... )
    """
    # Define the API endpoint
    url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/{app_id}/variables/"

    # prepare the keys to be posted
    payload = {
        "key": key,
        "value": value,
        "is_secret": is_secret,
    }

    # Make the POST request
    response = requests.post(url, headers=get_viktor_headers(), json=payload)

    # Check if the request was successful
    response.raise_for_status()


def delete_variable_from_viktor_app(
    app_id: int,
    key_id: int,
) -> None:
    """Delete an environment variable from a VIKTOR app.

    Parameters
    ----------
    app_id : int
        The ID of the app to delete the variable from
    key_id : int
        The ID of the variable (not the name — use the id from the API response)

    Raises
    ------
    requests.HTTPError
        If the API request fails.
    ValueError
        If VIKTOR_DEV_TOKEN is not set.

    Examples
    --------
    >>> # Delete variable with ID 123 from app 237
    >>> delete_variable_from_viktor_app(app_id=237, key_id=123)

    See Also
    --------
    ViktorEnvironmentVariables : Use the context manager for easier delete operations
    """
    # Define the API endpoint
    url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/{app_id}/variables/{key_id}/"

    # Make the DELETE request
    response = requests.delete(url, headers=get_viktor_headers(), json={})

    # Check if the request was successful
    response.raise_for_status()


def get_viktor_app_name_by_id(app_id: int) -> str:
    """Get the VIKTOR app name by its ID.

    Parameters
    ----------
    app_id : int
        The ID of the app to get the name for

    Returns
    -------
    str
        The friendly name of the app

    Raises
    ------
    requests.HTTPError
        If the API request fails.
    ValueError
        If VIKTOR_DEV_TOKEN is not set.
    KeyError
        If the app ID doesn't exist.

    Examples
    --------
    >>> name = get_viktor_app_name_by_id(237)
    >>> print(name)
    'My Analysis App'

    >>> # Combine with get_all_viktor_app_ids() to list all apps
    >>> for app_id in get_all_viktor_app_ids():
    ...     name = get_viktor_app_name_by_id(app_id)
    ...     print(f"{app_id}: {name}")
    """
    # Define the API endpoint
    url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/{app_id}/"

    # Make the GET request
    response = requests.get(url, headers=get_viktor_headers())

    # Check if the request was successful
    response.raise_for_status()

    # Return the app name from the response JSON
    return response.json()["name"]


def get_viktor_app_id_by_name(app_name: str) -> int:
    """Get the VIKTOR app ID by its friendly name.

    Parameters
    ----------
    app_name : str
        The friendly name of the app (case-sensitive exact match)

    Returns
    -------
    int
        The ID of the app

    Raises
    ------
    requests.HTTPError
        If the API request fails.
    ValueError
        If VIKTOR_DEV_TOKEN is not set, or app name not found.

    Examples
    --------
    >>> app_id = get_viktor_app_id_by_name('My Analysis App')
    >>> print(app_id)
    237

    >>> # Use with the context manager for easy variable management
    >>> with ViktorEnvironmentVariables(app=237) as env:
    ...     env.add_variable('API_KEY', 'secret123')
    """
    # Define the API endpoint
    url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/"

    # Make the GET request
    response = requests.get(url, headers=get_viktor_headers())

    # Check if the request was successful
    response.raise_for_status()

    # Find and return the app ID by name from the response JSON
    for app in response.json():
        if app["name"] == app_name:
            return app["id"]

    raise ValueError(f"App with name '{app_name}' not found.")


class ViktorEnvironmentVariables:
    """Context manager for safely managing VIKTOR app environment variables.

    Fetches current variables on enter, applies changes on exit (add, modify, delete).
    All changes are sent to the API only when exiting the context.

    Examples
    --------
    **Add new variables:**

    >>> with ViktorEnvironmentVariables(app=237) as env:
    ...     env.add_variable('DATABASE_URL', 'postgres://localhost/mydb')
    ...     env.add_variable('LOG_LEVEL', 'DEBUG', is_secret=False)

    **Modify an existing variable:**

    >>> with ViktorEnvironmentVariables(app='My Analysis App') as env:
    ...     env.change_variable('API_KEY', 'new_secret_value')

    **Delete a variable:**

    >>> with ViktorEnvironmentVariables(app=237) as env:
    ...     env.delete_variable('OLD_VARIABLE')

    **Clear all variables:**

    >>> with ViktorEnvironmentVariables(app=237) as env:
    ...     env.clear_variables()

    **View current variables (read-only):**

    >>> with ViktorEnvironmentVariables(app=237) as env:
    ...     for key, value_dict in env.variables.items():
    ...         print(f"{key} = {value_dict['value']}")
    """

    def __init__(self, app: int | str) -> None:
        """Initialize the context manager with an app ID or name.

        Parameters
        ----------
        app : int | str
            The app ID (int) or the app name (str, case-sensitive exact match)

        Raises
        ------
        ValueError
            If app is a string and doesn't match any existing app name
        """
        self.app_id = (
            app if isinstance(app, int) else get_viktor_app_id_by_name(app_name=app)
        )
        self.app_name = (
            app
            if isinstance(app, str)
            else get_viktor_app_name_by_id(app_id=self.app_id)
        )
        self.variables = {}
        self.url = f"https://{COMPANY_NAME}.viktor.ai/api/apps/{self.app_id}/variables/"

    def __enter__(self) -> Self:
        # delete id's from api call and set variables as a dictionary
        for env_key in self._variables_from_api():
            env_key["modified"] = False  # all variables are not modified at the start
            self.variables[env_key["key"]] = env_key
        return self

    def _variables_from_api(self) -> list[dict[str, Any]]:
        """Fetch the current variables from the VIKTOR API.

        Returns
        -------
        list[dict[str, Any]]
            List of variable dicts with keys: id, key, value, is_secret

        Raises
        ------
        requests.HTTPError
            If the API request fails
        """
        # Make the GET request
        response = requests.get(self.url, headers=get_viktor_headers())

        # Check if the request was successful
        response.raise_for_status()

        # Return the response as JSON
        return response.json()

    def delete_variable(self, key: str) -> None:
        """Mark a variable for deletion (removed on exit).

        Parameters
        ----------
        key : str
            The name of the variable to delete

        Raises
        ------
        KeyError
            If the variable doesn't exist

        Examples
        --------
        >>> with ViktorEnvironmentVariables(app=237) as env:
        ...     env.delete_variable('OLD_API_KEY')
        ...     env.delete_variable('DEPRECATED_SETTING')
        """
        try:
            self.variables.pop(key)
        except KeyError:
            raise KeyError(
                f"Variable '{key}' not found in the environment of app '{self.app_name}'."
            )

    def add_variable(self, key: str, value: str, is_secret: bool = True) -> None:
        """Add or update a variable in the context (sent on exit).

        Parameters
        ----------
        key : str
            Variable name (must be a valid Python identifier: letters, digits, '_')
        value : str
            The value to set
        is_secret : bool, optional
            If True, value is hidden in VIKTOR UI (default True)

        Raises
        ------
        ValueError
            If key is not a valid identifier

        Examples
        --------
        >>> with ViktorEnvironmentVariables(app=237) as env:
        ...     env.add_variable('DATABASE_URL', 'postgresql://localhost/mydb')
        ...     env.add_variable('DEBUG_MODE', 'true', is_secret=False)
        """
        # check if the key is valid
        if not key.isidentifier():
            raise ValueError(
                f"Invalid key '{key}'. Variable name can only contain letters, digits and the character '_'."
            )

        key_to_set = {
            "key": key,
            "value": value,
            "is_secret": is_secret,
            "modified": True,
        }

        # if the key already exists in the variables, then set the id to the current key
        if key in self.variables:
            key_to_set["id"] = self.variables[key]["id"]

        self.variables[key] = key_to_set

    def change_variable(self, key: str, value: str, is_secret: bool = True) -> None:
        """Modify an existing variable's value and/or secret status (sent on exit).

        Parameters
        ----------
        key : str
            The name of the variable to modify (must exist)
        value : str
            The new value
        is_secret : bool, optional
            If True, value is hidden in VIKTOR UI (default True)

        Raises
        ------
        KeyError
            If the variable doesn't exist

        Examples
        --------
        >>> with ViktorEnvironmentVariables(app=237) as env:
        ...     env.change_variable('API_KEY', 'new_secret_123')
        ...     env.change_variable('LOG_LEVEL', 'DEBUG', is_secret=False)
        """
        if key in self.variables:
            self.variables[key]["value"] = value
            self.variables[key]["is_secret"] = is_secret
            self.variables[key]["modified"] = True
        else:
            raise KeyError(
                f"Variable '{key}' not found in the environment of app '{self.app_name}'."
            )

    def clear_variables(self) -> None:
        """Remove all environment variables from the app (sent immediately).

        This clears all variables synchronously by posting an empty list to the API.

        Examples
        --------
        >>> with ViktorEnvironmentVariables(app=237) as env:
        ...     env.clear_variables()
        ...     # All variables removed from app 237
        """
        self.variables = {}
        request = requests.post(url=self.url, headers=get_viktor_headers(), json=[])
        request.raise_for_status()
        return

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        """Exit the context manager and synchronize all changes with the VIKTOR API.

        Deletes variables that are no longer in the context, updates modified ones,
        and adds new ones. All API calls are made during this exit phase.

        Parameters
        ----------
        exc_type : type | None
            Exception type if an exception occurred in the context
        exc_value : Exception | None
            Exception instance if an exception occurred
        traceback : TracebackType | None
            Traceback object if an exception occurred
        """
        # if all variables has been cleared then delete them from the API
        if not self.variables:
            self.clear_variables()

        # delete all variables that are present in the API but not in this context manager
        for env_key in self._variables_from_api():
            if env_key["key"] not in self.variables:
                delete_variable_from_viktor_app(
                    app_id=self.app_id,
                    key_id=env_key["id"],
                )

        # Post the variables to the VIKTOR API
        for key, value in self.variables.items():
            if value["modified"]:
                if value.get("id"):
                    # delete the variable from the API to make sure it is not duplicated
                    delete_variable_from_viktor_app(
                        app_id=self.app_id,
                        key_id=value["id"],
                    )
                # Post the variable to the VIKTOR API
                post_variable_to_viktor_app(
                    app_id=self.app_id,
                    key=key,
                    value=value["value"],
                    is_secret=value["is_secret"],
                )

        # Clear everything
        self.variables.clear()

if __name__ == "__main__":
    with ViktorEnvironmentVariables(app=237) as env:
        env.add_variable(
            key='API_KEY', 
            value='secret123',
            is_secret=True,
        )
2 Likes