Source code for pyvergeos.resources.recipe_common

"""Recipe Questions and Sections shared between VM and Tenant recipes.

Recipe questions define the configuration options presented to users when deploying
a recipe. Questions are organized into sections that group related options together.

Question Types:
    User Input Types:
        - string: Free-form text input
        - number: Numeric value (supports min/max)
        - boolean: Checkbox (true/false)
        - password: Masked text with confirmation
        - list: Dropdown selection from predefined options
        - text_area: Multi-line text input
        - hidden: Not shown on form (for internal values)

    VergeOS-Specific Types:
        - ram: RAM selector with unit conversion
        - disk_size: Disk size selector
        - cluster: Cluster selection dropdown
        - network: Network selection dropdown
        - row_selection: Database row picker

    Database Automation Types:
        - database_create: Create a database record
        - database_edit: Modify a database record
        - database_find: Look up database values

Common Sections:
    - "Virtual Machine": Core VM settings (CPU, RAM, hostname)
    - "Network": Network and IP configuration
    - "Drives": Storage/disk settings
    - "Static IP Configuration": Manual IP settings
    - "User Configuration": Guest OS user accounts
    - "$database": Database automation (hidden from users)

Variable Naming Convention:
    Question names serve as variable names in recipe scripts. Common prefixes:
    - YB_* : VergeOS built-in variables (YB_RAM, YB_CPU_CORES, etc.)
    - SELECT_* : Selection/boolean options
    - Custom names for recipe-specific variables

Example:
    >>> # List questions for a recipe
    >>> recipe = client.vm_recipes.get(name="Ubuntu Server")
    >>> questions = client.recipe_questions.list(
    ...     recipe_ref=f"vm_recipes/{recipe.key}"
    ... )
    >>> for q in questions:
    ...     print(f"{q.name}: {q.question_type} - {q.get('display')}")

    >>> # Get sections for a recipe
    >>> sections = client.recipe_sections.list(
    ...     recipe_ref=f"vm_recipes/{recipe.key}"
    ... )
    >>> for s in sections:
    ...     print(f"{s.name}: {s.get('description', 'No description')}")
"""

from __future__ import annotations

import builtins
from typing import TYPE_CHECKING, Any

from pyvergeos.exceptions import NotFoundError
from pyvergeos.filters import build_filter
from pyvergeos.resources.base import ResourceManager, ResourceObject

if TYPE_CHECKING:
    from pyvergeos.client import VergeClient


