Skip to content

alc.config

Studio and project configuration.

alc.config

AlchemyConfig

Bases: BaseSettings

software_registry cached property

software_registry: SoftwareRegistry

Lazy-loads the DCC registry. If it doesn't exist in memory, it triggers the load/scan logic.

company_id property writable

company_id: Optional[str]

The grounded company id: the active web request's /c/{cid} when a request context is active (see :mod:alc.context), else the persisted boot selection. DCC/CLI/farm code never activates a context, so outside the web app this is always the boot value.

company property

company: Optional[Company]

Returns the active company object based on company_id.

project property

project: Optional[Project]

The grounded project: the request's /project/{pid} URL segment when the active web context carries one (a URL naming a project the company doesn't have resolves to None — surface the error, don't ground to a different project), else the grounded company's persisted current.

cookbook_import_root property

cookbook_import_root: Optional[Path]

The cookbook import anchor — the parent of the cookbook package, i.e. the directory that must be on sys.path for import cookbook.* to resolve. Distinct from cookbook_dir (the cookbook package / git root itself). This is the single path meaning "where Python imports the cookbook from"; use env.ensure_cookbook_on_path() to actually wire it into an interpreter.

cookbook_root property

cookbook_root: Optional[Path]

Deprecated alias for :attr:cookbook_import_root. The name read as "root of the cookbook" and was ambiguous with the git root; prefer cookbook_import_root in new code.

perforce_root property

perforce_root: Optional[str]

The active company's Perforce workspace root (Unreal lives here).

Distinct from prod_root (the VERSIONS/PUBLISHES tree) — Unreal/Perforce content is rooted here (e.g. C:/alc/p4v). The route engine uses it to decompose live .uproject / Content paths into PathObjects.

task_registry property

task_registry: ProductionTaskRegistry

Lazy-loads the DCC registry. If it doesn't exist in memory, it triggers the load/scan logic.

software_config_file property

software_config_file: Path | None

The scanned DCC registry.

util_config property

util_config: Dict[str, Any]

The scanned DCC registry.

textures_config property

textures_config: Dict[str, Any]

Texture conventions (e.g. UDIM channel names). Empty dict when the config file is absent — callers (e.g. is_udim) treat that as 'unknown'.

python_path property

python_path

Return the packaged python path, or with development return the code_root venv. Returns:

get_instance classmethod

get_instance(force_reload: bool = False) -> AlchemyConfig

The absolute entry point. Ensures one manifest rules them all.

Source code in alc\config\config.py
@classmethod
def get_instance(cls, force_reload: bool = False) -> AlchemyConfig:
    """
    The absolute entry point. Ensures one manifest rules them all.
    """
    if cls._instance is None or force_reload:
        cls._instance = cls._load_from_io()
    return cls._instance

sync_environment

sync_environment() -> AlchemyConfig

Ensures the OS environment matches the validated Pydantic state.

Source code in alc\config\config.py
@model_validator(mode="after")
def sync_environment(self) -> AlchemyConfig:
    """Ensures the OS environment matches the validated Pydantic state."""
    if self.prod_root:
        os.environ["ALC_PROD_ROOT"] = str(self.prod_root)
    # The route engine's `when: root: ALC_PERFORCE_ROOT` clauses resolve via
    # os.getenv — so the Unreal/Perforce on-disk routes only fire when this is
    # exported (mirrors ALC_PROD_ROOT). Grounds Unreal paths to the workspace.
    if self.perforce_root:
        os.environ["ALC_PERFORCE_ROOT"] = str(self.perforce_root)
    return self

set_current_project

set_current_project(project_id: str)

Sets the current project for the active company. updates the config on disk. Args: project_id:

Returns:

Source code in alc\config\config.py
def set_current_project(self, project_id: str):
    """
    Sets the current project for the active company.
    updates the config on disk.
    Args:
        project_id:

    Returns:

    """
    if project_id not in self.company.projects:
        raise ValueError(f"Project {project_id} does not exist")
    self.company.last_project_id = project_id
    self.commit()

