Refactor(clan_lib): move clan_cli.api into clan_lib.api
This commit is contained in:
246
pkgs/clan-cli/clan_lib/api/modules.py
Normal file
246
pkgs/clan-cli/clan_lib/api/modules.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from clan_cli.cmd import run_no_stdout
|
||||
from clan_cli.errors import ClanCmdError, ClanError
|
||||
from clan_cli.nix import nix_eval
|
||||
|
||||
from . import API
|
||||
|
||||
|
||||
class CategoryInfo(TypedDict):
|
||||
color: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frontmatter:
|
||||
description: str
|
||||
categories: list[str] = field(default_factory=lambda: ["Uncategorized"])
|
||||
features: list[str] = field(default_factory=list)
|
||||
constraints: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def categories_info(self) -> dict[str, CategoryInfo]:
|
||||
category_map: dict[str, CategoryInfo] = {
|
||||
"AudioVideo": {
|
||||
"color": "#AEC6CF",
|
||||
"description": "Applications for presenting, creating, or processing multimedia (audio/video)",
|
||||
},
|
||||
"Audio": {"color": "#CFCFC4", "description": "Audio"},
|
||||
"Video": {"color": "#FFD1DC", "description": "Video"},
|
||||
"Development": {"color": "#F49AC2", "description": "Development"},
|
||||
"Education": {"color": "#B39EB5", "description": "Education"},
|
||||
"Game": {"color": "#FFB347", "description": "Game"},
|
||||
"Graphics": {"color": "#FF6961", "description": "Graphics"},
|
||||
"Social": {"color": "#76D7C4", "description": "Social"},
|
||||
"Network": {"color": "#77DD77", "description": "Network"},
|
||||
"Office": {"color": "#85C1E9", "description": "Office"},
|
||||
"Science": {"color": "#779ECB", "description": "Science"},
|
||||
"System": {"color": "#F5C3C0", "description": "System"},
|
||||
"Settings": {"color": "#03C03C", "description": "Settings"},
|
||||
"Utility": {"color": "#B19CD9", "description": "Utility"},
|
||||
"Uncategorized": {"color": "#C23B22", "description": "Uncategorized"},
|
||||
}
|
||||
|
||||
return category_map
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for category in self.categories:
|
||||
if category not in self.categories_info:
|
||||
msg = f"Invalid category: {category}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def parse_frontmatter(readme_content: str) -> tuple[dict[str, Any] | None, str]:
|
||||
"""
|
||||
Extracts TOML frontmatter from a string
|
||||
|
||||
Raises:
|
||||
- ClanError: If the toml frontmatter is invalid
|
||||
"""
|
||||
# Pattern to match YAML frontmatter enclosed by triple-dashed lines
|
||||
frontmatter_pattern = r"^---\s+(.*?)\s+---\s?+(.*)$"
|
||||
|
||||
# Search for the frontmatter using the pattern
|
||||
match = re.search(frontmatter_pattern, readme_content, re.DOTALL)
|
||||
|
||||
# If a match is found, return the frontmatter content
|
||||
match = re.search(frontmatter_pattern, readme_content, re.DOTALL)
|
||||
|
||||
# If a match is found, parse the TOML frontmatter and return both parts
|
||||
if match:
|
||||
frontmatter_raw, remaining_content = match.groups()
|
||||
try:
|
||||
# Parse the TOML frontmatter
|
||||
frontmatter_parsed = tomllib.loads(frontmatter_raw)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
msg = f"Error parsing TOML frontmatter: {e}"
|
||||
raise ClanError(
|
||||
msg,
|
||||
description="Invalid TOML frontmatter",
|
||||
location="extract_frontmatter",
|
||||
) from e
|
||||
|
||||
return frontmatter_parsed, remaining_content
|
||||
return None, readme_content
|
||||
|
||||
|
||||
def extract_frontmatter(readme_content: str, err_scope: str) -> tuple[Frontmatter, str]:
|
||||
"""
|
||||
Extracts TOML frontmatter from a README file content.
|
||||
|
||||
Parameters:
|
||||
- readme_content (str): The content of the README file as a string.
|
||||
|
||||
Returns:
|
||||
- str: The extracted frontmatter as a string.
|
||||
- str: The content of the README file without the frontmatter.
|
||||
|
||||
Raises:
|
||||
- ValueError: If the README does not contain valid frontmatter.
|
||||
"""
|
||||
frontmatter_raw, remaining_content = parse_frontmatter(readme_content)
|
||||
|
||||
if frontmatter_raw:
|
||||
return Frontmatter(**frontmatter_raw), remaining_content
|
||||
|
||||
# If no frontmatter is found, raise an error
|
||||
msg = "Invalid README: Frontmatter not found."
|
||||
raise ClanError(
|
||||
msg,
|
||||
location="extract_frontmatter",
|
||||
description=f"{err_scope} does not contain valid frontmatter.",
|
||||
)
|
||||
|
||||
|
||||
def has_inventory_feature(module_path: Path) -> bool:
|
||||
readme_file = module_path / "README.md"
|
||||
if not readme_file.exists():
|
||||
return False
|
||||
with readme_file.open() as f:
|
||||
readme = f.read()
|
||||
frontmatter, _ = extract_frontmatter(readme, f"{module_path}")
|
||||
return "inventory" in frontmatter.features
|
||||
|
||||
|
||||
def get_roles(module_path: Path) -> None | list[str]:
|
||||
if not has_inventory_feature(module_path):
|
||||
return None
|
||||
|
||||
roles_dir = module_path / "roles"
|
||||
if not roles_dir.exists() or not roles_dir.is_dir():
|
||||
return []
|
||||
|
||||
return [
|
||||
role.stem # filename without .nix extension
|
||||
for role in roles_dir.iterdir()
|
||||
if role.is_file() and role.suffix == ".nix"
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
description: str
|
||||
readme: str
|
||||
categories: list[str]
|
||||
roles: list[str] | None
|
||||
features: list[str] = field(default_factory=list)
|
||||
constraints: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def get_modules(base_path: str) -> dict[str, str]:
|
||||
cmd = nix_eval(
|
||||
[
|
||||
f"{base_path}#clanInternals.inventory.modules",
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
try:
|
||||
proc = run_no_stdout(cmd)
|
||||
res = proc.stdout.strip()
|
||||
except ClanCmdError as e:
|
||||
msg = "clanInternals might not have inventory.modules attributes"
|
||||
raise ClanError(
|
||||
msg,
|
||||
location=f"list_modules {base_path}",
|
||||
description="Evaluation failed on clanInternals.inventory.modules attribute",
|
||||
) from e
|
||||
modules: dict[str, str] = json.loads(res)
|
||||
return modules
|
||||
|
||||
|
||||
@API.register
|
||||
def get_module_interface(base_path: str, module_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Check if a module exists and returns the interface schema
|
||||
Returns an error if the module does not exist or has an incorrect interface
|
||||
"""
|
||||
cmd = nix_eval([f"{base_path}#clanInternals.moduleSchemas.{module_name}", "--json"])
|
||||
try:
|
||||
proc = run_no_stdout(cmd)
|
||||
res = proc.stdout.strip()
|
||||
except ClanCmdError as e:
|
||||
msg = "clanInternals might not have moduleSchemas attributes"
|
||||
raise ClanError(
|
||||
msg,
|
||||
location=f"list_modules {base_path}",
|
||||
description="Evaluation failed on clanInternals.moduleSchemas attribute",
|
||||
) from e
|
||||
modules_schema: dict[str, Any] = json.loads(res)
|
||||
|
||||
return modules_schema
|
||||
|
||||
|
||||
@API.register
|
||||
def list_modules(base_path: str) -> dict[str, ModuleInfo]:
|
||||
"""
|
||||
Show information about a module
|
||||
"""
|
||||
modules = get_modules(base_path)
|
||||
return {
|
||||
module_name: get_module_info(module_name, Path(module_path))
|
||||
for module_name, module_path in modules.items()
|
||||
}
|
||||
|
||||
|
||||
def get_module_info(
|
||||
module_name: str,
|
||||
module_path: Path,
|
||||
) -> ModuleInfo:
|
||||
"""
|
||||
Retrieves information about a module
|
||||
"""
|
||||
if not module_path.exists():
|
||||
msg = "Module not found"
|
||||
raise ClanError(
|
||||
msg,
|
||||
location=f"show_module_info {module_name}",
|
||||
description="Module does not exist",
|
||||
)
|
||||
module_readme = module_path / "README.md"
|
||||
if not module_readme.exists():
|
||||
msg = "Module not found"
|
||||
raise ClanError(
|
||||
msg,
|
||||
location=f"show_module_info {module_name}",
|
||||
description="Module does not exist or doesn't have any README.md file",
|
||||
)
|
||||
with module_readme.open() as f:
|
||||
readme = f.read()
|
||||
frontmatter, readme_content = extract_frontmatter(
|
||||
readme, f"{module_path}/README.md"
|
||||
)
|
||||
|
||||
return ModuleInfo(
|
||||
description=frontmatter.description,
|
||||
categories=frontmatter.categories,
|
||||
roles=get_roles(module_path),
|
||||
readme=readme_content,
|
||||
features=["inventory"] if has_inventory_feature(module_path) else [],
|
||||
constraints=frontmatter.constraints,
|
||||
)
|
||||
Reference in New Issue
Block a user