|
7 | 7 | import time |
8 | 8 | import urllib.error |
9 | 9 | import urllib.request |
| 10 | +from dataclasses import dataclass |
10 | 11 | from pathlib import Path |
11 | 12 | from typing import Any |
12 | 13 |
|
|
18 | 19 |
|
19 | 20 | console = Console(stderr=True) |
20 | 21 |
|
| 22 | +CUSTOM_TEMPLATE_SENTINEL = "__custom_template__" |
| 23 | +_ANSI_RESET = "\033[0m" |
| 24 | +_CATEGORY_PALETTE = ( |
| 25 | + "\033[33m", # yellow |
| 26 | + "\033[32m", # green |
| 27 | + "\033[36m", # cyan |
| 28 | + "\033[35m", # magenta |
| 29 | + "\033[34m", # blue |
| 30 | +) |
| 31 | + |
| 32 | + |
| 33 | +@dataclass(frozen=True) |
| 34 | +class TemplateChoice: |
| 35 | + """Searchable interactive template choice.""" |
| 36 | + |
| 37 | + title: str |
| 38 | + value: str |
| 39 | + search: str |
| 40 | + |
21 | 41 |
|
22 | 42 | class CatalogResolutionError(ValueError): |
23 | 43 | """Raised when a template or extension slug is not in the catalog.""" |
@@ -55,6 +75,96 @@ def resolve_catalog_specs( |
55 | 75 | return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs] |
56 | 76 |
|
57 | 77 |
|
| 78 | +def short_category_label(category_name: str) -> str: |
| 79 | + """Derive a compact badge label from a catalog category name.""" |
| 80 | + stop_words = {"Applications", "Application", "Boilerplate"} |
| 81 | + words = [word for word in category_name.split() if word not in stop_words] |
| 82 | + if len(words) >= 3: |
| 83 | + return "".join(word[:1].upper() for word in words) |
| 84 | + return " ".join(words[:2]) or category_name |
| 85 | + |
| 86 | + |
| 87 | +def _color_category(slug: str, label: str) -> str: |
| 88 | + if os.environ.get("NO_COLOR"): |
| 89 | + return label |
| 90 | + idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE) |
| 91 | + return f"{_CATEGORY_PALETTE[idx]}{label}{_ANSI_RESET}" |
| 92 | + |
| 93 | + |
| 94 | +def _category_map(data: dict[str, Any]) -> dict[str, str]: |
| 95 | + return { |
| 96 | + str(category.get("slug", "")): str(category.get("name", "")) |
| 97 | + for category in data.get("categories", []) |
| 98 | + } |
| 99 | + |
| 100 | + |
| 101 | +def _search_text(template: dict[str, Any], category_name: str) -> str: |
| 102 | + labels = template.get("labels", []) |
| 103 | + if not isinstance(labels, list): |
| 104 | + labels = [] |
| 105 | + tokens = [ |
| 106 | + template.get("slug", ""), |
| 107 | + template.get("name", ""), |
| 108 | + template.get("description", ""), |
| 109 | + template.get("category", ""), |
| 110 | + category_name, |
| 111 | + *labels, |
| 112 | + ] |
| 113 | + return " ".join(str(token) for token in tokens if token).lower() |
| 114 | + |
| 115 | + |
| 116 | +def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]: |
| 117 | + """Build CNA-style searchable template choices for interactive mode.""" |
| 118 | + categories = _category_map(data) |
| 119 | + choices: list[TemplateChoice] = [] |
| 120 | + templates = sorted( |
| 121 | + (item for item in data.get("templates", []) if isinstance(item, dict)), |
| 122 | + key=lambda item: ( |
| 123 | + list(categories).index(str(item.get("category", ""))) |
| 124 | + if str(item.get("category", "")) in categories |
| 125 | + else len(categories), |
| 126 | + str(item.get("name", item.get("slug", ""))).lower(), |
| 127 | + ), |
| 128 | + ) |
| 129 | + for template in templates: |
| 130 | + if not isinstance(template, dict): |
| 131 | + continue |
| 132 | + template_url = str(template.get("url", "")) |
| 133 | + if not template_url: |
| 134 | + continue |
| 135 | + category_slug = str(template.get("category", "custom")) |
| 136 | + category_name = categories.get(category_slug, category_slug) |
| 137 | + badge = short_category_label(category_name).ljust(10)[:10] |
| 138 | + slug = str(template.get("slug", "")) |
| 139 | + labels = template.get("labels", []) |
| 140 | + label_suffix = "" |
| 141 | + if isinstance(labels, list) and labels: |
| 142 | + label_suffix = " · " + ", ".join(str(label) for label in labels[:3]) |
| 143 | + description = str(template.get("description", "")).strip() |
| 144 | + description_suffix = f" — {description}" if description else "" |
| 145 | + title = ( |
| 146 | + f"{_color_category(category_slug, badge)} " |
| 147 | + f"{template.get('name', slug)} ({slug})" |
| 148 | + f"{label_suffix}{description_suffix}" |
| 149 | + ) |
| 150 | + choices.append( |
| 151 | + TemplateChoice( |
| 152 | + title=title, |
| 153 | + value=template_url, |
| 154 | + search=_search_text(template, category_name), |
| 155 | + ) |
| 156 | + ) |
| 157 | + |
| 158 | + choices.append( |
| 159 | + TemplateChoice( |
| 160 | + title=" " * 12 + "Use my own template URL", |
| 161 | + value=CUSTOM_TEMPLATE_SENTINEL, |
| 162 | + search="custom own template url github file", |
| 163 | + ) |
| 164 | + ) |
| 165 | + return choices |
| 166 | + |
| 167 | + |
58 | 168 | DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json" |
59 | 169 | CACHE_TTL_SECONDS = 3600 |
60 | 170 | FETCH_TIMEOUT_SECONDS = 10 |
|
0 commit comments