[docs] class RecipeQuestion(ResourceObject): """Recipe question resource object. Represents a configuration question for a recipe. Questions are used to gather input during recipe deployment. Attributes: key: Question $key (integer row ID). recipe: Recipe reference (e.g., "vm_recipes/{id}" or "tenant_recipes/{id}"). section: Section key this question belongs to. name: Question variable name (used in recipe scripts). display: Display label for the question. hint: Placeholder text shown in input field. help: Tooltip text shown on hover. note: Note text displayed below the field. type: Question type (string, bool, num, password, list, etc.). default: Default value for the question. required: Whether the question is required. enabled: Whether the question is enabled. readonly: Whether the question is read-only after creation. orderid: Display order within the section. """ @property def recipe_ref(self) -> str | None: """Get the recipe reference string.""" return self.get("recipe") @property def section_key(self) -> int | None: """Get the section key this question belongs to.""" section = self.get("section") return int(section) if section is not None else None @property def question_type(self) -> str | None: """Get the question type.""" return self.get("type") @property def is_required(self) -> bool: """Check if the question is required.""" return bool(self.get("required", False)) @property def is_enabled(self) -> bool: """Check if the question is enabled.""" return bool(self.get("enabled", True)) @property def is_readonly(self) -> bool: """Check if the question is read-only.""" return bool(self.get("readonly", False)) @property def is_password(self) -> bool: """Check if this is a password question.""" return self.get("type") == "password" @property def is_hidden(self) -> bool: """Check if this is a hidden question.""" return self.get("type") == "hidden" @property def list_options(self) -> dict[str, Any] | None: """Get list options if this is a list-type question.""" if self.get("type") != "list": return None return self.get("list")
[docs] class RecipeSection(ResourceObject): """Recipe section resource object. Represents a section that groups related questions in a recipe form. Attributes: key: Section $key (integer row ID). recipe: Recipe reference (e.g., "vm_recipes/{id}" or "tenant_recipes/{id}"). name: Section name. description: Section description. orderid: Display order. """ @property def recipe_ref(self) -> str | None: """Get the recipe reference string.""" return self.get("recipe")
[docs] class RecipeQuestionManager(ResourceManager["RecipeQuestion"]): """Manager for recipe question operations. Recipe questions define the configuration options available when deploying a recipe. Questions are organized into sections. Example: >>> # List all questions for a VM recipe >>> questions = client.recipe_questions.list( ... recipe_ref="vm_recipes/8f73f8bcc9c9..." ... ) >>> for q in questions: ... print(f"{q.name}: {q.question_type} (required={q.is_required})") >>> # List questions for a specific section >>> questions = client.recipe_questions.list(section=123) """ _endpoint = "recipe_questions" _default_fields = [ "$key", "recipe", "section", "section#name as section_name", "name", "display", "hint", "help", "note", "type", "default", "required", "enabled", "readonly", "orderid", "min", "max", "regex", "list", "table", "fields", "filter", "database_context", "hide_none", "conditions", "on_change", "postprocess_string", "dont_store", "system", ]
[docs] def __init__( self, client: VergeClient, *, recipe_ref: str | None = None, section_key: int | None = None, ) -> None: super().__init__(client) self._recipe_ref = recipe_ref self._section_key = section_key
[docs] def list( self, filter: str | None = None, fields: builtins.list[str] | None = None, limit: int | None = None, offset: int | None = None, recipe_ref: str | None = None, section: int | None = None, enabled: bool | None = None, **filter_kwargs: Any, ) -> builtins.list[RecipeQuestion]: """List recipe questions with optional filtering. Args: filter: OData filter string. fields: List of fields to return (uses defaults if not specified). limit: Maximum number of results. offset: Skip this many results. recipe_ref: Filter by recipe reference (e.g., "vm_recipes/{id}"). section: Filter by section key. enabled: Filter by enabled state. **filter_kwargs: Shorthand filter arguments (name, type, etc.). Returns: List of RecipeQuestion objects. Example: >>> # List all questions for a VM recipe >>> questions = client.recipe_questions.list( ... recipe_ref="vm_recipes/8f73f8bcc9c9..." ... ) >>> # List enabled questions only >>> enabled = client.recipe_questions.list(enabled=True) """ params: dict[str, Any] = {} # Build filter filters: builtins.list[str] = [] if filter: filters.append(filter) if filter_kwargs: filters.append(build_filter(**filter_kwargs)) # Add recipe filter (from scope or parameter) recipe = self._recipe_ref if recipe is None and recipe_ref is not None: recipe = recipe_ref if recipe is not None: filters.append(f"recipe eq '{recipe}'") # Add section filter (from scope or parameter) sect = self._section_key if sect is None and section is not None: sect = section if sect is not None: filters.append(f"section eq {sect}") # Add enabled filter if enabled is not None: filters.append(f"enabled eq {1 if enabled else 0}") if filters: params["filter"] = " and ".join(filters) # Use default fields if not specified if fields: params["fields"] = ",".join(fields) else: params["fields"] = ",".join(self._default_fields) # Sort by orderid params["sort"] = "+orderid" # Pagination if limit is not None: params["limit"] = limit if offset is not None: params["offset"] = offset response = self._client._request("GET", self._endpoint, params=params) if response is None: return [] if not isinstance(response, list): response = [response] return [self._to_model(item) for item in response if item]
[docs] def get( self, key: int | None = None, *, name: str | None = None, fields: builtins.list[str] | None = None, ) -> RecipeQuestion: """Get a single recipe question by key or name. Args: key: Question $key (row ID). name: Question name (variable name). fields: List of fields to return. Returns: RecipeQuestion object. Raises: NotFoundError: If question not found. ValueError: If no identifier provided. """ if key is not None: params: dict[str, Any] = {} if fields: params["fields"] = ",".join(fields) else: params["fields"] = ",".join(self._default_fields) response = self._client._request("GET", f"{self._endpoint}/{key}", params=params) if response is None: raise NotFoundError(f"Recipe question with key {key} not found") if not isinstance(response, dict): raise NotFoundError(f"Recipe question with key {key} returned invalid response") return self._to_model(response) if name is not None: escaped_name = name.replace("'", "''") results = self.list(filter=f"name eq '{escaped_name}'", fields=fields, limit=1) if not results: raise NotFoundError(f"Recipe question with name '{name}' not found") return results[0] raise ValueError("Either key or name must be provided")
[docs] def create( # type: ignore[override] self, name: str, recipe_ref: str, section: int, question_type: str, *, display: str | None = None, hint: str | None = None, help_text: str | None = None, note: str | None = None, default: str | None = None, required: bool = False, enabled: bool = True, readonly: bool = False, min_value: int | None = None, max_value: int | None = None, regex: str | None = None, list_options: dict[str, str] | None = None, table: str | None = None, fields: str | None = None, db_filter: str | None = None, database_context: str | None = None, hide_none: bool = False, conditions: dict[str, Any] | None = None, on_change: dict[str, Any] | None = None, postprocess_string: str | None = None, dont_store: bool = False, ) -> RecipeQuestion: """Create a new recipe question. Args: name: Variable name for the question (alphanumeric with underscores). recipe_ref: Recipe reference (e.g., "vm_recipes/{id}"). section: Section key to add the question to. question_type: Question type (string, bool, num, password, list, etc.). display: Display label. hint: Placeholder text. help_text: Tooltip text. note: Note text below field. default: Default value. required: Whether the question is required. enabled: Whether the question is enabled. readonly: Whether the question is read-only after creation. min_value: Minimum value (for numeric types). max_value: Maximum value (for numeric types). regex: Validation regex. list_options: Options for list type (key: value dict). table: Database table for row selection type. fields: Database fields for row selection. db_filter: Database filter for row selection. database_context: Database context (local or tenant). hide_none: Hide the "None" option in lists. conditions: Conditional visibility rules. on_change: On-change actions. postprocess_string: Post-processing type. dont_store: Don't store the answer in the database. Returns: Created RecipeQuestion object. Example: >>> question = client.recipe_questions.create( ... name="vm_ram", ... recipe_ref="vm_recipes/8f73f8bcc9c9...", ... section=123, ... question_type="ram", ... display="RAM", ... default="4096", ... required=True ... ) """ body: dict[str, Any] = { "name": name, "recipe": recipe_ref, "section": section, "type": question_type, } if display is not None: body["display"] = display if hint is not None: body["hint"] = hint if help_text is not None: body["help"] = help_text if note is not None: body["note"] = note if default is not None: body["default"] = default body["required"] = required body["enabled"] = enabled body["readonly"] = readonly if min_value is not None: body["min"] = min_value if max_value is not None: body["max"] = max_value if regex is not None: body["regex"] = regex if list_options is not None: body["list"] = list_options if table is not None: body["table"] = table if fields is not None: body["fields"] = fields if db_filter is not None: body["filter"] = db_filter if database_context is not None: body["database_context"] = database_context body["hide_none"] = hide_none if conditions is not None: body["conditions"] = conditions if on_change is not None: body["on_change"] = on_change if postprocess_string is not None: body["postprocess_string"] = postprocess_string body["dont_store"] = dont_store response = self._client._request("POST", self._endpoint, json_data=body) # Get the created question if response and isinstance(response, dict): q_key = response.get("$key") if q_key: return self.get(key=q_key) # Fallback: search by name in the recipe results = self.list(recipe_ref=recipe_ref, filter=f"name eq '{name}'", limit=1) if results: return results[0] raise NotFoundError(f"Failed to retrieve created question '{name}'")
[docs] def update( # type: ignore[override] self, key: int, *, display: str | None = None, hint: str | None = None, help_text: str | None = None, note: str | None = None, default: str | None = None, required: bool | None = None, enabled: bool | None = None, readonly: bool | None = None, min_value: int | None = None, max_value: int | None = None, orderid: int | None = None, ) -> RecipeQuestion: """Update a recipe question. Args: key: Question $key (row ID). display: New display label. hint: New placeholder text. help_text: New tooltip text. note: New note text. default: New default value. required: Set required state. enabled: Set enabled state. readonly: Set readonly state. min_value: New minimum value. max_value: New maximum value. orderid: New display order. Returns: Updated RecipeQuestion object. """ body: dict[str, Any] = {} if display is not None: body["display"] = display if hint is not None: body["hint"] = hint if help_text is not None: body["help"] = help_text if note is not None: body["note"] = note if default is not None: body["default"] = default if required is not None: body["required"] = required if enabled is not None: body["enabled"] = enabled if readonly is not None: body["readonly"] = readonly if min_value is not None: body["min"] = min_value if max_value is not None: body["max"] = max_value if orderid is not None: body["orderid"] = orderid if not body: return self.get(key) self._client._request("PUT", f"{self._endpoint}/{key}", json_data=body) return self.get(key)
[docs] def delete(self, key: int) -> None: """Delete a recipe question. Args: key: Question $key (row ID). """ self._client._request("DELETE", f"{self._endpoint}/{key}")
def _to_model(self, data: dict[str, Any]) -> RecipeQuestion: """Convert API response to RecipeQuestion object.""" return RecipeQuestion(data, self)
[docs] class RecipeSectionManager(ResourceManager["RecipeSection"]): """Manager for recipe section operations. Recipe sections organize questions into logical groups. Example: >>> # List all sections for a VM recipe >>> sections = client.recipe_sections.list( ... recipe_ref="vm_recipes/8f73f8bcc9c9..." ... ) >>> for s in sections: ... print(f"{s.name}: {s.description}") >>> # Create a new section >>> section = client.recipe_sections.create( ... name="Network Settings", ... recipe_ref="vm_recipes/8f73f8bcc9c9...", ... description="Network configuration options" ... ) """ _endpoint = "recipe_sections" _default_fields = [ "$key", "recipe", "name", "description", "orderid", ]
[docs] def __init__( self, client: VergeClient, *, recipe_ref: str | None = None, ) -> None: super().__init__(client) self._recipe_ref = recipe_ref
[docs] def list( self, filter: str | None = None, fields: builtins.list[str] | None = None, limit: int | None = None, offset: int | None = None, recipe_ref: str | None = None, **filter_kwargs: Any, ) -> builtins.list[RecipeSection]: """List recipe sections with optional filtering. Args: filter: OData filter string. fields: List of fields to return (uses defaults if not specified). limit: Maximum number of results. offset: Skip this many results. recipe_ref: Filter by recipe reference (e.g., "vm_recipes/{id}"). **filter_kwargs: Shorthand filter arguments (name, etc.). Returns: List of RecipeSection objects. Example: >>> # List all sections for a VM recipe >>> sections = client.recipe_sections.list( ... recipe_ref="vm_recipes/8f73f8bcc9c9..." ... ) """ params: dict[str, Any] = {} # Build filter filters: builtins.list[str] = [] if filter: filters.append(filter) if filter_kwargs: filters.append(build_filter(**filter_kwargs)) # Add recipe filter (from scope or parameter) recipe = self._recipe_ref if recipe is None and recipe_ref is not None: recipe = recipe_ref if recipe is not None: filters.append(f"recipe eq '{recipe}'") if filters: params["filter"] = " and ".join(filters) # Use default fields if not specified if fields: params["fields"] = ",".join(fields) else: params["fields"] = ",".join(self._default_fields) # Sort by orderid params["sort"] = "+orderid" # Pagination if limit is not None: params["limit"] = limit if offset is not None: params["offset"] = offset response = self._client._request("GET", self._endpoint, params=params) if response is None: return [] if not isinstance(response, list): response = [response] return [self._to_model(item) for item in response if item]
[docs] def get( self, key: int | None = None, *, name: str | None = None, fields: builtins.list[str] | None = None, ) -> RecipeSection: """Get a single recipe section by key or name. Args: key: Section $key (row ID). name: Section name. fields: List of fields to return. Returns: RecipeSection object. Raises: NotFoundError: If section not found. ValueError: If no identifier provided. """ if key is not None: params: dict[str, Any] = {} if fields: params["fields"] = ",".join(fields) else: params["fields"] = ",".join(self._default_fields) response = self._client._request("GET", f"{self._endpoint}/{key}", params=params) if response is None: raise NotFoundError(f"Recipe section with key {key} not found") if not isinstance(response, dict): raise NotFoundError(f"Recipe section with key {key} returned invalid response") return self._to_model(response) if name is not None: escaped_name = name.replace("'", "''") results = self.list(filter=f"name eq '{escaped_name}'", fields=fields, limit=1) if not results: raise NotFoundError(f"Recipe section with name '{name}' not found") return results[0] raise ValueError("Either key or name must be provided")
[docs] def create( # type: ignore[override] self, name: str, recipe_ref: str, *, description: str | None = None, ) -> RecipeSection: """Create a new recipe section. Args: name: Section name. recipe_ref: Recipe reference (e.g., "vm_recipes/{id}"). description: Section description. Returns: Created RecipeSection object. Example: >>> section = client.recipe_sections.create( ... name="Network Settings", ... recipe_ref="vm_recipes/8f73f8bcc9c9...", ... description="Network configuration options" ... ) """ body: dict[str, Any] = { "name": name, "recipe": recipe_ref, } if description is not None: body["description"] = description response = self._client._request("POST", self._endpoint, json_data=body) # Get the created section if response and isinstance(response, dict): s_key = response.get("$key") if s_key: return self.get(key=s_key) # Fallback: search by name in the recipe results = self.list(recipe_ref=recipe_ref, filter=f"name eq '{name}'", limit=1) if results: return results[0] raise NotFoundError(f"Failed to retrieve created section '{name}'")
[docs] def update( # type: ignore[override] self, key: int, *, name: str | None = None, description: str | None = None, orderid: int | None = None, ) -> RecipeSection: """Update a recipe section. Args: key: Section $key (row ID). name: New name. description: New description. orderid: New display order. Returns: Updated RecipeSection object. """ body: dict[str, Any] = {} if name is not None: body["name"] = name if description is not None: body["description"] = description if orderid is not None: body["orderid"] = orderid if not body: return self.get(key) self._client._request("PUT", f"{self._endpoint}/{key}", json_data=body) return self.get(key)
[docs] def delete(self, key: int) -> None: """Delete a recipe section. Note: This will also delete all questions in the section. Args: key: Section $key (row ID). """ self._client._request("DELETE", f"{self._endpoint}/{key}")
[docs] def questions(self, key: int) -> RecipeQuestionManager: """Get a question manager scoped to a specific section. Args: key: Section $key (row ID). Returns: RecipeQuestionManager for the section. """ return RecipeQuestionManager(self._client, section_key=key)
def _to_model(self, data: dict[str, Any]) -> RecipeSection: """Convert API response to RecipeSection object.""" return RecipeSection(data, self)