custom_components/bahmcloud_store/metadata.py aktualisiert
This commit is contained in:
@@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RepoMetadata:
|
class RepoMetadata:
|
||||||
source: str | None = None # "bcs.yaml" | "hacs.yaml" | "hacs.json"
|
source: str | None = None # "bcs.yaml" | "hacs.yaml" | "hacs.json" | None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
@@ -23,63 +23,13 @@ class RepoMetadata:
|
|||||||
maintainer: str | None = None
|
maintainer: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _clean_str(v: Any) -> str | None:
|
def _normalize_repo_name(name: str | None) -> str | None:
|
||||||
if v is None:
|
if not name:
|
||||||
return None
|
return None
|
||||||
if isinstance(v, (int, float)):
|
n = name.strip()
|
||||||
v = str(v)
|
if n.endswith(".git"):
|
||||||
if not isinstance(v, str):
|
n = n[:-4]
|
||||||
return None
|
return n or None
|
||||||
s = v.strip()
|
|
||||||
return s or None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_common_fields(data: dict[str, Any]) -> RepoMetadata:
|
|
||||||
"""
|
|
||||||
Best-effort extraction across possible schemas.
|
|
||||||
We keep this forgiving because third-party repos vary widely.
|
|
||||||
"""
|
|
||||||
md = RepoMetadata()
|
|
||||||
|
|
||||||
# Common / preferred keys for BCS
|
|
||||||
md.name = _clean_str(data.get("name"))
|
|
||||||
md.description = _clean_str(data.get("description"))
|
|
||||||
md.category = _clean_str(data.get("category"))
|
|
||||||
md.author = _clean_str(data.get("author"))
|
|
||||||
md.maintainer = _clean_str(data.get("maintainer"))
|
|
||||||
|
|
||||||
# HACS compatibility fields
|
|
||||||
# Some repos use 'render_readme', 'content_in_root', etc. – ignored for now.
|
|
||||||
# Some use "authors" list or "maintainers" list:
|
|
||||||
if not md.author:
|
|
||||||
a = data.get("authors") or data.get("author")
|
|
||||||
if isinstance(a, list) and a:
|
|
||||||
md.author = _clean_str(a[0])
|
|
||||||
elif isinstance(a, str):
|
|
||||||
md.author = _clean_str(a)
|
|
||||||
|
|
||||||
if not md.maintainer:
|
|
||||||
m = data.get("maintainers") or data.get("maintainer")
|
|
||||||
if isinstance(m, list) and m:
|
|
||||||
md.maintainer = _clean_str(m[0])
|
|
||||||
elif isinstance(m, str):
|
|
||||||
md.maintainer = _clean_str(m)
|
|
||||||
|
|
||||||
# Some HACS style manifests use "documentation" or "info" as description-like
|
|
||||||
if not md.description:
|
|
||||||
md.description = _clean_str(data.get("info")) or _clean_str(data.get("documentation"))
|
|
||||||
|
|
||||||
return md
|
|
||||||
|
|
||||||
|
|
||||||
def _is_github(repo_url: str) -> bool:
|
|
||||||
return "github.com" in urlparse(repo_url).netloc.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_gitea(repo_url: str) -> bool:
|
|
||||||
# We treat self-hosted owner/repo as gitea in this project.
|
|
||||||
host = urlparse(repo_url).netloc.lower()
|
|
||||||
return host and "github.com" not in host and "gitlab.com" not in host
|
|
||||||
|
|
||||||
|
|
||||||
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
|
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
|
||||||
@@ -87,13 +37,24 @@ def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
|
|||||||
parts = [p for p in u.path.strip("/").split("/") if p]
|
parts = [p for p in u.path.strip("/").split("/") if p]
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return None, None
|
return None, None
|
||||||
return parts[0], parts[1]
|
owner = parts[0].strip() or None
|
||||||
|
repo = _normalize_repo_name(parts[1])
|
||||||
|
return owner, repo
|
||||||
|
|
||||||
|
|
||||||
|
def _is_github(repo_url: str) -> bool:
|
||||||
|
return "github.com" in urlparse(repo_url).netloc.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gitea(repo_url: str) -> bool:
|
||||||
|
host = urlparse(repo_url).netloc.lower()
|
||||||
|
return host and ("github.com" not in host) and ("gitlab.com" not in host)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_text(hass: HomeAssistant, url: str) -> str | None:
|
async def _fetch_text(hass: HomeAssistant, url: str) -> str | None:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=15) as resp:
|
async with session.get(url, timeout=20) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
return None
|
return None
|
||||||
return await resp.text()
|
return await resp.text()
|
||||||
@@ -101,64 +62,100 @@ async def _fetch_text(hass: HomeAssistant, url: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_repo_metadata(hass: HomeAssistant, repo_url: str, default_branch: str | None) -> RepoMetadata:
|
def _parse_meta_yaml(raw: str, source: str) -> RepoMetadata:
|
||||||
"""
|
try:
|
||||||
Best-effort metadata resolution from repo root:
|
data = ha_yaml.parse_yaml(raw)
|
||||||
1) bcs.yaml
|
if not isinstance(data, dict):
|
||||||
2) hacs.yaml
|
return RepoMetadata(source=source)
|
||||||
3) hacs.json
|
|
||||||
|
|
||||||
Works for:
|
return RepoMetadata(
|
||||||
- GitHub: raw.githubusercontent.com
|
source=source,
|
||||||
- Gitea: /raw/branch/<branch>/
|
name=data.get("name"),
|
||||||
"""
|
description=data.get("description"),
|
||||||
|
category=data.get("category"),
|
||||||
|
author=data.get("author"),
|
||||||
|
maintainer=data.get("maintainer"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return RepoMetadata(source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meta_hacs_json(raw: str) -> RepoMetadata:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return RepoMetadata(source="hacs.json")
|
||||||
|
|
||||||
|
# HACS metadata is not standardized for description/category across all repos,
|
||||||
|
# but we support common fields and keep them optional.
|
||||||
|
name = data.get("name")
|
||||||
|
description = data.get("description")
|
||||||
|
author = data.get("author")
|
||||||
|
maintainer = data.get("maintainer")
|
||||||
|
|
||||||
|
# Optional: some repos use "category" or "type" for store grouping
|
||||||
|
category = data.get("category") or data.get("type")
|
||||||
|
|
||||||
|
return RepoMetadata(
|
||||||
|
source="hacs.json",
|
||||||
|
name=name if isinstance(name, str) else None,
|
||||||
|
description=description if isinstance(description, str) else None,
|
||||||
|
category=category if isinstance(category, str) else None,
|
||||||
|
author=author if isinstance(author, str) else None,
|
||||||
|
maintainer=maintainer if isinstance(maintainer, str) else None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return RepoMetadata(source="hacs.json")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_repo_metadata(hass: HomeAssistant, repo_url: str, default_branch: str | None) -> RepoMetadata:
|
||||||
owner, repo = _split_owner_repo(repo_url)
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
if not owner or not repo:
|
if not owner or not repo:
|
||||||
return RepoMetadata()
|
return RepoMetadata()
|
||||||
|
|
||||||
branch = default_branch or "main"
|
branch = default_branch or "main"
|
||||||
|
|
||||||
|
# Priority:
|
||||||
|
# 1) bcs.yaml
|
||||||
|
# 2) hacs.yaml
|
||||||
|
# 3) hacs.json
|
||||||
|
filenames = ["bcs.yaml", "hacs.yaml", "hacs.json"]
|
||||||
|
|
||||||
candidates: list[tuple[str, str]] = []
|
candidates: list[tuple[str, str]] = []
|
||||||
|
|
||||||
if _is_github(repo_url):
|
if _is_github(repo_url):
|
||||||
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
|
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
|
||||||
candidates = [
|
for fn in filenames:
|
||||||
("bcs.yaml", f"{base}/bcs.yaml"),
|
candidates.append((fn, f"{base}/{fn}"))
|
||||||
("hacs.yaml", f"{base}/hacs.yaml"),
|
|
||||||
("hacs.json", f"{base}/hacs.json"),
|
|
||||||
]
|
|
||||||
elif _is_gitea(repo_url):
|
elif _is_gitea(repo_url):
|
||||||
u = urlparse(repo_url.rstrip("/"))
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
base = f"{u.scheme}://{u.netloc}/{owner}/{repo}/raw/branch/{branch}"
|
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
|
||||||
candidates = [
|
|
||||||
("bcs.yaml", f"{base}/bcs.yaml"),
|
bases = [
|
||||||
("hacs.yaml", f"{base}/hacs.yaml"),
|
f"{root}/raw/branch/{branch}",
|
||||||
("hacs.json", f"{base}/hacs.json"),
|
f"{root}/raw/{branch}",
|
||||||
]
|
]
|
||||||
|
for fn in filenames:
|
||||||
|
for b in bases:
|
||||||
|
candidates.append((fn, f"{b}/{fn}"))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unsupported provider for metadata raw fetch in 0.3.2
|
|
||||||
return RepoMetadata()
|
return RepoMetadata()
|
||||||
|
|
||||||
for source, url in candidates:
|
for fn, url in candidates:
|
||||||
text = await _fetch_text(hass, url)
|
raw = await _fetch_text(hass, url)
|
||||||
if not text:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
if fn.endswith(".json"):
|
||||||
if source.endswith(".json"):
|
meta = _parse_meta_hacs_json(raw)
|
||||||
data = json.loads(text)
|
if meta.source:
|
||||||
if not isinstance(data, dict):
|
return meta
|
||||||
continue
|
|
||||||
else:
|
|
||||||
data = ha_yaml.parse_yaml(text)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
md = _extract_common_fields(data)
|
|
||||||
md.source = source
|
|
||||||
return md
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.debug("Failed parsing %s for %s: %s", source, repo_url, e)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
meta = _parse_meta_yaml(raw, fn)
|
||||||
|
if meta.source:
|
||||||
|
return meta
|
||||||
|
|
||||||
return RepoMetadata()
|
return RepoMetadata()
|
||||||
|
|||||||
Reference in New Issue
Block a user