set_current_company

set_current_company(company_id: str)

Sets the current company. updates the config on disk. Args: company_id:

Returns:

Source code in alc\config\config.py
def set_current_company(self, company_id: str):
    """
    Sets the current company.
    updates the config on disk.
    Args:
        company_id:

    Returns:

    """
    if company_id not in self.companies:
        raise ValueError(f"Company ID '{company_id}' does not exist.")
    self.company_id = company_id
    self.commit()

add_company

add_company(
    company_id: str,
    label: str,
    prod_root: str = "",
    **kwargs
) -> Company

Registers a new company in the config and writes it to disk.

Parameters:

Name Type Description Default
company_id str

The 3-letter company code (e.g. 'cgl').

required
label str

The human-readable company name.

required
prod_root str

Optional production root for the company.

''

Returns:

Type Description
Company

The Company that is now registered (existing one if it already existed).

Source code in alc\config\config.py
def add_company(
    self, company_id: str, label: str, prod_root: str = "", **kwargs
) -> Company:
    """
    Registers a new company in the config and writes it to disk.

    Args:
        company_id: The 3-letter company code (e.g. 'cgl').
        label: The human-readable company name.
        prod_root: Optional production root for the company.

    Returns:
        The Company that is now registered (existing one if it already existed).
    """
    company_id = company_id.lower()
    if company_id in self.companies:
        logger.warning(f"Company '{company_id}' already exists.")
        return self.companies[company_id]

    # Default to the shared studio root used by existing companies so a new
    # company is usable immediately (the path can still be overridden later).
    if not prod_root:
        prod_root = next(
            (c.prod_root for c in self.companies.values() if c.prod_root), ""
        )

    company = Company(id=company_id, label=label, prod_root=prod_root, **kwargs)
    self.companies[company_id] = company
    self.commit()
    logger.info(f"ALC-CONF: Added company '{label}' ({company_id}).")
    return company

update

update(**kwargs) -> 'AlchemyConfig'

Creates a new instance with updated fields and updates the singleton.

Source code in alc\config\config.py
def update(self, **kwargs) -> "AlchemyConfig":
    """
    Creates a new instance with updated fields and updates the singleton.
    """
    # model_copy(update=...) is the Pydantic 'replace'
    new_instance = self.model_copy(update=kwargs)

    # If commit() writes to disk, call it on the new instance
    # new_instance.commit()

    AlchemyConfig._instance = new_instance
    logger.info(f"ALC-CONF: State updated: {list(kwargs.keys())}")
    return new_instance

commit

commit() -> None

Serializes the current state of the model and writes it to the config_file.

Source code in alc\config\config.py
def commit(self) -> None:
    """
    Serializes the current state of the model and writes it to the config_file.
    """
    try:
        # 1. Convert model to JSON string
        # indent=4 makes it human-readable like your alchemy.json example
        json_data = self.model_dump_json(indent=4)
        print(self.path)
        # 2. Ensure the directory exists
        self.path.parent.mkdir(parents=True, exist_ok=True)

        # 3. Atomic write (optional but recommended for configs)
        with open(self.path, "w", encoding="utf-8") as f:
            f.write(json_data)

        logger.info(f"ALC-CONF: Successfully saved configuration to {self.path}")
    except Exception as e:
        logger.error(f"ALC-CONF: Failed to save config: {e}")
        raise

get_software_cookbooks

get_software_cookbooks() -> Dict[str, SoftwareCookbook]

Returns:

Source code in alc\config\config.py
def get_software_cookbooks(self) -> Dict[str, SoftwareCookbook]:
    """

    Returns:

    """

    from alc.cookbook.cookbook import SoftwareCookbook

    sw_cbs = {}
    sw_folders = os.listdir(self.cookbook_dir)
    for sw_name in self.software_registry.software:
        if sw_name in sw_folders:
            sw_cbs[sw_name] = SoftwareCookbook(software=sw_name)
    return sw_cbs

