python-lucide 0.2.24__tar.gz → 0.3.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 (25) hide show
  1. {python_lucide-0.2.24 → python_lucide-0.3.0}/PKG-INFO +3 -1
  2. {python_lucide-0.2.24 → python_lucide-0.3.0}/pyproject.toml +26 -6
  3. {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/__init__.py +12 -0
  4. python_lucide-0.3.0/src/lucide/build_clusters.py +287 -0
  5. python_lucide-0.3.0/src/lucide/build_search.py +771 -0
  6. python_lucide-0.3.0/src/lucide/cli.py +935 -0
  7. python_lucide-0.3.0/src/lucide/config.py +30 -0
  8. python_lucide-0.3.0/src/lucide/data/gemini-icon-descriptions.jsonl +1733 -0
  9. python_lucide-0.3.0/src/lucide/data/lucide-icon-clusters.json +9028 -0
  10. python_lucide-0.3.0/src/lucide/data/lucide-icons.db +0 -0
  11. python_lucide-0.3.0/src/lucide/search.py +317 -0
  12. python_lucide-0.3.0/tests/build_search_test.py +326 -0
  13. python_lucide-0.3.0/tests/cli_test.py +213 -0
  14. python_lucide-0.3.0/tests/search_test.py +298 -0
  15. python_lucide-0.2.24/src/lucide/cli.py +0 -418
  16. python_lucide-0.2.24/src/lucide/config.py +0 -10
  17. python_lucide-0.2.24/src/lucide/data/lucide-icons.db +0 -0
  18. python_lucide-0.2.24/tests/cli_test.py +0 -153
  19. {python_lucide-0.2.24 → python_lucide-0.3.0}/README.md +0 -0
  20. {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/core.py +0 -0
  21. {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/data/__init__.py +0 -0
  22. {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/db.py +0 -0
  23. {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/dev_utils.py +0 -0
  24. {python_lucide-0.2.24 → python_lucide-0.3.0}/tests/__init__.py +0 -0
  25. {python_lucide-0.2.24 → python_lucide-0.3.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.24
3
+ Version: 0.3.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
@@ -1,17 +1,22 @@
1
1
  [build-system]
2
2
  build-backend = "uv_build"
3
- requires = ["uv_build>=0.8.3,<0.11.0"]
3
+ 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",
10
14
  "pytest-cov>=4.0.0",
11
15
  "pytest>=7.0.0",
12
16
  "ruff>=0.1.0",
13
17
  "types-setuptools>=80.9.0.20250529",
14
- "typing-extensions>=4.1.0"
18
+ "typing-extensions>=4.1.0",
19
+ "umap-learn>=0.5.0"
15
20
  ]
16
21
 
17
22
  [project]
@@ -35,11 +40,15 @@ license = {text = "MIT"}
35
40
  name = "python-lucide"
36
41
  readme = "README.md"
37
42
  requires-python = ">=3.10"
38
- version = "0.2.24"
43
+ version = "0.3.0"
44
+
45
+ [project.optional-dependencies]
46
+ search = ["fastembed>=0.4.0"]
39
47
 
40
48
  [project.scripts]
41
49
  check-lucide-version = "lucide.dev_utils:print_version_status"
42
- lucide-db = "lucide.cli:main"
50
+ lucide = "lucide.cli:main"
51
+ lucide-db = "lucide.cli:main_legacy_db"
43
52
 
44
53
  [project.urls]
45
54
  "Bug Tracker" = "https://github.com/mmacpherson/python-lucide/issues"
@@ -126,7 +135,8 @@ section-order = [
126
135
 
127
136
  [tool.ruff.lint.per-file-ignores]
128
137
  "**/__init__.py" = ["F401"]
129
- "tests/**/*.py" = ["D", "SLF001"]
138
+ "scripts/**/*.py" = ["D", "SLF001", "PLR2004", "PLR0915", "PLC0415", "ERA001", "E741", "C408"]
139
+ "tests/**/*.py" = ["D", "SLF001", "PLR2004", "ARG005"]
130
140
 
131
141
  [tool.ruff.lint.pydocstyle]
132
142
  convention = "google"
@@ -134,8 +144,18 @@ convention = "google"
134
144
  [tool.uv.build-backend]
135
145
  # The module name differs from the normalized package name
136
146
  module-name = "lucide"
147
+ # The search DB is a derived artifact published as a GitHub release asset
148
+ # and downloaded on first use; never package it
149
+ source-exclude = ["src/lucide/data/lucide-search.db"]
137
150
  # Include tests and database files in source distribution
138
151
  source-include = [
139
152
  "tests/**",
140
- "src/lucide/data/*.db"
153
+ "src/lucide/data/lucide-icons.db"
154
+ ]
155
+ # Build-time data (descriptions, clusters) stays in the sdist/repo but out
156
+ # of the runtime wheel
157
+ wheel-exclude = [
158
+ "lucide/data/lucide-search.db",
159
+ "lucide/data/gemini-icon-descriptions.jsonl",
160
+ "lucide/data/lucide-icon-clusters.json"
141
161
  ]
@@ -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,287 @@
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
+ import urllib.request
17
+ from datetime import datetime, timezone
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
22
+
23
+ NAMING_PROMPT_TEMPLATE = """\
24
+ Here are icon names that form a visual/semantic cluster: {icon_names}
25
+
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".
29
+
30
+ Return ONLY the theme name, nothing else."""
31
+
32
+ GEMINI_API_URL = (
33
+ "https://generativelanguage.googleapis.com/v1beta/models/"
34
+ "{model}:generateContent?key={api_key}"
35
+ )
36
+
37
+
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
61
+
62
+
63
+ def discover_clusters(
64
+ search_db_path: pathlib.Path,
65
+ *,
66
+ min_cluster_size: int = 5,
67
+ ) -> dict:
68
+ """Run UMAP + HDBSCAN on the icon embeddings.
69
+
70
+ Args:
71
+ search_db_path: Path to the search SQLite database.
72
+ min_cluster_size: Minimum icons to form a cluster.
73
+
74
+ Returns:
75
+ Dict with keys: clusters, coords, descriptions.
76
+ """
77
+ import numpy as np # noqa: PLC0415
78
+ import umap # noqa: PLC0415
79
+ from hdbscan import HDBSCAN # noqa: PLC0415
80
+
81
+ conn = sqlite3.connect(f"file:{search_db_path}?mode=ro", uri=True)
82
+ rows = conn.execute(
83
+ "SELECT e.name, e.embedding, d.description "
84
+ "FROM icon_embeddings e "
85
+ "JOIN icon_descriptions d ON e.name = d.name "
86
+ "ORDER BY e.name"
87
+ ).fetchall()
88
+ conn.close()
89
+
90
+ names = [r[0] for r in rows]
91
+ descriptions = {r[0]: r[2] for r in rows}
92
+ matrix = np.stack([np.frombuffer(r[1], dtype=np.float32) for r in rows])
93
+ logger.info("Loaded %d embeddings (%dd)", len(names), matrix.shape[1])
94
+
95
+ logger.info("Running UMAP (2D projection)...")
96
+ reducer = umap.UMAP(
97
+ n_components=2,
98
+ n_neighbors=15,
99
+ min_dist=0.1,
100
+ metric="cosine",
101
+ random_state=42,
102
+ )
103
+ coords_array = reducer.fit_transform(matrix)
104
+
105
+ logger.info("Running HDBSCAN (min_cluster_size=%d)...", min_cluster_size)
106
+ clusterer = HDBSCAN(
107
+ min_cluster_size=min_cluster_size,
108
+ metric="euclidean",
109
+ cluster_selection_method="eom",
110
+ )
111
+ labels = clusterer.fit_predict(coords_array)
112
+
113
+ n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
114
+ n_noise = int((labels == -1).sum())
115
+ logger.info("Found %d clusters, %d unclustered points", n_clusters, n_noise)
116
+
117
+ clusters: dict[str, dict] = {}
118
+ for i, label in enumerate(labels):
119
+ lid = str(int(label))
120
+ if lid not in clusters:
121
+ clusters[lid] = {"icons": [], "theme": None}
122
+ clusters[lid]["icons"].append(names[i])
123
+
124
+ for lid in clusters:
125
+ clusters[lid]["icons"].sort()
126
+
127
+ coords = {
128
+ names[i]: [float(coords_array[i, 0]), float(coords_array[i, 1])]
129
+ for i in range(len(names))
130
+ }
131
+
132
+ return {
133
+ "clusters": clusters,
134
+ "coords": coords,
135
+ "descriptions": descriptions,
136
+ }
137
+
138
+
139
+ def name_clusters(
140
+ data: dict,
141
+ *,
142
+ api_key: str | None = None,
143
+ ) -> dict:
144
+ """Name each cluster using Gemini Flash.
145
+
146
+ Args:
147
+ data: Output from ``discover_clusters()``.
148
+ api_key: Gemini API key. Falls back to ``GEMINI_API_KEY`` env var.
149
+
150
+ Returns:
151
+ The same data dict with ``theme`` populated for each cluster.
152
+ """
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.")
156
+
157
+ clusters = data["clusters"]
158
+ for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
159
+ if lid == "-1":
160
+ clusters[lid]["theme"] = "Unclustered"
161
+ continue
162
+
163
+ icons = clusters[lid]["icons"]
164
+ # Cap at 40 names to keep prompt short
165
+ 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:
175
+ clusters[lid]["theme"] = f"Cluster {lid}"
176
+ logger.warning("Failed to name cluster %s", lid)
177
+
178
+ return data
179
+
180
+
181
+ def save_clusters_json(
182
+ data: dict,
183
+ output_path: pathlib.Path,
184
+ ) -> None:
185
+ """Save cluster data to JSON."""
186
+ # Strip descriptions from the output to keep it lean; keep the UMAP
187
+ # coords so downstream exporters (the web explore map) reuse this
188
+ # projection instead of recomputing it.
189
+ out = {
190
+ "clusters": data["clusters"],
191
+ "coords": {
192
+ name: [round(c[0], 5), round(c[1], 5)] for name, c in data["coords"].items()
193
+ },
194
+ "generated_at": datetime.now(tz=timezone.utc).isoformat(),
195
+ "naming_model": CLUSTER_NAMING_MODEL,
196
+ }
197
+ output_path.write_text(json.dumps(out, indent=2, ensure_ascii=False))
198
+ logger.info("Saved clusters to %s", output_path)
199
+
200
+
201
+ def build_cluster_visualization(
202
+ data: dict,
203
+ output_path: pathlib.Path,
204
+ ) -> None:
205
+ """Build an interactive HTML scatter plot of the clusters."""
206
+ import colorsys # noqa: PLC0415
207
+
208
+ import plotly.graph_objects as go # noqa: PLC0415
209
+
210
+ clusters = data["clusters"]
211
+ coords = data["coords"]
212
+ descriptions = data["descriptions"]
213
+
214
+ real_clusters = [cid for cid in clusters if cid != "-1"]
215
+ n = len(real_clusters)
216
+
217
+ palette = []
218
+ for i in range(n):
219
+ h = i / n
220
+ r, g, b = colorsys.hsv_to_rgb(h, 0.7, 0.9)
221
+ palette.append(f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})")
222
+
223
+ label_colors = {cid: palette[i] for i, cid in enumerate(real_clusters)}
224
+ label_colors["-1"] = "rgb(80,80,80)"
225
+
226
+ fig = go.Figure()
227
+
228
+ sorted_labels = sorted(real_clusters, key=lambda k: -len(clusters[k]["icons"]))
229
+ if "-1" in clusters:
230
+ sorted_labels.append("-1")
231
+
232
+ for lid in sorted_labels:
233
+ icons = clusters[lid]["icons"]
234
+ theme = clusters[lid].get("theme") or f"Cluster {lid}"
235
+ color = label_colors[lid]
236
+ is_noise = lid == "-1"
237
+
238
+ hover = [
239
+ f"<b>{name}</b><br><i>{theme}</i><br><br>{descriptions.get(name, '')[:150]}"
240
+ for name in icons
241
+ ]
242
+
243
+ fig.add_trace(
244
+ go.Scatter(
245
+ x=[coords[n][0] for n in icons],
246
+ y=[coords[n][1] for n in icons],
247
+ mode="markers",
248
+ name=f"{theme} ({len(icons)})",
249
+ marker={
250
+ "size": 3 if is_noise else 5,
251
+ "color": color,
252
+ "opacity": 0.3 if is_noise else 0.8,
253
+ "line": {"width": 0.5, "color": "white"},
254
+ },
255
+ text=hover,
256
+ hoverinfo="text",
257
+ )
258
+ )
259
+
260
+ fig.update_layout(
261
+ title={
262
+ "text": (
263
+ "Lucide Icon Embedding Clusters \u2014 "
264
+ "themes discovered via HDBSCAN in 768d space"
265
+ ),
266
+ "font": {"size": 16},
267
+ },
268
+ width=1400,
269
+ height=900,
270
+ template="plotly_dark",
271
+ legend={
272
+ "title": "Discovered Themes",
273
+ "font": {"size": 10},
274
+ "itemsizing": "constant",
275
+ "yanchor": "top",
276
+ "y": 1,
277
+ "xanchor": "left",
278
+ "x": 1.02,
279
+ },
280
+ xaxis={"showgrid": False, "showticklabels": False, "title": ""},
281
+ yaxis={"showgrid": False, "showticklabels": False, "title": ""},
282
+ hovermode="closest",
283
+ )
284
+
285
+ fig.write_html(str(output_path), include_plotlyjs=True, full_html=True)
286
+ size_mb = output_path.stat().st_size / (1024 * 1024)
287
+ logger.info("Visualization written: %s (%.1f MB)", output_path, size_mb)