python-lucide 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {python_lucide-0.3.0 → python_lucide-0.4.0}/PKG-INFO +1 -1
  2. {python_lucide-0.3.0 → python_lucide-0.4.0}/pyproject.toml +2 -1
  3. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/build_clusters.py +88 -51
  4. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/build_search.py +51 -28
  5. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/cli.py +12 -2
  6. python_lucide-0.4.0/src/lucide/config.py +101 -0
  7. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/data/lucide-icon-clusters.json +2 -2
  8. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/search.py +92 -29
  9. python_lucide-0.4.0/tests/build_clusters_test.py +107 -0
  10. {python_lucide-0.3.0 → python_lucide-0.4.0}/tests/build_search_test.py +48 -10
  11. {python_lucide-0.3.0 → python_lucide-0.4.0}/tests/cli_test.py +27 -15
  12. python_lucide-0.4.0/tests/conftest.py +24 -0
  13. {python_lucide-0.3.0 → python_lucide-0.4.0}/tests/search_test.py +93 -14
  14. python_lucide-0.3.0/src/lucide/config.py +0 -30
  15. {python_lucide-0.3.0 → python_lucide-0.4.0}/README.md +0 -0
  16. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/__init__.py +0 -0
  17. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/core.py +0 -0
  18. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/data/__init__.py +0 -0
  19. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/data/gemini-icon-descriptions.jsonl +0 -0
  20. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/data/lucide-icons.db +0 -0
  21. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/db.py +0 -0
  22. {python_lucide-0.3.0 → python_lucide-0.4.0}/src/lucide/dev_utils.py +0 -0
  23. {python_lucide-0.3.0 → python_lucide-0.4.0}/tests/__init__.py +0 -0
  24. {python_lucide-0.3.0 → python_lucide-0.4.0}/tests/core_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-lucide
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A Python package for working with Lucide icons
5
5
  Keywords: lucide,icons,svg,sqlite
6
6
  Author: Mike Macpherson
