"""Skills Hub GitHub adapter: API auth, tap providers, and the Contents/Trees source.""" import json import logging import subprocess import time from pathlib import Path from typing import Dict, List, Optional, Tuple, Union from urllib.parse import quote import httpx from hermes_cli._subprocess_compat import windows_hide_flags from agent.retry_utils import parse_retry_after_seconds from tools.skills_guard import TRUSTED_REPOS from tools.skills_hub_models import ( SkillBundle, SkillMeta, SkillSource, _cache_metas, _cached_metas, _dedupe_by_trust, _hermes_tags, _matches_query, _parse_frontmatter, _referenced_support_paths, hub, _validate_bundle_rel_path, ) logger = logging.getLogger("tools.skills_hub") # GitHub tap repo (owner/repo) -> provider label used by the docs-site catalog # (website/scripts/extract-skills.py::GITHUB_TAP_LABELS). The runtime index collapses every tap into # source="github"; ``extra.provider`` keeps per-tap identity searchable/filterable without disturbing # dedup / floor / index-skip logic keyed on the bare source id. GITHUB_TAP_PROVIDERS = { "openai/skills": "OpenAI", "anthropics/skills": "Anthropic", "huggingface/skills": "HuggingFace", "nvidia/skills": "NVIDIA", "voltagent/awesome-agent-skills": "VoltAgent", "garrytan/gstack": "gstack", "minimax-ai/cli": "MiniMax", } # Accepted ``--source`` provider filters (lowercased). Not real source ids — # they narrow merged results to GitHub-tap skills carrying that ``extra.provider``. _PROVIDER_FILTER_VALUES = frozenset(v.lower() for v in GITHUB_TAP_PROVIDERS.values()) _API = "https://api.github.com/repos" _ACCEPT_JSON = "application/vnd.github.v3+json" def github_provider_for(repo: str) -> Optional[str]: """Provider label for an ``owner/repo`` tap (case-insensitive), or None.""" return GITHUB_TAP_PROVIDERS.get(repo.strip().lower()) if repo else None def _filter_results_by_provider(results: List[SkillMeta], provider: str) -> List[SkillMeta]: """Keep only results whose ``extra.provider`` matches ``provider``. An explicit provider filter (``--source nvidia``) narrows to exactly that provider — the official catalog is NOT injected the way unfiltered browse does.""" want = provider.strip().lower() return [r for r in results if str((r.extra or {}).get("provider", "")).lower() == want] def _provider_filter_of(source_filter: str) -> str: """Normalized provider filter when ``--source`` names one (nvidia/openai/...), else ``""``.""" value = source_filter.strip().lower() return value if value in _PROVIDER_FILTER_VALUES else "" def _tap_cache_key(repo: str, path: str, bucket: Optional[str] = None) -> str: """Disk-cache key for one tap's skill listing (tests seed that cache through it too).""" return f"{repo}_{path}_{bucket or ''}".replace("/", "_").replace(" ", "_") def _is_rate_limit_response(resp: httpx.Response) -> bool: """403 with exhausted quota, or any 429.""" return resp.status_code == 429 or ( resp.status_code == 403 and resp.headers.get("X-RateLimit-Remaining", "") == "0" ) class GitHubAuth: """GitHub API authentication, tried in priority order: GITHUB_TOKEN / GH_TOKEN (PAT), `gh auth token` (gh CLI), GitHub App JWT + installation token, then unauthenticated (60 req/hr, public repos only).""" def __init__(self): self._cached_token: Optional[str] = None self._cached_method: Optional[str] = None self._app_token_expiry: float = 0 def get_headers(self) -> Dict[str, str]: token = self._resolve_token() return {"Accept": _ACCEPT_JSON, **({"Authorization": f"token {token}"} if token else {})} def is_authenticated(self) -> bool: return self._resolve_token() is not None def auth_method(self) -> str: """'pat', 'gh-cli', 'github-app', or 'anonymous'.""" self._resolve_token() return self._cached_method or "anonymous" def _resolve_token(self) -> Optional[str]: if self._cached_token and (self._cached_method != "github-app" or time.time() < self._app_token_expiry): return self._cached_token for method, resolve in ( ("pat", self._try_pat), ("gh-cli", self._try_gh_cli), ("github-app", self._try_github_app), ): token = resolve() if token: self._cached_token, self._cached_method = token, method if method == "github-app": self._app_token_expiry = time.time() + 3500 # ~58 min (tokens last 1 hour) return token self._cached_method = "anonymous" return None @staticmethod def _try_pat() -> Optional[str]: # Profile-scoped secret lookup (multiplexed gateway safe). from agent.secret_scope import get_secret return get_secret("GITHUB_TOKEN") or get_secret("GH_TOKEN") def _try_gh_cli(self) -> Optional[str]: try: result = subprocess.run( ["gh", "auth", "token"], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() except (FileNotFoundError, subprocess.TimeoutExpired) as e: logger.debug("gh CLI token lookup failed: %s", e) return None def _try_github_app(self) -> Optional[str]: from agent.secret_scope import get_secret app_id, key_path = get_secret("GITHUB_APP_ID"), get_secret("GITHUB_APP_PRIVATE_KEY_PATH") installation_id = get_secret("GITHUB_APP_INSTALLATION_ID") if not all([app_id, key_path, installation_id]): return None try: import jwt # PyJWT except ImportError: logger.debug("PyJWT not installed, skipping GitHub App auth") return None try: key_file = Path(key_path) if not key_file.exists(): return None now = int(time.time()) encoded_jwt = jwt.encode( {"iat": now - 60, "exp": now + (10 * 60), "iss": app_id}, key_file.read_text(encoding="utf-8-sig"), algorithm="RS256", ) resp = httpx.post( f"https://api.github.com/app/installations/{installation_id}/access_tokens", headers={"Authorization": f"Bearer {encoded_jwt}", "Accept": _ACCEPT_JSON}, timeout=10, ) if resp.status_code == 201: return resp.json().get("token") except Exception as e: logger.debug("GitHub App auth failed: %s", e) return None def _split_repo_id(identifier: str) -> Optional[Tuple[str, str]]: """``owner/repo/path/to/skill`` -> ``(owner/repo, path/to/skill)``; None when too short.""" parts = identifier.split("/", 2) return (f"{parts[0]}/{parts[1]}", parts[2]) if len(parts) >= 3 else None def _skill_file_path(skill_path: str, filename: str = "SKILL.md") -> str: """Path of ``filename`` inside a skill directory. An empty ``skill_path`` means the skill directory IS the repo root — the single-skill layout some skills.sh repos use (``SKILL.md`` and its ``references/``/``scripts/`` next to ``README.md``).""" return f"{skill_path}/{filename}" if skill_path else filename def _skip_bundle_file(rel_path: str) -> bool: """Dotfiles, bytecode and __pycache__ never ship in a bundle.""" base = rel_path.rsplit("/", 1)[-1] return base.startswith(".") or base.endswith(".pyc") or "__pycache__" in rel_path.split("/") def _tree_members(entries: List[dict], prefix: str): """``(rel_path, item_path, is_regular_blob)`` for every git-tree entry under ``prefix``. Symlinks (mode 120000) and non-blobs report ``is_regular_blob=False`` so callers can reject a SKILL.md-linked symlink instead of silently following it.""" for item in entries: item_path = item.get("path", "") if item_path.startswith(prefix): yield item_path[len(prefix):], item_path, item.get("type") == "blob" and item.get("mode") != "120000" class GitHubSource(SkillSource): """Fetch skills from GitHub repos via the Contents API.""" DEFAULT_TAPS = [ # openai/skills keeps content under skills/.curated/ + skills/.system/; _list_skills_in_repo # skips "."/"_" directories, so both entries point at the inner paths. {"repo": "openai/skills", "path": "skills/.curated/"}, {"repo": "openai/skills", "path": "skills/.system/"}, {"repo": "anthropics/skills", "path": "skills/"}, {"repo": "huggingface/skills", "path": "skills/"}, # NVIDIA-verified skills (CUDA-X, NeMo, cuOpt, ...), each with a signed skill.oms.sig # + governance card; `trusted` via tools/skills_guard.py::TRUSTED_REPOS. {"repo": "NVIDIA/skills", "path": "skills/"}, {"repo": "garrytan/gstack", "path": ""}, # --- Science bucket --- # Two scientific-skill repos share one hub category via the tap-level "bucket" key so # their skills surface together. Both stay `community` trust on purpose (NOT in # tools/skills_guard.py::TRUSTED_REPOS): the guard scans every skill and INSTALL_POLICY # auto-installs only "safe" ones. Skills wrap third-party tools with their OWN licenses # (some GPL; KEGG is commercial for non-academic use) — surfaced per skill, not vetted here. # K-Dense-AI/scientific-agent-skills: flat skills//, MIT. {"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/", "bucket": "science"}, # synthetic-sciences/openscience: Apache-2.0, nested backend/cli/skills///. # _list_skills_in_repo walks ONE level under a tap path, so each category is its own tap # (same one-entry-per-inner-path pattern as openai/skills above). *( {"repo": "synthetic-sciences/openscience", "path": f"backend/cli/skills/{_cat}/", "bucket": "science"} for _cat in ( "biology", "chemistry", "cloud-compute", "coding", "data-engineering", "databases", "document-parsing", "llm-tools", "ml-inference", "ml-training", "other", "physics", "quantum", "research", "scholar-evaluation", "visualization", "writing", ) ), ] SOURCE_ID = "github" _parse_frontmatter_quick = staticmethod(_parse_frontmatter) def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None): self.auth = auth self.taps = list(self.DEFAULT_TAPS) + list(extra_taps or []) # Per-instance repo -> (default_branch, tree_entries); lives for one # search/install flow so repeated tree lookups cost no API calls. self._tree_cache: Dict[str, Optional[Tuple[str, List[dict]]]] = {} self._tree_revisions: Dict[str, str] = {} # repo -> skills.sh.json grouping map; None = fetched, no sidecar. self._skillsh_groupings: Dict[str, Optional[Dict[str, str]]] = {} self._rate_limited: bool = False @property def is_rate_limited(self) -> bool: # whether the GitHub API rate limit was hit during operations return self._rate_limited def trust_level_for(self, identifier: str) -> str: # identifier format: "owner/repo/path/to/skill" parts = identifier.split("/", 2) return "trusted" if len(parts) >= 2 and f"{parts[0]}/{parts[1]}" in TRUSTED_REPOS else "community" def search(self, query: str, limit: int = 10, *, provider_filter: str = "") -> List[SkillMeta]: """Substring-match taps, skip taps outside a provider filter, then dedupe by identifier preferring higher trust and limit.""" results: List[SkillMeta] = [] query_lower = query.lower() want = provider_filter.strip().lower() for tap in self.taps: # The tap repo fixes every result's provider, so wrong-provider taps can be # skipped before their enumeration cost (cache reads or GitHub API calls). if want and (github_provider_for(tap["repo"]) or "").lower() != want: continue try: for skill in self._list_skills_in_repo(tap["repo"], tap.get("path", ""), tap.get("bucket")): if _matches_query(query_lower, skill.name, skill.description, skill.tags): results.append(skill) except Exception as e: logger.debug("Failed to search %s: %s", tap['repo'], e) return _dedupe_by_trust(results)[:limit] def fetch(self, identifier: str) -> Optional[SkillBundle]: """Download a skill; identifier format: "owner/repo/path/to/skill-dir".""" if (split := _split_repo_id(identifier)) is None: return None repo, skill_path = split skill_dir = skill_path.rstrip("/") # Resolve the tree FIRST so every byte fetch — SKILL.md included — is pinned to the # same revision; an unpinned /contents fetch floats to HEAD and can serve bytes newer # than the tree the paths were validated against (TOCTOU). Idempotent + cached. tree = self._get_repo_tree(repo) pinned_ref = self._tree_revisions.get(repo) skill_md = self._fetch_file_content(repo, _skill_file_path(skill_dir), ref=pinned_ref) if skill_md is None: return None referenced = _referenced_support_paths(skill_md) if referenced is None: return None files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md} if tree is not None: complete = self._collect_tree_files(repo, skill_dir, tree[1], pinned_ref, referenced, files) if complete is None: return None # A bundle with a transiently failed blob fetch must not record the tree sha: the # update check would otherwise see "same revision" and never re-fetch the gap (#101454). revision = (pinned_ref or tree[0]) if complete else "" else: for rel_path in referenced: self._add_support_file(repo, _skill_file_path(skill_dir, rel_path), rel_path, files, rel_path) revision = "" url = (f"https://github.com/{repo}/tree/{revision}" + (f"/{skill_path}" if skill_path else "") if revision else f"https://github.com/{repo}/{skill_path}") return SkillBundle( name=skill_dir.split("/")[-1] or repo.split("/")[-1], files=files, source="github", identifier=identifier, trust_level=self.trust_level_for(identifier), metadata={"source_url": url, "source_revision": revision}, ) def current_revision(self, identifier: str) -> str: """Tree sha the default branch currently resolves to — one cached tree lookup per repo, no blob downloads — so an update check can skip refetching unchanged skills.""" if (split := _split_repo_id(identifier)) is None: return "" repo, _ = split self._get_repo_tree(repo) # populates _tree_revisions return self._tree_revisions.get(repo, "") def _add_support_file(self, repo: str, item_path: str, rel_path: str, files: dict, shown: str, **kw) -> bool: """Fetch one support file into ``files``; a failed fetch warns (naming ``shown``), is skipped, and returns False.""" content = self._fetch_file_bytes(repo, item_path, **kw) if content is None: logger.warning("Failed to fetch referenced skill support file; continuing without it: %s", shown) return False files[rel_path] = content return True def _collect_tree_files( self, repo: str, skill_path: str, entries: List[dict], ref: Optional[str], referenced: set, files: Dict[str, Union[str, bytes]], ) -> Optional[bool]: """Download the FULL skill directory from the pinned tree into ``files``. Link-driven fetching silently dropped support files under non-canonical dirs (``reference/``, ``agents/``, root LICENSE); everything still goes through quarantine + scan, and the scanner sees MORE this way. Returns None (bundle rejected) on an unsafe path or a SKILL.md-linked path that exists in the tree as a symlink/non-blob — that shape is an escape attempt. A linked path that is simply absent is a dangling link (repo-only dev tool, prose over-match): warn and install without it. Returns False when a blob fetch failed (installed with a gap the next update check must be able to fill). An empty ``skill_path`` is the repo-root skill layout, so the whole repo root is its directory.""" prefix = f"{skill_path}/" if skill_path else "" symlinked: set = set() complete = True for rel_path, item_path, regular in _tree_members(entries, prefix): if not regular: symlinked.add(rel_path) continue if rel_path == "SKILL.md" or _skip_bundle_file(rel_path): continue try: rel_path = _validate_bundle_rel_path(rel_path) except ValueError: logger.warning("Rejected unsafe file path in skill bundle: %s", item_path) return None complete &= self._add_support_file(repo, item_path, rel_path, files, item_path, ref=ref) for rel_path in sorted(referenced): # A SKILL.md-linked support path that isn't in the tree is a dangling link — a repo-only dev # tool, prose over-match, or a file the author forgot to push. Warn and install without it # rather than aborting the whole install (#66760/#90081): the skill body still works, and the # gap is visible in the log. A referenced path that IS in the tree but as a symlink (or any # non-regular entry) stays a hard rejection — that shape is an escape attempt, not a forgotten # file. if rel_path in symlinked: logger.warning("Rejected non-regular referenced file in skill bundle: %s%s", prefix, rel_path) return None if rel_path not in files: logger.warning( "Referenced skill support file is missing; continuing without it: %s%s", prefix, rel_path) return complete def inspect(self, identifier: str) -> Optional[SkillMeta]: """Fetch just the SKILL.md metadata for preview.""" if (split := _split_repo_id(identifier)) is None: return None repo, skill_path = split[0], split[1].rstrip("/") content = self._fetch_file_content(repo, _skill_file_path(skill_path)) if not content: return None fm = _parse_frontmatter(content) tags = _hermes_tags(fm) or (fm["tags"] if isinstance(fm.get("tags"), list) else []) provider = github_provider_for(repo) return SkillMeta( name=fm.get("name", skill_path.split("/")[-1] or repo.split("/")[-1]), description=str(fm.get("description", "")), source="github", identifier=identifier, trust_level=self.trust_level_for(identifier), repo=repo, path=skill_path, tags=[str(t) for t in tags], extra={"provider": provider} if provider else {}, ) # -- Internal helpers -- def _list_skills_in_repo(self, repo: str, path: str, bucket: Optional[str] = None) -> List[SkillMeta]: """List skill directories in a GitHub repo path, using cached index. ``bucket`` labels every skill from a tap whose repo ships no ``skills.sh.json`` grouping, so several repos can share one hub category (e.g. "science"); a sidecar grouping still wins when present.""" cache_key = _tap_cache_key(repo, path, bucket) cached = _cached_metas(cache_key) if cached is not None: return cached resp = self._github_get(f"{_API}/{repo}/contents/{path.rstrip('/')}") if resp is None or resp.status_code != 200: return [] entries = resp.json() if not isinstance(entries, list): return [] skills: List[SkillMeta] = [] groupings = self._get_skillsh_groupings(repo) prefix = path.rstrip("/") for entry in entries: if entry.get("type") != "dir" or entry["name"].startswith((".", "_")): continue dir_name = entry["name"] meta = self.inspect(f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}") if meta: category = (groupings and (groupings.get(meta.name) or groupings.get(dir_name))) or bucket if category: meta.extra["category"] = category skills.append(meta) _cache_metas(cache_key, skills) return skills def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]: """Cached ``(default_branch, tree_entries)`` for a repo, or None. One install may need the tree several times; caching saves the ``GET /repos/{repo}`` + ``GET .../git/trees/{branch}`` pair each time (~12 of the 60/hr unauthenticated budget before).""" if repo in self._tree_cache: return self._tree_cache[repo] # Misses are cached too: within one command a truncated/unreachable tree stays that way, # and the update check now probes the tree before every fetch (#101454). self._tree_cache[repo] = None repo_data = self._github_json(f"{_API}/{repo}") if repo_data is None: return None default_branch = repo_data.get("default_branch", "main") tree_data = self._github_json( f"{_API}/{repo}/git/trees/{default_branch}", params={"recursive": "1"}, timeout=30.0, ) if tree_data is None: return None if tree_data.get("truncated"): logger.debug("Git tree truncated for %s", repo) return None if isinstance(tree_data.get("sha"), str) and tree_data["sha"]: self._tree_revisions[repo] = tree_data["sha"] self._tree_cache[repo] = tree = (default_branch, tree_data.get("tree", [])) return tree def _github_json(self, url: str, **kwargs) -> Optional[dict]: """Decoded JSON body of a 200 ``_github_get`` (which flags rate-limit exhaustion), else None.""" resp = self._github_get(url, **kwargs) try: return resp.json() if resp is not None and resp.status_code == 200 else None except ValueError: return None def _github_get( self, url: str, *, params: Optional[Dict] = None, headers: Optional[Dict] = None, timeout: float = 15.0, max_retries: int = 3, ) -> Optional[httpx.Response]: """GET against the GitHub API with retry/backoff on transient failures. Returns the final response (caller inspects status) or None when every attempt raised a transport error. Retries rate-limit 403/429 (waiting until ``Retry-After`` / ``X-RateLimit-Reset`` when present, capped 60s — one shared limit zeroes every GitHub tap at once during an index build), 5xx, and transport errors with exponential backoff. Terminal rate-limit exhaustion flags the instance so an index build fails loud instead of silently shipping zero GitHub skills.""" hdrs = headers if headers is not None else self.auth.get_headers() backoff = 1.0 last_resp: Optional[httpx.Response] = None for attempt in range(max_retries): last_attempt = attempt >= max_retries - 1 wait = backoff try: resp = hub()._skills_hub_http_get( url, params=params, headers=hdrs, timeout=timeout, follow_redirects=True ) except httpx.HTTPError as e: logger.debug("GitHub GET %s failed (attempt %d/%d): %s", url, attempt + 1, max_retries, e) if last_attempt: return None else: last_resp = resp if resp.status_code == 200: return resp if resp.status_code in (403, 429): limited = _is_rate_limit_response(resp) if not limited or last_attempt: if limited: # terminal exhaustion: flag the instance so callers fail loud self._rate_limited = True logger.warning("GitHub API rate limit exhausted (unauthenticated: 60 req/hr). " "Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr.") return resp reset = resp.headers.get("X-RateLimit-Reset", "") retry_after = parse_retry_after_seconds(resp.headers) if retry_after is not None: wait = min(retry_after, 60.0) elif reset.isdigit(): delta = float(reset) - time.time() if 0 < delta <= 60.0: wait = delta logger.debug("GitHub rate limited on %s, waiting %.1fs (attempt %d/%d)", url, wait, attempt + 1, max_retries) elif not (500 <= resp.status_code < 600) or last_attempt: return resp time.sleep(wait) backoff = min(backoff * 2, 30.0) return last_resp def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]: """Locate ``/SKILL.md`` anywhere in the repo tree (one API call); full identifier or None.""" if (cached := self._get_repo_tree(repo)) is None: return None skill_md_suffix = f"/{skill_name}/SKILL.md" for entry in cached[1]: path = entry.get("path", "") if entry.get("type") == "blob" and (path.endswith(skill_md_suffix) or path == skill_md_suffix[1:]): return f"{repo}/{path[: -len('/SKILL.md')]}" return None def _find_repo_root_skill(self, repo: str) -> Optional[str]: """Identifier for a single-skill repo whose ``SKILL.md`` sits at the repo ROOT (no skill directory) — e.g. ``orzcls/win-disk-cleaner``. The empty path segment (``owner/repo/``) denotes the skill directory being the repo root. Only repos with EXACTLY ONE SKILL.md in the whole tree qualify, so a categorized multi-skill repo never resolves here.""" tree = self._get_repo_tree(repo) if tree is None: return None skill_mds = [ entry.get("path", "") for entry in tree[1] if entry.get("type") == "blob" and entry.get("mode") != "120000" and (entry.get("path", "") == "SKILL.md" or entry.get("path", "").endswith("/SKILL.md")) ] return f"{repo}/" if skill_mds == ["SKILL.md"] else None def _fetch_file_content(self, repo: str, path: str, ref: Optional[str] = None) -> Optional[str]: """Fetch a single text file from GitHub (None on miss or non-UTF-8).""" content = self._fetch_file_bytes(repo, path, ref=ref) try: return None if content is None else content.decode("utf-8") except UnicodeDecodeError: return None def _fetch_file_bytes(self, repo: str, path: str, ref: Optional[str] = None) -> Optional[bytes]: """Fetch exact file bytes. ``ref`` pins to a tree SHA (see ``fetch`` on the TOCTOU); None keeps the legacy unpinned behavior.""" resp = self._github_get( f"{_API}/{repo}/contents/{quote(path, safe='/')}", params={"ref": ref} if ref else None, headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"}, ) return resp.content if resp is not None and resp.status_code == 200 else None def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]: """Repo-root ``skills.sh.json`` groupings flattened to ``{skill_name: title}``. ``skills.sh.json`` is a cross-ecosystem standard (``$schema: https://skills.sh/schemas/skills.sh.schema.json``); any tap shipping it gets category pills for free. None when absent/unparsable; cached per repo.""" if repo not in self._skillsh_groupings: content = self._fetch_file_content(repo, "skills.sh.json") self._skillsh_groupings[repo] = self._parse_skillsh_groupings(content) if content else None return self._skillsh_groupings[repo] @staticmethod def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]: """Flatten ``{"groupings": [{"title", "skills": [...]}]}``; None if not usable.""" try: data = json.loads(content) except (json.JSONDecodeError, TypeError): return None groupings = data.get("groupings") if isinstance(data, dict) else None if not isinstance(groupings, list): return None mapping: Dict[str, str] = {} for group in groupings: if not isinstance(group, dict): continue title, members = group.get("title"), group.get("skills") if not isinstance(title, str) or not isinstance(members, list): continue for member in members: if isinstance(member, str) and member: mapping.setdefault(member, title) # first grouping wins return mapping