get_recipe

get_recipe(
    software: str, task: str, recipe_type: str
) -> Optional[Recipe]

Fetches a recipe based on software, task, and recipe type. Args: software: The name of the DCC software (e.g., "maya"). task: The production task (e.g., "modeling"). recipe_type: The type of recipe (e.g., "default", "publish").

Returns:

Type Description
Optional[Recipe]

A Recipe object if found, otherwise None.

Source code in alc\config\config.py
def get_recipe(
    self, software: str, task: str, recipe_type: str
) -> Optional[Recipe]:
    """
    Fetches a recipe based on software, task, and recipe type.
    Args:
        software: The name of the DCC software (e.g., "maya").
        task: The production task (e.g., "modeling").
        recipe_type: The type of recipe (e.g., "default", "publish").

    Returns:
        A Recipe object if found, otherwise None.
    """
    try:
        cookbook = self.software_cookbooks.get(software)
        if not cookbook:
            logger.warning(f"No cookbook found for software: {software}")
            return None
        recipe = cookbook.get_recipe(task=task, recipe_type=recipe_type)
        if recipe:
            return recipe

        # Artist-UI recipes (menus / shelves / context-menus) are addressed by
        # their menu *name* rather than a production task, and live at
        # {software}/{recipe_type}/{name}/{name}.json. The task-cookbook lookup
        # above misses them when the name isn't a real task (e.g. "alchemy"), so
        # fall back to loading the file directly.
        from alc.cookbook.constants import ARTIST_RECIPES

        if recipe_type in ARTIST_RECIPES:
            from alc.cookbook.recipe import Recipe

            menu_path = (
                Path(self.cookbook_dir)
                / software
                / recipe_type
                / task
                / f"{task}.json"
            )
            if menu_path.exists():
                return Recipe.load_from_file(menu_path)
        return None
    except Exception as e:
        logger.warning(
            f"Failed to get recipe for {software} - {task} - {recipe_type}: {e}"
        )
        return None

SoftwareRegistry

Bases: BaseModel

software_list property

software_list: List[str]

Helper to get software as a list for easier UI rendering.

load classmethod

load(force_refresh: bool = False) -> SoftwareRegistry

Modified to mirror the AllSoftware migration and loading logic.

Source code in alc\config\software.py
@classmethod
def load(cls, force_refresh: bool = False) -> SoftwareRegistry:
    """
    Modified to mirror the AllSoftware migration and loading logic.
    """
    from alc.config import CFG

    # Resolve paths from the live CFG singleton
    config_dir = Path(CFG.cookbook_dir) / "config_files"
    json_path = config_dir / "software.json"

    # 2. Load the base data
    data = load_json(json_path)
    for id, sw_data in data["software"].items():
        sw_data["id"] = id

    instance = cls.model_validate(data)
    instance.json_path = json_path
    instance.hydrate()
    if force_refresh:
        return instance.save()

    return instance

ext_index

ext_index() -> Dict[str, str]

Map .ext -> software id, built from the registry's extension attrs. source_extensions win over output_extensions (a .ma is a Maya scene, not whatever exports .ma); within a tier, registry order decides ties. Cached until the next save().

Source code in alc\config\software.py
def ext_index(self) -> Dict[str, str]:
    """Map ``.ext`` -> software id, built from the registry's extension
    attrs. ``source_extensions`` win over ``output_extensions`` (a .ma is a
    Maya scene, not whatever exports .ma); within a tier, registry order
    decides ties. Cached until the next save()."""
    if self._ext_index is None:
        index: Dict[str, str] = {}
        for name, sw in self.software.items():
            for ext in sw.output_extensions:
                index.setdefault(ext.lower(), name)
        for name, sw in self.software.items():
            for ext in sw.source_extensions:
                index[ext.lower()] = name
        self._ext_index = index
    return self._ext_index

software_for_ext

software_for_ext(ext: str) -> Optional[Software]

The software that owns a file extension (e.g. '.blend' -> Blender).

