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.
- {python_lucide-0.2.24 → python_lucide-0.3.0}/PKG-INFO +3 -1
- {python_lucide-0.2.24 → python_lucide-0.3.0}/pyproject.toml +26 -6
- {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/__init__.py +12 -0
- python_lucide-0.3.0/src/lucide/build_clusters.py +287 -0
- python_lucide-0.3.0/src/lucide/build_search.py +771 -0
- python_lucide-0.3.0/src/lucide/cli.py +935 -0
- python_lucide-0.3.0/src/lucide/config.py +30 -0
- python_lucide-0.3.0/src/lucide/data/gemini-icon-descriptions.jsonl +1733 -0
- python_lucide-0.3.0/src/lucide/data/lucide-icon-clusters.json +9028 -0
- python_lucide-0.3.0/src/lucide/data/lucide-icons.db +0 -0
- python_lucide-0.3.0/src/lucide/search.py +317 -0
- python_lucide-0.3.0/tests/build_search_test.py +326 -0
- python_lucide-0.3.0/tests/cli_test.py +213 -0
- python_lucide-0.3.0/tests/search_test.py +298 -0
- python_lucide-0.2.24/src/lucide/cli.py +0 -418
- python_lucide-0.2.24/src/lucide/config.py +0 -10
- python_lucide-0.2.24/src/lucide/data/lucide-icons.db +0 -0
- python_lucide-0.2.24/tests/cli_test.py +0 -153
- {python_lucide-0.2.24 → python_lucide-0.3.0}/README.md +0 -0
- {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/core.py +0 -0
- {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/data/__init__.py +0 -0
- {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/db.py +0 -0
- {python_lucide-0.2.24 → python_lucide-0.3.0}/src/lucide/dev_utils.py +0 -0
- {python_lucide-0.2.24 → python_lucide-0.3.0}/tests/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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)
|