@@ -11,6 +11,7 @@ dev = [
11
11
  "mypy>=1.0.0",
12
12
  "plotly>=5.0.0",
13
13
  "pre-commit>=3.0.0",
14
+ "pydantic-ai-slim[google]>=1.0.0",
14
15
  "pytest-cov>=4.0.0",
15
16
  "pytest>=7.0.0",
16
17
  "ruff>=0.1.0",
@@ -40,7 +41,7 @@ license = {text = "MIT"}
40
41
  name = "python-lucide"
41
42
  readme = "README.md"
42
43
  requires-python = ">=3.10"
43
- version = "0.3.0"
44
+ version = "0.4.0"
44
45
 
45
46
  [project.optional-dependencies]
46
47
  search = ["fastembed>=0.4.0"]
@@ -13,51 +13,68 @@ import logging
13
13
  import os
14
14
  import pathlib
15
15
  import sqlite3
16
- import urllib.request
17
16
  from datetime import datetime, timezone
18
17
 
18
+ from pydantic import BaseModel, field_validator
19
+ from pydantic_ai import Agent
20
+ from pydantic_ai.models import Model
21
+
22
+ from .config import DEFAULT_SEARCH_MODEL_ID
23
+
19
24
  logger = logging.getLogger(__name__)
20
25
 
21
26
  CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
22
27
 
23
- NAMING_PROMPT_TEMPLATE = """\
24
- Here are icon names that form a visual/semantic cluster: {icon_names}
28
+ NAMING_INSTRUCTIONS = """\
29
+ You name clusters of icons. Given icon names that form a visual/semantic
30
+ cluster, respond with a short, evocative theme name (2-4 words).
31
+ Be specific about what unifies the icons.
32
+ Do not use generic labels like "UI elements" or "miscellaneous"."""
25
33
 
26
- Give this cluster a short, evocative theme name (2-4 words).
27
- Be specific about what unifies these icons.
28
- Do not use generic labels like "UI elements" or "miscellaneous".
34
+ # Generous bound for a "2-4 word" name; anything longer means the model
35
+ # ignored the instruction (e.g. leaked its reasoning)
36
+ MAX_THEME_LENGTH = 40
29
37
 
30
- Return ONLY the theme name, nothing else."""
31
38
 
32
- GEMINI_API_URL = (
33
- "https://generativelanguage.googleapis.com/v1beta/models/"
34
- "{model}:generateContent?key={api_key}"
35
- )
39
+ def _sanitize_theme(raw: str) -> str | None:
40
+ """Validate a model-proposed theme name.
36
41
 
42
+ Models occasionally return their full chain-of-thought instead of just
43
+ the name — two shipped clusters once carried ~4k-char reasoning dumps
44
+ as their theme. Reject anything multi-line or implausibly long rather
45
+ than trying to salvage it.
46
+
47
+ Args:
48
+ raw: The raw model response text.
49
+
50
+ Returns:
51
+ The cleaned theme name, or None if the response is unusable.
52
+ """
53
+ theme = raw.strip().strip("\"'").strip()
54
+ if not theme or "\n" in theme or len(theme) > MAX_THEME_LENGTH:
55
+ return None
56
+ return theme
37
57
 
38
- def _call_gemini_text(prompt: str, api_key: str) -> str | None:
39
- """Call Gemini API with a text-only prompt."""
40
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
41
- body = json.dumps(payload).encode("utf-8")
42
- url = GEMINI_API_URL.format(model=CLUSTER_NAMING_MODEL, api_key=api_key)
43
- req = urllib.request.Request(
44
- url,
45
- data=body,
46
- headers={"Content-Type": "application/json"},
47
- method="POST",
48
- )
49
- try:
50
- with urllib.request.urlopen(req, timeout=30) as resp:
51
- result = json.loads(resp.read().decode("utf-8"))
52
- candidates = result.get("candidates", [])
53
- if candidates:
54
- parts = candidates[0].get("content", {}).get("parts", [])
55
- if parts:
56
- text: str = parts[0].get("text", "")
57
- return text.strip()
58
- except Exception:
59
- logger.warning("Gemini API call failed", exc_info=True)
60
- return None
58
+
59
+ class ClusterTheme(BaseModel):
60
+ """Structured output for cluster naming.
61
+
62
+ A failed validation here becomes a retry request to the model, so a
63
+ leaked chain-of-thought gets re-asked instead of stored or discarded.
64
+ """
65
+
66
+ theme: str
67
+
68
+ @field_validator("theme")
69
+ @classmethod
70
+ def _must_be_short_single_line(cls, value: str) -> str:
71
+ clean = _sanitize_theme(value)
72
+ if clean is None:
73
+ raise ValueError(
74
+ f"theme must be a single line of at most {MAX_THEME_LENGTH} "
75
+ "characters — return only the 2-4 word name itself"
76
+ )
77
+ return clean
61
78
 
62
79
 
63
80
  def discover_clusters(
@@ -83,7 +100,9 @@ def discover_clusters(
83
100
  "SELECT e.name, e.embedding, d.description "
84
101
  "FROM icon_embeddings e "
85
102
  "JOIN icon_descriptions d ON e.name = d.name "
86
- "ORDER BY e.name"
103
+ "WHERE e.model = ? "
104
+ "ORDER BY e.name",
105
+ (DEFAULT_SEARCH_MODEL_ID,),
87
106
  ).fetchall()
88
107
  conn.close()
89
108
 
@@ -140,19 +159,40 @@ def name_clusters(
140
159
  data: dict,
141
160
  *,
142
161
  api_key: str | None = None,
162
+ model: Model | None = None,
143
163
  ) -> dict:
144
- """Name each cluster using Gemini Flash.
164
+ """Name each cluster using Gemini Flash via a Pydantic AI agent.
165
+
166
+ Output is validated by ``ClusterTheme``; an invalid response (e.g. a
167
+ leaked chain-of-thought) triggers an automatic retry instead of being
168
+ stored. Only after retries are exhausted does a cluster fall back to a
169
+ ``Cluster {id}`` placeholder.
145
170
 
146
171
  Args:
147
172
  data: Output from ``discover_clusters()``.
148
173
  api_key: Gemini API key. Falls back to ``GEMINI_API_KEY`` env var.
174
+ model: Model override, used by tests to avoid real API calls.
149
175
 
150
176
  Returns:
151
177
  The same data dict with ``theme`` populated for each cluster.
152
178
  """
153
- api_key = api_key or os.environ.get("GEMINI_API_KEY")
154
- if not api_key:
155
- raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
179
+ if model is None:
180
+ from pydantic_ai.models.google import GoogleModel # noqa: PLC0415
181
+ from pydantic_ai.providers.google import GoogleProvider # noqa: PLC0415
182
+
183
+ api_key = api_key or os.environ.get("GEMINI_API_KEY")
184
+ if not api_key:
185
+ raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
186
+ model = GoogleModel(
187
+ CLUSTER_NAMING_MODEL, provider=GoogleProvider(api_key=api_key)
188
+ )
189
+
190
+ agent = Agent(
191
+ model,
192
+ output_type=ClusterTheme,
193
+ instructions=NAMING_INSTRUCTIONS,
194
+ output_retries=3,
195
+ )
156
196
 
157
197
  clusters = data["clusters"]
158
198
  for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
@@ -163,17 +203,15 @@ def name_clusters(
163
203
  icons = clusters[lid]["icons"]
164
204
  # Cap at 40 names to keep prompt short
165
205
  icon_names = ", ".join(icons[:40])
166
- prompt = NAMING_PROMPT_TEMPLATE.format(icon_names=icon_names)
167
- theme = _call_gemini_text(prompt, api_key)
168
-
169
- if theme:
170
- # Strip quotes if the model wraps it
171
- theme = theme.strip("\"'")
172
- clusters[lid]["theme"] = theme
173
- logger.info("Cluster %s (%d icons): %s", lid, len(icons), theme)
174
- else:
206
+ try:
207
+ result = agent.run_sync(f"Icon cluster: {icon_names}")
208
+ clusters[lid]["theme"] = result.output.theme
209
+ logger.info(
210
+ "Cluster %s (%d icons): %s", lid, len(icons), result.output.theme
211
+ )
212
+ except Exception:
175
213
  clusters[lid]["theme"] = f"Cluster {lid}"
176
- logger.warning("Failed to name cluster %s", lid)
214
+ logger.warning("Failed to name cluster %s", lid, exc_info=True)
177
215
 
178
216
  return data
179
217
 
@@ -260,8 +298,7 @@ def build_cluster_visualization(
260
298
  fig.update_layout(
261
299
  title={
262
300
  "text": (
263
- "Lucide Icon Embedding Clusters \u2014 "
264
- "themes discovered via HDBSCAN in 768d space"
301
+ "Lucide Icon Embedding Clusters \u2014 themes discovered via HDBSCAN"
265
302
  ),
266
303
  "font": {"size": 16},
267
304
  },
@@ -30,10 +30,9 @@ from datetime import datetime, timezone
30
30
  from typing import TypedDict
31
31
 
32
32
  from .config import (
33
- DEFAULT_EMBEDDING_DIM,
34
- DEFAULT_EMBEDDING_MODEL,
35
33
  DEFAULT_VLM_MODEL,
36
- EMBEDDING_DOCUMENT_PREFIX,
34
+ EMBEDDING_MODELS,
35
+ SEARCH_DB_SCHEMA_VERSION,
37
36
  )
38
37
 
39
38
  logger = logging.getLogger(__name__)
@@ -486,9 +485,10 @@ def _ensure_search_tables(conn: sqlite3.Connection) -> None:
486
485
  )
487
486
  conn.execute(
488
487
  "CREATE TABLE IF NOT EXISTS icon_embeddings ("
489
- " name TEXT PRIMARY KEY,"
488
+ " name TEXT NOT NULL,"
490
489
  " embedding BLOB NOT NULL,"
491
- " model TEXT NOT NULL"
490
+ " model TEXT NOT NULL,"
491
+ " PRIMARY KEY (name, model)"
492
492
  ")"
493
493
  )
494
494
  conn.execute(
@@ -513,23 +513,35 @@ def _write_search_db( # noqa: PLR0913
513
513
  search_db_path: pathlib.Path,
514
514
  ordered_names: list[str],
515
515
  records: dict[str, DescriptionRecord],
516
- embeddings: list,
516
+ embeddings: dict[str, dict[str, object]],
517
517
  clusters_path: pathlib.Path,
518
518
  *,
519
519
  version: str | None = None,
520
520
  verbose: bool = False,
521
521
  ) -> None:
522
- """Write descriptions, embeddings, clusters, and metadata to SQLite."""
522
+ """Write descriptions, embeddings, clusters, and metadata to SQLite.
523
+
524
+ Args:
525
+ search_db_path: Output SQLite path (recreated from scratch).
526
+ ordered_names: Icon names in insertion order.
527
+ records: Description records keyed by icon name.
528
+ embeddings: Embedding vectors as ``{model_id: {icon_name: vector}}``.
529
+ A model may cover only a subset of icons; absent names are
530
+ simply not inserted for that model.
531
+ clusters_path: JSON file with cluster assignments.
532
+ version: Lucide icon-set version for the metadata table.
533
+ verbose: Verbose logging.
534
+ """
523
535
  import numpy as np # noqa: PLC0415
524
536
 
525
537
  search_db_path.parent.mkdir(parents=True, exist_ok=True)
538
+ # Recreate rather than DELETE-and-reuse: the schema itself may have
539
+ # changed since the last build (CREATE IF NOT EXISTS won't migrate it)
540
+ search_db_path.unlink(missing_ok=True)
526
541
  conn = sqlite3.connect(search_db_path)
527
542
  try:
528
543
  _ensure_search_tables(conn)
529
544
 
530
- conn.execute("DELETE FROM icon_descriptions")
531
- conn.execute("DELETE FROM icon_embeddings")
532
-
533
545
  for name in ordered_names:
534
546
  rec = records[name]
535
547
  conn.execute(
@@ -538,15 +550,18 @@ def _write_search_db( # noqa: PLR0913
538
550
  (name, rec["description"], rec["model"]),
539
551
  )
540
552
 
541
- for name, emb in zip(ordered_names, embeddings, strict=True):
542
- blob = np.array(emb, dtype=np.float32).tobytes()
543
- conn.execute(
544
- "INSERT INTO icon_embeddings (name, embedding, model) VALUES (?, ?, ?)",
545
- (name, blob, DEFAULT_EMBEDDING_MODEL),
546
- )
553
+ for model_id, vectors_by_name in embeddings.items():
554
+ for name in ordered_names:
555
+ if name not in vectors_by_name:
556
+ continue
557
+ blob = np.array(vectors_by_name[name], dtype=np.float32).tobytes()
558
+ conn.execute(
559
+ "INSERT INTO icon_embeddings (name, embedding, model)"
560
+ " VALUES (?, ?, ?)",
561
+ (name, blob, model_id),
562
+ )
547
563
 
548
564
  # Load clusters
549
- conn.execute("DELETE FROM icon_clusters")
550
565
  cluster_data = json.loads(clusters_path.read_text())
551
566
  for cid, cluster in cluster_data["clusters"].items():
552
567
  theme = cluster.get("theme", f"Cluster {cid}")
@@ -584,10 +599,11 @@ def _write_search_db( # noqa: PLR0913
584
599
  first = records[ordered_names[0]]
585
600
  resolved_version = version or first.get("lucide_version", "unknown")
586
601
  now = datetime.now(tz=timezone.utc).isoformat()
602
+ model_dims = json.dumps({mid: EMBEDDING_MODELS[mid].dim for mid in embeddings})
587
603
  for key, value in [
588
604
  ("version", resolved_version),
589
- ("embedding_model", DEFAULT_EMBEDDING_MODEL),
590
- ("embedding_dim", str(DEFAULT_EMBEDDING_DIM)),
605
+ ("schema_version", str(SEARCH_DB_SCHEMA_VERSION)),
606
+ ("embedding_models", model_dims),
591
607
  ("description_model", DEFAULT_VLM_MODEL),
592
608
  ("built_at", now),
593
609
  ]:
@@ -631,9 +647,10 @@ def build_search_db(
631
647
  ) -> None:
632
648
  """Build the SQLite search database from descriptions, embeddings, and clusters.
633
649
 
634
- Reads descriptions from *jsonl_path*, computes embeddings with fastembed,
635
- loads cluster assignments from *clusters_path*, and writes everything to
636
- *search_db_path*. The DB is rebuilt from scratch each time.
650
+ Reads descriptions from *jsonl_path*, computes one embedding set per
651
+ model in ``EMBEDDING_MODELS`` with fastembed, loads cluster assignments
652
+ from *clusters_path*, and writes everything to *search_db_path*. The DB
653
+ is rebuilt from scratch each time.
637
654
 
638
655
  When *icons_db_path* is provided, only icons present in that database are
639
656
  included and the version metadata is read from it.
@@ -687,12 +704,18 @@ def build_search_db(
687
704
  if rec["categories"]:
688
705
  parts.append(f"Categories: {', '.join(rec['categories'])}")
689
706
  parts.append(rec["description"])
690
- return f"{EMBEDDING_DOCUMENT_PREFIX}{'. '.join(parts)}"
691
-
692
- texts = [_embedding_text(records[n]) for n in ordered_names]
693
- embedder = TextEmbedding(model_name=DEFAULT_EMBEDDING_MODEL)
694
- embeddings = list(embedder.embed(texts))
695
- logger.info("Computed %d embeddings", len(embeddings))
707
+ return ". ".join(parts)
708
+
709
+ base_texts = [_embedding_text(records[n]) for n in ordered_names]
710
+ embeddings: dict[str, dict[str, object]] = {}
711
+ for model_id, model_cfg in EMBEDDING_MODELS.items():
712
+ texts = [f"{model_cfg.document_prefix}{t}" for t in base_texts]
713
+ embedder = TextEmbedding(model_name=model_cfg.fastembed_model)
714
+ vecs = list(embedder.embed(texts))
715
+ embeddings[model_id] = dict(zip(ordered_names, vecs, strict=True))
716
+ logger.info(
717
+ "Computed %d embeddings with %s", len(texts), model_cfg.fastembed_model
718
+ )
696
719
 
697
720
  _write_search_db(
698
721
  search_db_path,
@@ -24,7 +24,11 @@ import sys
24
24
  import tempfile
25
25
  from datetime import datetime
26
26
 
27
- from .config import DEFAULT_LUCIDE_TAG
27
+ from .config import (
28
+ DEFAULT_LUCIDE_TAG,
29
+ DEFAULT_SEARCH_MODEL_ID,
30
+ EMBEDDING_MODELS,
31
+ )
28
32
 
29
33
  logger = logging.getLogger(__name__)
30
34
 
@@ -546,7 +550,7 @@ def _cmd_search(args: argparse.Namespace) -> int:
546
550
  os.environ["LUCIDE_SEARCH_DB_PATH"] = str(candidate)
547
551
 
548
552
  try:
549
- results = search_icons(args.query, limit=args.limit)
553
+ results = search_icons(args.query, limit=args.limit, model=args.model)
550
554
  except Exception as e:
551
555
  print(f"Error: {e}")
552
556
  return 1
@@ -856,6 +860,12 @@ def main() -> int:
856
860
  help="Path to the search database",
857
861
  default=None,
858
862
  )
863
+ search_parser.add_argument(
864
+ "--model",
865
+ choices=sorted(EMBEDDING_MODELS),
866
+ default=DEFAULT_SEARCH_MODEL_ID,
867
+ help="Embedding model to search with",
868
+ )
859
869
  search_parser.add_argument(
860
870
  "-v",
861
871
  "--verbose",
@@ -0,0 +1,101 @@
1
+ """Configuration for python-lucide.
2
+
3
+ This module contains default configuration values used throughout the package.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+
8
+ # Default Lucide tag to use when building the icon database
9
+ DEFAULT_LUCIDE_TAG = "1.17.0"
10
+
11
+ # Default size for the LRU cache used by lucide_icon function
12
+ DEFAULT_ICON_CACHE_SIZE = 128
13
+
14
+ # --- Semantic search ---
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class EmbeddingModelConfig:
19
+ """One embedding model usable for icon search.
20
+
21
+ Each model must be available both as a fastembed ONNX model (build time
22
+ and Python query time) and as a transformers.js model (browser query
23
+ time), and the two runtimes must produce identical vectors. ``pooling``
24
+ is the transformers.js pooling mode that matches fastembed's output for
25
+ this model — verified empirically (cosine 1.0) before a model is added
26
+ here; mismatched pooling silently degrades search quality instead of
27
+ erroring.
28
+
29
+ Attributes:
30
+ id: Short stable identifier stored in the search DB and web manifest.
31
+ fastembed_model: fastembed model name.
32
+ web_model: transformers.js (Hugging Face) model id. Empty means the
33
+ model is Python-only and excluded from the web manifest.
34
+ dim: Embedding dimensionality.
35
+ pooling: transformers.js pooling mode ("mean" or "cls").
36
+ web_dtype: transformers.js quantization. Document vectors always
37
+ come from fastembed (fp32), so a quantized browser model only
38
+ perturbs the query vector — q8 stays ~0.994 cosine to fp32,
39
+ which is ranking-equivalent, and roughly quarters the download.
40
+ query_prefix: Prefix prepended to queries (asymmetric retrieval).
41
+ document_prefix: Prefix prepended to documents at build time.
42
+ label: Human-facing label for the web UI model toggle.
43
+ """
44
+
45
+ id: str
46
+ dim: int
47
+ fastembed_model: str = ""
48
+ web_model: str = ""
49
+ pooling: str = "mean"
50
+ web_dtype: str = "fp32"
51
+ query_prefix: str = ""
52
+ document_prefix: str = ""
53
+ label: str = ""
54
+
55
+
56
+ EMBEDDING_MODELS: dict[str, EmbeddingModelConfig] = {
57
+ "minilm": EmbeddingModelConfig(
58
+ id="minilm",
59
+ fastembed_model="sentence-transformers/all-MiniLM-L6-v2",
60
+ web_model="Xenova/all-MiniLM-L6-v2",
61
+ dim=384,
62
+ pooling="mean",
63
+ label="Faster",
64
+ ),
65
+ "bge-small": EmbeddingModelConfig(
66
+ id="bge-small",
67
+ fastembed_model="BAAI/bge-small-en-v1.5",
68
+ web_model="Xenova/bge-small-en-v1.5",
69
+ dim=384,
70
+ pooling="cls",
71
+ query_prefix="Represent this sentence for searching relevant passages: ",
72
+ label="Better",
73
+ ),
74
+ "multilingual": EmbeddingModelConfig(
75
+ id="multilingual",
76
+ fastembed_model="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
77
+ web_model="Xenova/paraphrase-multilingual-MiniLM-L12-v2",
78
+ dim=384,
79
+ pooling="mean",
80
+ web_dtype="q8",
81
+ label="Multilingual",
82
+ ),
83
+ }
84
+
85
+ DEFAULT_SEARCH_MODEL_ID = "bge-small"
86
+
87
+ # VLM used to generate icon descriptions at build time
88
+ DEFAULT_VLM_MODEL = "gemini-2.5-flash-lite"
89
+
90
+ # Bumped when the search DB layout changes incompatibly (v2: multi-model
91
+ # embeddings keyed by (name, model)). Recorded in the DB metadata and baked
92
+ # into the local cache filename so a stale cache from an older package
93
+ # version is never misread after an upgrade.
94
+ SEARCH_DB_SCHEMA_VERSION = 2
95
+
96
+ # URL template for downloading pre-built search data.
97
+ # {version} is replaced with the Lucide icon-set version (e.g. "0.577.0").
98
+ SEARCH_DB_URL_TEMPLATE = (
99
+ "https://github.com/mmacpherson/python-lucide/releases/download/"
100
+ "search-v{version}/lucide-search.db"
101
+ )
@@ -1767,7 +1767,7 @@
1767
1767
  "dice-6",
1768
1768
  "dices"
1769
1769
  ],
1770
- "theme": "THINK\nThe user wants a short, evocative theme name (2-4 words) for the icon cluster: `dice-1, dice-2, dice-3, dice-4, dice-5, dice-6, dices`.\nI need to be specific about what unifies them, which is clearly the concept of dice and rolling dice.\nI must not use generic labels.\nI must return ONLY the theme name.\n\nAnalyzing the icons:\n- `dice-1` through `dice-6`: Individual dice faces, showing specific numbers.\n- `dices`: Likely multiple dice, representing the concept of dice as a whole or a roll.\n\nThe unifying concept is \"dice\" and \"rolling/gambling with dice\".\n\nBrainstorming theme names:\n- Rolling Dice\n- Game Dice\n- Dice Roll\n- Lucky Dice\n- Dice Play\n- Gaming Dice\n- Casino Dice\n- Tabletop Dice\n\nConstraints:\n- Short (2-4 words)\n- Evocative\n- Specific\n\n\"Rolling Dice\" is good, 2 words, specific, evocative of action.\n\"Game Dice\" is good, 2 words, specific, implies context.\n\"Dice Roll\" is good, 2 words, specific, implies action/outcome.\n\"Lucky Dice\" is good, 2 words, evocative, specific.\n\"Gaming Dice\" is good, 2 words, specific, implies context.\n\nLet's pick one that feels most direct and comprehensive.\n\"Rolling Dice\" covers the individual dice and the action implied by \"dices\".\n\"Game Dice\" is also very fitting as dice are primarily used in games.\n\n\"Rolling Dice\" feels slightly more dynamic and covers the implied action of the \"dices\" icon (a set being rolled) and the outcome (the individual numbered dice).\n\"Game Dice\" is also very strong as it defines their purpose.\n\nLet's go with \"Rolling Dice\" as it implies the use case and the items. It's direct and clear.\n\"Dice Roll\" is also very good.\n\nComparing \"Rolling Dice\" vs \"Dice Roll\":\n- \"Rolling Dice\": Focuses on the items and the continuous action.\n- \"Dice Roll\": Focuses on the action and its singular outcome.\n\nGiven `dice-1` to `dice-6` (outcomes) and `dices` (the items themselves, likely in motion or a collection), \"Rolling Dice\" seems to encompass both the objects and the action they perform or are associated with.\n\nAnother option: \"Dice Game\". This is also very strong.\n\nLet's reconsider the cluster:\n`dice-1, dice-2, dice-3, dice-4, dice-5, dice-6` are the *results* of a roll.\n`dices` implies the *objects* themselves, perhaps a collection or mid-roll.\n\nSo, the cluster represents both the *tools* (dice) and their *outcomes* (numbered faces).\n\n\"Dice Games\" implies the context.\n\"Rolling Dice\" implies the action and the tools.\n\"Dice Faces\" would only cover the 1-6.\n\nLet's try to capture both the object and the action/outcome.\n\"Dice & Rolls\" - 3 words. Specific.\n\"Dice Game Elements\" - 3 words. Specific.\n\"Game Dice Icons\" - 3 words. Too meta with \"icons\".\n\n\"Rolling Dice\" is still very strong. It suggests the act and the instruments.\nIt's concise and evocative.\n\n\"Gaming Dice\" - 2 words. This is good. It implies their purpose and context.\nIt's specific and covers all aspects (the items themselves and their numbered faces which are used in gaming).\n\nLet's try \"Gaming Dice\".\nIt's 2 words.\nIt's evocative.\nIt's specific: \"dice\" specifically, and \"gaming\" for their primary use. It covers the individual faces (used in gaming) and the collection of dice (used in gaming).\n\nFinal check:\n- Short (2-4 words): Yes, 2 words.\n- Evocative: Yes, suggests a category.\n- Specific about what unifies: Yes, all are dice used for games.\n- No generic labels: No.\n- Return ONLY the theme name: Yes.\n\nI think \"Gaming Dice\" is a very strong contender.\n\"Rolling Dice\" is also strong.\n\nLet's consider which one is more of a *theme name* for a cluster of icons.\n\"Gaming Dice\" feels more like a classification for a group of related items.\n\"Rolling Dice\" feels more like an action. While the icons represent dice that are rolled, \"Gaming Dice\" directly labels what these items fundamentally *are* and *what they are for*.\n\nSo, \"Gaming Dice\" seems a slightly better fit as a theme name for a collection of icons."
1770
+ "theme": "Gaming Dice"
1771
1771
  },
1772
1772
  "18": {
1773
1773
  "icons": [
@@ -2098,7 +2098,7 @@
2098
2098
  "phone-missed",
2099
2099
  "phone-outgoing"
2100
2100
  ],
2101
- "theme": "THOUGHTS:\n\nThe user wants a short, evocative theme name (2-4 words) for the given icon cluster: `phone, phone-call, phone-forwarded, phone-incoming, phone-missed, phone-outgoing`.\nI need to be specific about what unifies them, avoiding generic labels.\nThe output should ONLY be the theme name.\n\nWhat unifies them?\n- All are related to phone communication.\n- Specifically, they denote different states or types of *calls*.\n- `phone` is the general device.\n- The others describe actions or states of a *call*:\n - `phone-call` (general call)\n - `phone-forwarded` (call status)\n - `phone-incoming` (call direction)\n - `phone-missed` (call status)\n - `phone-outgoing` (call direction)\n\nSo, the core is \"phone calls\" and their \"status/direction\".\n\nLet's brainstorm some options:\n1. **Call History Icons:** \"History\" might be good for missed/incoming/outgoing, but \"phone-call\" and \"phone-forwarded\" don't strictly imply history. \"Phone\" itself is just the device.\n2. **Phone Call States:** This is accurate. \"States\" covers forwarded, missed, incoming, outgoing. \"Phone call\" covers the base. \"Phone\" is the base device.\n3. **Call Status Indicators:** Similar to \"States\". \"Indicators\" is good.\n4. **Telephony Status:** \"Telephony\" is a bit formal.\n5. **Call Management Icons:** \"Management\" could work, but \"status\" or \"states\" feels more direct for what these icons represent visually.\n6. **Communication Call Signs:** \"Signs\" is okay, \"Communication\" is good.\n7. **Voice Call Cues:** \"Voice Call\" is good. \"Cues\" is okay.\n8. **Phone Interaction States:** \"Interaction\" is good. \"States\" is good.\n9. **Call Flow Indicators:** \"Flow\" might be too specific.\n10. **Call Activity Indicators:** \"Activity\" covers incoming/outgoing/missed. \"Call\" is central. \"Indicators\" is good. This feels quite comprehensive.\n11. **Phone Call Dynamics:** \"Dynamics\" is interesting, implies movement/change.\n12. **Call Progress Icons:** \"Progress\" could work for forwarded, incoming, outgoing.\n\nLet's re-evaluate \"Phone Call States\" or \"Call Status Indicators\".\n`phone` - the device/general call icon\n`phone-call` - a generic call\n`phone-forwarded` - a state of a call\n`phone-incoming` - a direction/state of a call\n`phone-missed` - a state of a call\n`phone-outgoing` - a direction/state of a call\n\n\"Phone Call States\" - very direct.\n\"Call Status Indicators\" - also very direct.\n\"Call Activity Indicators\" - \"Activity\" is good for incoming/outgoing/missed. \"Status\" feels more encompassing for \"forwarded\" and \"phone-call\".\n\nI need it to be 2-4 words.\n\n* \"Call Status Icons\" (3 words) - Good.\n* \"Phone Call States\" (3 words) - Also good.\n* \"Telephony Call States\" (3 words) - \"Telephony\" is a bit jargon-y.\n* \"Call Interaction Icons\" (3 words) - \"Interaction\" is good.\n* \"Communication Call Status\" (3 words) - Very good.\n* \"Call Event Indicators\" (3 words) - \"Event\" captures the 'missed', 'incoming', 'outgoing'. 'Forwarded' is also an event. 'Phone-call' is a general event. 'Phone' is the device.\n\nLet's consider \"Call Event Indicators\".\n- `phone`: General call/device.\n- `phone-call`: A call event.\n- `phone-forwarded`: An event (call was forwarded).\n- `phone-incoming`: An event (call incoming).\n- `phone-missed`: An event (call was missed).\n- `phone-outgoing`: An event (call outgoing).\nThis seems to fit all very well. It's specific and descriptive.\n\nIt's 3 words, fits the criteria.\n\"Call Event Indicators\" or \"Call Status Indicators\".\n\"Event\" feels slightly better as it covers the happening of something (incoming, outgoing, missed, forwarded) rather than just its static state. A \"missed\" call is an event. \"Forwarded\" is an event.\n\nLet's go with \"Call Event Indicators\". It captures the dynamic nature and the record-keeping aspect implied by many of these.\nIt's short, evocative (in a functional way), and specific.\nIt avoids generic terms.\nIt unifies them as representations of different occurrences or states related to phone calls."
2101
+ "theme": "Call Event Indicators"
2102
2102
  },
2103
2103
  "60": {
2104
2104
  "icons": [