Source code in alc\config\software.py
def software_for_ext(self, ext: str) -> Optional[Software]:
    """The software that owns a file extension (e.g. '.blend' -> Blender)."""
    sid = self.ext_index().get((ext or "").lower())
    return self.software.get(sid) if sid else None

icon_for_path

icon_for_path(filename: str) -> Optional[str]

Best icon url (svg > png > None) for a file, keyed on its extension. The single entry point the UI uses to badge a file with its app icon.

Source code in alc\config\software.py
def icon_for_path(self, filename: str) -> Optional[str]:
    """Best icon url (svg > png > None) for a file, keyed on its extension.
    The single entry point the UI uses to badge a file with its app icon."""
    sw = self.software_for_ext(Path(filename or "").suffix)
    return sw.best_icon_url if sw else None

hydrate

hydrate() -> SoftwareRegistry

The Master Blender: Merges Web Metadata with local discovery.

Source code in alc\config\software.py
def hydrate(self) -> SoftwareRegistry:
    """
    The Master Blender: Merges Web Metadata with local discovery.
    """
    # 1. Map categories and icons

    seen_cats = []
    for name, software in self.software.items():
        # software.id = name
        # Sync active state with the registry's list
        software.is_active = name in self.active_tools
        software.check_icon()
        # Perform disk-check for icons

        if software.category not in seen_cats:
            seen_cats.append(software.category)

    self.categories = seen_cats

    # 2. Inject HSL Colors based on category index
    for tool in self.software.values():
        if software.category in self.categories:
            idx = self.categories.index(software.category)
            hue = (360 // max(len(self.categories), 1)) * idx
            software.ui_color = f"hsl({hue}, 60%, 65%)"

    # 3. TODO: Run DCC Version discovery logic (local install scanning)
    # This is where your 'setup.py' plugins will eventually hook in.

    return self

save

save(path: Optional[Path] = None) -> SoftwareRegistry

Persists state back to software.json.

Source code in alc\config\software.py
def save(self, path: Optional[Path] = None) -> SoftwareRegistry:
    """Persists state back to software.json."""
    target_path = path or self.json_path
    if not target_path:
        from alc.config.env import ENV

        target_path = ENV.software_config_file

    # Merge over the existing file so disk-only fields the model drops on
    # load (api / api_notes / price_install / flat version) survive a save.
    existing = load_json(target_path) or {}
    existing_software = existing.get("software", {})
    for name, tool in self.software.items():
        merged = dict(existing_software.get(name, {}))
        merged.update(tool.to_dict())
        existing_software[name] = merged

    output = {
        "active_tools": self.active_tools,
        "software": existing_software,
    }
    save_json(target_path, output)
    return self

get_column

get_column(category: str) -> List[Software]

Returns tools for a shelf, sorted by status and name.

Source code in alc\config\software.py
def get_column(self, category: str) -> List[Software]:
    """Returns tools for a shelf, sorted by status and name."""
    column = [t for t in self.software.values() if t.category == category]
    return sorted(column, key=lambda x: (not x.is_active, x.label))

update_tool

update_tool(name: str, key: str, value: any)

Generic editor for software metadata.

Source code in alc\config\software.py
def update_tool(self, name: str, key: str, value: any):
    """Generic editor for software metadata."""
    if name in self.software:
        setattr(self.software[name], key, value)
        self.save()

toggle_tool

toggle_tool(tool_name: str)

Toggles the tool state and ensures the object is synced for the UI.

Source code in alc\config\software.py
def toggle_tool(self, tool_name: str):
    """Toggles the tool state and ensures the object is synced for the UI."""
    if tool_name in self.active_tools:
        self.active_tools.remove(tool_name)
        new_state = False
    else:
        self.active_tools.append(tool_name)
        new_state = True

    # Update the actual object in the dictionary immediately
    if tool_name in self.software:
        self.software[tool_name].is_active = new_state

    # Now save the state to disk
    self.save()

get_category_color

get_category_color(category: str) -> str

Helper to get the HSL color for a category based on the first tool found.

Source code in alc\config\software.py
def get_category_color(self, category: str) -> str:
    """Helper to get the HSL color for a category based on the first tool found."""
    for tool in self.software.values():
        if tool.category == category:
            return tool.ui_color
    return "hsl(0, 0%, 50%)"

UserConfig

Bases: BaseModel

is_installed

is_installed(
    dcc: str, version: Optional[str] = None
) -> bool

Consolidated DCC check. Example: user_cfg.is_installed('maya', '2024')

Source code in alc\config\user.py
def is_installed(self, dcc: str, version: Optional[str] = None) -> bool:
    """
    Consolidated DCC check.
    Example: user_cfg.is_installed('maya', '2024')
    """
    versions = self.installs.get(dcc.lower(), [])
    if not versions:
        return False
    if version:
        return str(version) in [str(v) for v in versions]
    return True

__getattr__

__getattr__(name: str)

Magic Method: Allows for user_cfg.maya_installed style calls without writing 50 properties.

Source code in alc\config\user.py
def __getattr__(self, name: str):
    """
    Magic Method: Allows for user_cfg.maya_installed style calls
    without writing 50 properties.
    """
    if name.endswith("_installed"):
        dcc = name.replace("_installed", "")
        return self.is_installed(dcc)
    raise AttributeError(
        f"'{self.__class__.__name__}' object has no attribute '{name}'"
    )

commit

commit()

Writes current state to disk.

Source code in alc\config\user.py
def commit(self):
    """Writes current state to disk."""

    # We use model_dump (dict) because save_json usually expects a dict,
    # or use model_dump_json if you are writing raw text.
    data = self.model_dump(by_alias=True, exclude_none=True)

    # Ensure directory exists
    self.path.parent.mkdir(parents=True, exist_ok=True)

    # Save logic
    save_json(str(self.path), data)
    print(f"Config saved to {self.path}")

edit

edit(**kwargs) -> 'UserConfig'

Updates the current instance with new values and commits.

Source code in alc\config\user.py
def edit(self, **kwargs) -> "UserConfig":
    """
    Updates the current instance with new values and commits.
    """
    for key, value in kwargs.items():
        if hasattr(self, key):
            setattr(self, key, value)

    self.commit()
    return self

update_installs

update_installs(
    dcc: str, version: LocalSoftwareVersion
) -> "UserConfig"

Helper to manage the 'installs' dictionary specifically.

Source code in alc\config\user.py
def update_installs(self, dcc: str, version: LocalSoftwareVersion) -> "UserConfig":
    """
    Helper to manage the 'installs' dictionary specifically.
    """
    dcc_key = dcc.lower()
    if dcc_key not in self.installs:
        self.installs[dcc_key] = []

    if version not in self.installs[dcc_key]:
        self.installs[dcc_key].append(version)

    self.commit()
    return self

set_dcc

set_dcc(dcc='alchemy')

Returns:

Source code in alc\config\env.py
def set_dcc(dcc="alchemy"):
    """

    Returns:

    """
    os.environ["ALC_DCC"] = dcc
    logger.info(f"Setting ALC_DCC variable: {dcc}")
    return dcc

init_logging

init_logging(level=logging.INFO)

Standardized logging config for all Alchemy scripts. Ensures -u unbuffered output works correctly with our terminal stream.

Source code in alc\config\config.py
def init_logging(level=logging.INFO):
    """
    Standardized logging config for all Alchemy scripts.
    Ensures -u unbuffered output works correctly with our terminal stream.
    """
    logging.basicConfig(
        level=level,
        format="%(levelname)s: %(message)s",
        handlers=[
            logging.StreamHandler(
                sys.stdout
            )  # Explicitly route to stdout for our console
        ],
        force=True,  # Overrides any existing config
    )
    # Optional: silence noisy third-party loggers
    logging.getLogger("PIL").setLevel(logging.WARNING)