python-lucide 0.2.28__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 (27) hide show
  1. {python_lucide-0.2.28 → python_lucide-0.4.0}/PKG-INFO +3 -1
  2. {python_lucide-0.2.28 → python_lucide-0.4.0}/pyproject.toml +26 -5
  3. {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/__init__.py +12 -0
  4. python_lucide-0.4.0/src/lucide/build_clusters.py +324 -0
  5. python_lucide-0.4.0/src/lucide/build_search.py +794 -0
  6. python_lucide-0.4.0/src/lucide/cli.py +945 -0
  7. python_lucide-0.4.0/src/lucide/config.py +101 -0
  8. python_lucide-0.4.0/src/lucide/data/gemini-icon-descriptions.jsonl +1733 -0
  9. python_lucide-0.4.0/src/lucide/data/lucide-icon-clusters.json +9028 -0
  10. python_lucide-0.4.0/src/lucide/data/lucide-icons.db +0 -0
  11. python_lucide-0.4.0/src/lucide/search.py +380 -0
  12. python_lucide-0.4.0/tests/build_clusters_test.py +107 -0
  13. python_lucide-0.4.0/tests/build_search_test.py +364 -0
  14. python_lucide-0.4.0/tests/cli_test.py +225 -0
  15. python_lucide-0.4.0/tests/conftest.py +24 -0
  16. python_lucide-0.4.0/tests/search_test.py +377 -0
  17. python_lucide-0.2.28/src/lucide/cli.py +0 -418
  18. python_lucide-0.2.28/src/lucide/config.py +0 -10
  19. python_lucide-0.2.28/src/lucide/data/lucide-icons.db +0 -0
  20. python_lucide-0.2.28/tests/cli_test.py +0 -153
  21. {python_lucide-0.2.28 → python_lucide-0.4.0}/README.md +0 -0
  22. {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/core.py +0 -0
  23. {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/data/__init__.py +0 -0
  24. {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/db.py +0 -0
  25. {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/dev_utils.py +0 -0
  26. {python_lucide-0.2.28 → python_lucide-0.4.0}/tests/__init__.py +0 -0
  27. {python_lucide-0.2.28 → 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.2.28
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
@@ -15,10 +15,12 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
18
19
  Requires-Python: >=3.10
19
20
  Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
20
21
  Project-URL: Homepage, https://github.com/mmacpherson/python-lucide
21
22
  Project-URL: Source Code, https://github.com/mmacpherson/python-lucide
23
+ Provides-Extra: search
22
24
  Description-Content-Type: text/markdown
23
25
 
24
26
  # python-lucide
@@ -4,14 +4,20 @@ requires = ["uv_build>=0.8.3,<0.12.0"]
4
4
 
5
5
  [dependency-groups]
6
6
  dev = [
7
+ "cairosvg>=2.7.0",
8
+ "fastembed>=0.4.0",
9
+ "hdbscan>=0.8.0",
7
10
  "mypy-extensions>=1.0.0",
8
11
  "mypy>=1.0.0",
12
+ "plotly>=5.0.0",
9
13
  "pre-commit>=3.0.0",
14
+ "pydantic-ai-slim[google]>=1.0.0",
10
15
  "pytest-cov>=4.0.0",
11
16
  "pytest>=7.0.0",
12
17
  "ruff>=0.1.0",
13
18
  "types-setuptools>=80.9.0.20250529",
14
- "typing-extensions>=4.1.0"
19
+ "typing-extensions>=4.1.0",
20
+ "umap-learn>=0.5.0"
15
21
  ]
16
22
 
17
23
  [project]
@@ -35,11 +41,15 @@ license = {text = "MIT"}
35
41
  name = "python-lucide"
36
42
  readme = "README.md"
37
43
  requires-python = ">=3.10"
38
- version = "0.2.28"
44
+ version = "0.4.0"
45
+
46
+ [project.optional-dependencies]
47
+ search = ["fastembed>=0.4.0"]
39
48
 
40
49
  [project.scripts]
41
50
  check-lucide-version = "lucide.dev_utils:print_version_status"
42
- lucide-db = "lucide.cli:main"
51
+ lucide = "lucide.cli:main"
52
+ lucide-db = "lucide.cli:main_legacy_db"
43
53
 
44
54
  [project.urls]
45
55
  "Bug Tracker" = "https://github.com/mmacpherson/python-lucide/issues"
@@ -126,7 +136,8 @@ section-order = [
126
136
 
127
137
  [tool.ruff.lint.per-file-ignores]
128
138
  "**/__init__.py" = ["F401"]
129
- "tests/**/*.py" = ["D", "SLF001"]
139
+ "scripts/**/*.py" = ["D", "SLF001", "PLR2004", "PLR0915", "PLC0415", "ERA001", "E741", "C408"]
140
+ "tests/**/*.py" = ["D", "SLF001", "PLR2004", "ARG005"]
130
141
 
131
142
  [tool.ruff.lint.pydocstyle]
132
143
  convention = "google"
@@ -134,8 +145,18 @@ convention = "google"
134
145
  [tool.uv.build-backend]
135
146
  # The module name differs from the normalized package name
136
147
  module-name = "lucide"
148
+ # The search DB is a derived artifact published as a GitHub release asset
149
+ # and downloaded on first use; never package it
150
+ source-exclude = ["src/lucide/data/lucide-search.db"]
137
151
  # Include tests and database files in source distribution
138
152
  source-include = [
139
153
  "tests/**",
140
- "src/lucide/data/*.db"
154
+ "src/lucide/data/lucide-icons.db"
155
+ ]
156
+ # Build-time data (descriptions, clusters) stays in the sdist/repo but out
157
+ # of the runtime wheel
158
+ wheel-exclude = [
159
+ "lucide/data/lucide-search.db",
160
+ "lucide/data/gemini-icon-descriptions.jsonl",
161
+ "lucide/data/lucide-icon-clusters.json"
141
162
  ]
@@ -8,11 +8,23 @@ __version__ = "0.1.0"
8
8
 
9
9
  from .core import create_placeholder_svg, get_icon_list, lucide_icon
10
10
  from .db import get_db_connection, get_default_db_path
11
+ from .search import (
12
+ SearchNotAvailableError,
13
+ SearchResult,
14
+ get_icon_description,
15
+ search_available,
16
+ search_icons,
17
+ )
11
18
 
12
19
  __all__ = [
20
+ "SearchNotAvailableError",
21
+ "SearchResult",
13
22
  "create_placeholder_svg",
14
23
  "get_db_connection",
15
24
  "get_default_db_path",
25
+ "get_icon_description",
16
26
  "get_icon_list",
17
27
  "lucide_icon",
28
+ "search_available",
29
+ "search_icons",
18
30
  ]
@@ -0,0 +1,324 @@
1
+ """Discover semantic clusters in the icon embedding space and name them.
2
+
3
+ Uses UMAP for dimensionality reduction, HDBSCAN for density-based
4
+ clustering, and Gemini Flash to generate evocative theme names from
5
+ the icon names in each cluster.
6
+
7
+ Build-time only — requires ``umap-learn``, ``hdbscan``, and ``plotly``
8
+ (all in the dev dependency group).
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import pathlib
15
+ import sqlite3
16
+ from datetime import datetime, timezone
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
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
27
+
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"."""
33
+
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
37
+
38
+
39
+ def _sanitize_theme(raw: str) -> str | None:
40
+ """Validate a model-proposed theme name.
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
57
+
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
78
+
79
+
80
+ def discover_clusters(
81
+ search_db_path: pathlib.Path,
82
+ *,
83
+ min_cluster_size: int = 5,
84
+ ) -> dict:
85
+ """Run UMAP + HDBSCAN on the icon embeddings.
86
+
87
+ Args:
88
+ search_db_path: Path to the search SQLite database.
89
+ min_cluster_size: Minimum icons to form a cluster.
90
+
91
+ Returns:
92
+ Dict with keys: clusters, coords, descriptions.
93
+ """
94
+ import numpy as np # noqa: PLC0415
95
+ import umap # noqa: PLC0415
96
+ from hdbscan import HDBSCAN # noqa: PLC0415
97
+
98
+ conn = sqlite3.connect(f"file:{search_db_path}?mode=ro", uri=True)
99
+ rows = conn.execute(
100
+ "SELECT e.name, e.embedding, d.description "
101
+ "FROM icon_embeddings e "
102
+ "JOIN icon_descriptions d ON e.name = d.name "
103
+ "WHERE e.model = ? "
104
+ "ORDER BY e.name",
105
+ (DEFAULT_SEARCH_MODEL_ID,),
106
+ ).fetchall()
107
+ conn.close()
108
+
109
+ names = [r[0] for r in rows]
110
+ descriptions = {r[0]: r[2] for r in rows}
111
+ matrix = np.stack([np.frombuffer(r[1], dtype=np.float32) for r in rows])
112
+ logger.info("Loaded %d embeddings (%dd)", len(names), matrix.shape[1])
113
+
114
+ logger.info("Running UMAP (2D projection)...")
115
+ reducer = umap.UMAP(
116
+ n_components=2,
117
+ n_neighbors=15,
118
+ min_dist=0.1,
119
+ metric="cosine",
120
+ random_state=42,
121
+ )
122
+ coords_array = reducer.fit_transform(matrix)
123
+
124
+ logger.info("Running HDBSCAN (min_cluster_size=%d)...", min_cluster_size)
125
+ clusterer = HDBSCAN(
126
+ min_cluster_size=min_cluster_size,
127
+ metric="euclidean",
128
+ cluster_selection_method="eom",
129
+ )
130
+ labels = clusterer.fit_predict(coords_array)
131
+
132
+ n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
133
+ n_noise = int((labels == -1).sum())
134
+ logger.info("Found %d clusters, %d unclustered points", n_clusters, n_noise)
135
+
136
+ clusters: dict[str, dict] = {}
137
+ for i, label in enumerate(labels):
138
+ lid = str(int(label))
139
+ if lid not in clusters:
140
+ clusters[lid] = {"icons": [], "theme": None}
141
+ clusters[lid]["icons"].append(names[i])
142
+
143
+ for lid in clusters:
144
+ clusters[lid]["icons"].sort()
145
+
146
+ coords = {
147
+ names[i]: [float(coords_array[i, 0]), float(coords_array[i, 1])]
148
+ for i in range(len(names))
149
+ }
150
+
151
+ return {
152
+ "clusters": clusters,
153
+ "coords": coords,
154
+ "descriptions": descriptions,
155
+ }
156
+
157
+
158
+ def name_clusters(
159
+ data: dict,
160
+ *,
161
+ api_key: str | None = None,
162
+ model: Model | None = None,
163
+ ) -> dict:
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.
170
+
171
+ Args:
172
+ data: Output from ``discover_clusters()``.
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.
175
+
176
+ Returns:
177
+ The same data dict with ``theme`` populated for each cluster.
178
+ """
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
+ )
196
+
197
+ clusters = data["clusters"]
198
+ for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
199
+ if lid == "-1":
200
+ clusters[lid]["theme"] = "Unclustered"
201
+ continue
202
+
203
+ icons = clusters[lid]["icons"]
204
+ # Cap at 40 names to keep prompt short
205
+ icon_names = ", ".join(icons[:40])
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:
213
+ clusters[lid]["theme"] = f"Cluster {lid}"
214
+ logger.warning("Failed to name cluster %s", lid, exc_info=True)
215
+
216
+ return data
217
+
218
+
219
+ def save_clusters_json(
220
+ data: dict,
221
+ output_path: pathlib.Path,
222
+ ) -> None:
223
+ """Save cluster data to JSON."""
224
+ # Strip descriptions from the output to keep it lean; keep the UMAP
225
+ # coords so downstream exporters (the web explore map) reuse this
226
+ # projection instead of recomputing it.
227
+ out = {
228
+ "clusters": data["clusters"],
229
+ "coords": {
230
+ name: [round(c[0], 5), round(c[1], 5)] for name, c in data["coords"].items()
231
+ },
232
+ "generated_at": datetime.now(tz=timezone.utc).isoformat(),
233
+ "naming_model": CLUSTER_NAMING_MODEL,
234
+ }
235
+ output_path.write_text(json.dumps(out, indent=2, ensure_ascii=False))
236
+ logger.info("Saved clusters to %s", output_path)
237
+
238
+
239
+ def build_cluster_visualization(
240
+ data: dict,
241
+ output_path: pathlib.Path,
242
+ ) -> None:
243
+ """Build an interactive HTML scatter plot of the clusters."""
244
+ import colorsys # noqa: PLC0415
245
+
246
+ import plotly.graph_objects as go # noqa: PLC0415
247
+
248
+ clusters = data["clusters"]
249
+ coords = data["coords"]
250
+ descriptions = data["descriptions"]
251
+
252
+ real_clusters = [cid for cid in clusters if cid != "-1"]
253
+ n = len(real_clusters)
254
+
255
+ palette = []
256
+ for i in range(n):
257
+ h = i / n
258
+ r, g, b = colorsys.hsv_to_rgb(h, 0.7, 0.9)
259
+ palette.append(f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})")
260
+
261
+ label_colors = {cid: palette[i] for i, cid in enumerate(real_clusters)}
262
+ label_colors["-1"] = "rgb(80,80,80)"
263
+
264
+ fig = go.Figure()
265
+
266
+ sorted_labels = sorted(real_clusters, key=lambda k: -len(clusters[k]["icons"]))
267
+ if "-1" in clusters:
268
+ sorted_labels.append("-1")
269
+
270
+ for lid in sorted_labels:
271
+ icons = clusters[lid]["icons"]
272
+ theme = clusters[lid].get("theme") or f"Cluster {lid}"
273
+ color = label_colors[lid]
274
+ is_noise = lid == "-1"
275
+
276
+ hover = [
277
+ f"<b>{name}</b><br><i>{theme}</i><br><br>{descriptions.get(name, '')[:150]}"
278
+ for name in icons
279
+ ]
280
+
281
+ fig.add_trace(
282
+ go.Scatter(
283
+ x=[coords[n][0] for n in icons],
284
+ y=[coords[n][1] for n in icons],
285
+ mode="markers",
286
+ name=f"{theme} ({len(icons)})",
287
+ marker={
288
+ "size": 3 if is_noise else 5,
289
+ "color": color,
290
+ "opacity": 0.3 if is_noise else 0.8,
291
+ "line": {"width": 0.5, "color": "white"},
292
+ },
293
+ text=hover,
294
+ hoverinfo="text",
295
+ )
296
+ )
297
+
298
+ fig.update_layout(
299
+ title={
300
+ "text": (
301
+ "Lucide Icon Embedding Clusters \u2014 themes discovered via HDBSCAN"
302
+ ),
303
+ "font": {"size": 16},
304
+ },
305
+ width=1400,
306
+ height=900,
307
+ template="plotly_dark",
308
+ legend={
309
+ "title": "Discovered Themes",
310
+ "font": {"size": 10},
311
+ "itemsizing": "constant",
312
+ "yanchor": "top",
313
+ "y": 1,
314
+ "xanchor": "left",
315
+ "x": 1.02,
316
+ },
317
+ xaxis={"showgrid": False, "showticklabels": False, "title": ""},
318
+ yaxis={"showgrid": False, "showticklabels": False, "title": ""},
319
+ hovermode="closest",
320
+ )
321
+
322
+ fig.write_html(str(output_path), include_plotlyjs=True, full_html=True)
323
+ size_mb = output_path.stat().st_size / (1024 * 1024)
324
+ logger.info("Visualization written: %s (%.1f MB)", output_path, size_mb)