python-lucide 0.4.0__tar.gz → 0.5.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.4.0 → python_lucide-0.5.0}/PKG-INFO +57 -1
- {python_lucide-0.4.0 → python_lucide-0.5.0}/README.md +55 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/pyproject.toml +2 -2
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/cli.py +134 -29
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/config.py +10 -11
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/search.py +1 -1
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/cli_test.py +105 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/__init__.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/build_clusters.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/build_search.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/core.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/__init__.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/gemini-icon-descriptions.jsonl +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icon-clusters.json +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icons.db +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/db.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/dev_utils.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/__init__.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/build_clusters_test.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/build_search_test.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/conftest.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/core_test.py +0 -0
- {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/search_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.5.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,6 +15,7 @@ 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: cairosvg>=2.7.0 ; extra == 'search'
|
|
18
19
|
Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
|
|
19
20
|
Requires-Python: >=3.10
|
|
20
21
|
Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
|
|
@@ -43,6 +44,7 @@ in your Python projects, with no javascript in sight.
|
|
|
43
44
|
|
|
44
45
|
## Features
|
|
45
46
|
- 🎨 **Access 1600+ Lucide icons** directly from Python
|
|
47
|
+
- 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
|
|
46
48
|
- 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
|
|
47
49
|
- 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
|
|
48
50
|
- 📦 **Lightweight** with minimal dependencies
|
|
@@ -83,6 +85,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
|
|
|
83
85
|
lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
## Semantic Search
|
|
89
|
+
|
|
90
|
+
Find icons by describing what you mean, not what they're called — in English
|
|
91
|
+
or [50+ languages](https://mmacpherson.github.io/python-lucide/):
|
|
92
|
+
|
|
93
|
+
<p align="center">
|
|
94
|
+
<a href="https://mmacpherson.github.io/python-lucide/">
|
|
95
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching "celebrate a big achievement" returns trophy, award, medal, party-popper">
|
|
96
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
|
|
97
|
+
</a>
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# One-off, no install (requires uv)
|
|
102
|
+
uvx --from "python-lucide[search]" lucide search "waiting for a download"
|
|
103
|
+
|
|
104
|
+
# Or install the search extra
|
|
105
|
+
pip install "python-lucide[search]"
|
|
106
|
+
lucide search "celebrate a big achievement"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
|
|
110
|
+
|
|
111
|
+
In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
|
|
112
|
+
WezTerm), the icons render inline as above — inside tmux too, with
|
|
113
|
+
`tmux set -g allow-passthrough on`. Elsewhere you get the plain text
|
|
114
|
+
list. (Rendering uses the cairo system library; if it's missing, the
|
|
115
|
+
CLI prints a hint.)
|
|
116
|
+
|
|
117
|
+
Icons are matched against AI-generated descriptions using the same embedding
|
|
118
|
+
model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
|
|
119
|
+
so descriptive phrases ("an idea just occurred to me") work better than
|
|
120
|
+
keywords. Options:
|
|
121
|
+
|
|
122
|
+
- `--model multilingual` — search in 50+ languages (default `bge-small` ranks
|
|
123
|
+
best for English)
|
|
124
|
+
- `--limit N` — number of results; `--verbose` — include each icon's description
|
|
125
|
+
|
|
126
|
+
The first search downloads the embedding model (~67 MB) and the pre-built
|
|
127
|
+
search index (11 MB); both are cached, and every search after that runs
|
|
128
|
+
locally in well under a second.
|
|
129
|
+
|
|
130
|
+
The same search is available as a Python API:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from lucide.search import search_icons
|
|
134
|
+
|
|
135
|
+
for r in search_icons("secure login", limit=5):
|
|
136
|
+
print(r.name, r.score)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
|
|
140
|
+
runs the whole pipeline client-side.
|
|
141
|
+
|
|
86
142
|
## Framework Integration Examples
|
|
87
143
|
|
|
88
144
|
### Starlette
|
|
@@ -18,6 +18,7 @@ in your Python projects, with no javascript in sight.
|
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
- 🎨 **Access 1600+ Lucide icons** directly from Python
|
|
21
|
+
- 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
|
|
21
22
|
- 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
|
|
22
23
|
- 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
|
|
23
24
|
- 📦 **Lightweight** with minimal dependencies
|
|
@@ -58,6 +59,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
|
|
|
58
59
|
lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
## Semantic Search
|
|
63
|
+
|
|
64
|
+
Find icons by describing what you mean, not what they're called — in English
|
|
65
|
+
or [50+ languages](https://mmacpherson.github.io/python-lucide/):
|
|
66
|
+
|
|
67
|
+
<p align="center">
|
|
68
|
+
<a href="https://mmacpherson.github.io/python-lucide/">
|
|
69
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching "celebrate a big achievement" returns trophy, award, medal, party-popper">
|
|
70
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
|
|
71
|
+
</a>
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# One-off, no install (requires uv)
|
|
76
|
+
uvx --from "python-lucide[search]" lucide search "waiting for a download"
|
|
77
|
+
|
|
78
|
+
# Or install the search extra
|
|
79
|
+
pip install "python-lucide[search]"
|
|
80
|
+
lucide search "celebrate a big achievement"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
|
|
84
|
+
|
|
85
|
+
In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
|
|
86
|
+
WezTerm), the icons render inline as above — inside tmux too, with
|
|
87
|
+
`tmux set -g allow-passthrough on`. Elsewhere you get the plain text
|
|
88
|
+
list. (Rendering uses the cairo system library; if it's missing, the
|
|
89
|
+
CLI prints a hint.)
|
|
90
|
+
|
|
91
|
+
Icons are matched against AI-generated descriptions using the same embedding
|
|
92
|
+
model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
|
|
93
|
+
so descriptive phrases ("an idea just occurred to me") work better than
|
|
94
|
+
keywords. Options:
|
|
95
|
+
|
|
96
|
+
- `--model multilingual` — search in 50+ languages (default `bge-small` ranks
|
|
97
|
+
best for English)
|
|
98
|
+
- `--limit N` — number of results; `--verbose` — include each icon's description
|
|
99
|
+
|
|
100
|
+
The first search downloads the embedding model (~67 MB) and the pre-built
|
|
101
|
+
search index (11 MB); both are cached, and every search after that runs
|
|
102
|
+
locally in well under a second.
|
|
103
|
+
|
|
104
|
+
The same search is available as a Python API:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from lucide.search import search_icons
|
|
108
|
+
|
|
109
|
+
for r in search_icons("secure login", limit=5):
|
|
110
|
+
print(r.name, r.score)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
|
|
114
|
+
runs the whole pipeline client-side.
|
|
115
|
+
|
|
61
116
|
## Framework Integration Examples
|
|
62
117
|
|
|
63
118
|
### Starlette
|
|
@@ -41,10 +41,10 @@ license = {text = "MIT"}
|
|
|
41
41
|
name = "python-lucide"
|
|
42
42
|
readme = "README.md"
|
|
43
43
|
requires-python = ">=3.10"
|
|
44
|
-
version = "0.
|
|
44
|
+
version = "0.5.0"
|
|
45
45
|
|
|
46
46
|
[project.optional-dependencies]
|
|
47
|
-
search = ["fastembed>=0.4.0"]
|
|
47
|
+
search = ["cairosvg>=2.7.0", "fastembed>=0.4.0"]
|
|
48
48
|
|
|
49
49
|
[project.scripts]
|
|
50
50
|
check-lucide-version = "lucide.dev_utils:print_version_status"
|
|
@@ -529,6 +529,40 @@ def _cmd_build_search(args: argparse.Namespace) -> int:
|
|
|
529
529
|
return 0
|
|
530
530
|
|
|
531
531
|
|
|
532
|
+
def _first_run_downloads(model: str) -> list[str]:
|
|
533
|
+
"""Human-readable one-time downloads the next search will trigger.
|
|
534
|
+
|
|
535
|
+
Best-effort: peeks at fastembed's model cache and the search-DB cache;
|
|
536
|
+
any surprise in their layout just means the notice is skipped.
|
|
537
|
+
"""
|
|
538
|
+
from .search import search_available # noqa: PLC0415
|
|
539
|
+
|
|
540
|
+
pending = []
|
|
541
|
+
try:
|
|
542
|
+
from fastembed import TextEmbedding # noqa: PLC0415
|
|
543
|
+
from fastembed.common.utils import define_cache_dir # noqa: PLC0415
|
|
544
|
+
|
|
545
|
+
fastembed_name = EMBEDDING_MODELS[model].fastembed_model
|
|
546
|
+
meta = next(
|
|
547
|
+
m
|
|
548
|
+
for m in TextEmbedding.list_supported_models()
|
|
549
|
+
if m["model"] == fastembed_name
|
|
550
|
+
)
|
|
551
|
+
hf_repo = (meta.get("sources") or {}).get("hf") or ""
|
|
552
|
+
size_mb = round(meta["size_in_GB"] * 1000)
|
|
553
|
+
model_dir = (
|
|
554
|
+
pathlib.Path(define_cache_dir(None))
|
|
555
|
+
/ f"models--{hf_repo.replace('/', '--')}"
|
|
556
|
+
)
|
|
557
|
+
if hf_repo and not model_dir.exists():
|
|
558
|
+
pending.append(f"embedding model (~{size_mb} MB)")
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
if not search_available():
|
|
562
|
+
pending.append("search index (11 MB)")
|
|
563
|
+
return pending
|
|
564
|
+
|
|
565
|
+
|
|
532
566
|
def _cmd_search(args: argparse.Namespace) -> int:
|
|
533
567
|
"""Handle ``lucide search``."""
|
|
534
568
|
import os # noqa: PLC0415
|
|
@@ -549,6 +583,19 @@ def _cmd_search(args: argparse.Namespace) -> int:
|
|
|
549
583
|
if candidate.exists():
|
|
550
584
|
os.environ["LUCIDE_SEARCH_DB_PATH"] = str(candidate)
|
|
551
585
|
|
|
586
|
+
# Downloading without a token is expected here; the hf_xet download
|
|
587
|
+
# backend's HF_TOKEN nudge reads like an error mid-download, so keep
|
|
588
|
+
# warnings from its logger out of the way
|
|
589
|
+
logging.getLogger("hf_xet").setLevel(logging.ERROR)
|
|
590
|
+
|
|
591
|
+
pending = _first_run_downloads(args.model)
|
|
592
|
+
if pending:
|
|
593
|
+
print(
|
|
594
|
+
f"First run: downloading {' and '.join(pending)} — "
|
|
595
|
+
"cached for future searches.",
|
|
596
|
+
file=sys.stderr,
|
|
597
|
+
)
|
|
598
|
+
|
|
552
599
|
try:
|
|
553
600
|
results = search_icons(args.query, limit=args.limit, model=args.model)
|
|
554
601
|
except Exception as e:
|
|
@@ -559,7 +606,9 @@ def _cmd_search(args: argparse.Namespace) -> int:
|
|
|
559
606
|
print("No results found.")
|
|
560
607
|
return 0
|
|
561
608
|
|
|
562
|
-
show_icons =
|
|
609
|
+
show_icons, graphics_tip = _graphics_support()
|
|
610
|
+
if graphics_tip:
|
|
611
|
+
print(graphics_tip, file=sys.stderr)
|
|
563
612
|
svgs: dict[str, str] = {}
|
|
564
613
|
if show_icons:
|
|
565
614
|
svgs = _load_svgs_for_results([r.name for r in results])
|
|
@@ -570,36 +619,77 @@ def _cmd_search(args: argparse.Namespace) -> int:
|
|
|
570
619
|
cyan = "\033[38;2;100;200;220m"
|
|
571
620
|
|
|
572
621
|
for r in results:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
622
|
+
prefix = " "
|
|
623
|
+
if show_icons:
|
|
624
|
+
drawn = r.name in svgs and _display_kitty_image(svgs[r.name])
|
|
625
|
+
# The image sits in columns 1-2 (cursor untouched, C=1), so
|
|
626
|
+
# step over it to keep names aligned whether or not it drew
|
|
627
|
+
prefix = "\033[3C" if drawn else " "
|
|
628
|
+
print(f"{prefix}{bold}{cyan}{r.name}{reset} {dim}{r.score:.0%}{reset}")
|
|
576
629
|
if args.verbose and r.description:
|
|
577
630
|
print(f" {dim}{r.description[:100]}{reset}")
|
|
578
|
-
if show_icons:
|
|
579
|
-
print()
|
|
580
631
|
|
|
581
632
|
return 0
|
|
582
633
|
|
|
583
634
|
|
|
584
|
-
def
|
|
585
|
-
"""
|
|
635
|
+
def _graphics_support() -> tuple[bool, str | None]:
|
|
636
|
+
"""Decide whether to render inline icons via the Kitty graphics protocol.
|
|
586
637
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
638
|
+
Returns:
|
|
639
|
+
A ``(show_icons, tip)`` pair. *tip* is a one-line stderr hint for
|
|
640
|
+
the cases where the terminal could show icons but something the
|
|
641
|
+
user can fix is in the way (tmux passthrough off, cairosvg not
|
|
642
|
+
installed) — silently dropping the feature proved confusing.
|
|
590
643
|
"""
|
|
591
644
|
import os # noqa: PLC0415
|
|
592
645
|
|
|
593
|
-
# tmux blocks reliable graphics rendering
|
|
594
|
-
if os.environ.get("TMUX") or "screen" in os.environ.get("TERM", ""):
|
|
595
|
-
return False
|
|
596
|
-
|
|
597
646
|
term = os.environ.get("TERM_PROGRAM", "")
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
647
|
+
host_capable = (
|
|
648
|
+
term in ("ghostty", "kitty", "WezTerm")
|
|
649
|
+
or bool(os.environ.get("GHOSTTY_RESOURCES_DIR"))
|
|
650
|
+
or bool(os.environ.get("KITTY_WINDOW_ID"))
|
|
651
|
+
or "kitty" in os.environ.get("TERM", "")
|
|
652
|
+
)
|
|
653
|
+
if not host_capable:
|
|
654
|
+
return False, None
|
|
655
|
+
|
|
656
|
+
if os.environ.get("TMUX") and not _tmux_allows_passthrough():
|
|
657
|
+
return False, (
|
|
658
|
+
"Tip: your terminal can show icons inline, but tmux blocks "
|
|
659
|
+
"them — run: tmux set -g allow-passthrough on"
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
import cairosvg # noqa: F401, PLC0415
|
|
664
|
+
except ImportError:
|
|
665
|
+
return False, (
|
|
666
|
+
"Tip: your terminal can show icons inline — install cairosvg "
|
|
667
|
+
"to enable (pip install cairosvg, or uvx --with cairosvg)."
|
|
668
|
+
)
|
|
669
|
+
except OSError:
|
|
670
|
+
# cairosvg is installed but the native cairo library is absent;
|
|
671
|
+
# cairocffi raises OSError (not ImportError) from its dlopen
|
|
672
|
+
return False, (
|
|
673
|
+
"Tip: inline icons need the cairo system library "
|
|
674
|
+
"(e.g. apt install libcairo2, or brew install cairo)."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
return True, None
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _tmux_allows_passthrough() -> bool:
|
|
681
|
+
"""Check whether the current tmux pane permits escape passthrough."""
|
|
682
|
+
try:
|
|
683
|
+
proc = subprocess.run(
|
|
684
|
+
["tmux", "show", "-Apv", "allow-passthrough"],
|
|
685
|
+
capture_output=True,
|
|
686
|
+
text=True,
|
|
687
|
+
timeout=2,
|
|
688
|
+
check=False,
|
|
689
|
+
)
|
|
690
|
+
except Exception:
|
|
691
|
+
return False
|
|
692
|
+
return proc.stdout.strip() in ("on", "all")
|
|
603
693
|
|
|
604
694
|
|
|
605
695
|
def _load_svgs_for_results(names: list[str]) -> dict[str, str]:
|
|
@@ -619,30 +709,45 @@ def _load_svgs_for_results(names: list[str]) -> dict[str, str]:
|
|
|
619
709
|
return svgs
|
|
620
710
|
|
|
621
711
|
|
|
622
|
-
def _display_kitty_image(svg_content: str) ->
|
|
623
|
-
"""Render an SVG
|
|
712
|
+
def _display_kitty_image(svg_content: str) -> bool:
|
|
713
|
+
"""Render an SVG as a one-row inline image at the cursor position.
|
|
714
|
+
|
|
715
|
+
Emits with ``C=1`` so the cursor stays put and the caller controls
|
|
716
|
+
layout with ordinary cursor movement — which is also what makes tmux
|
|
717
|
+
passthrough safe: tmux never sees the image escape, and everything
|
|
718
|
+
that *does* move the cursor goes through tmux normally.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
True if an image escape was written.
|
|
722
|
+
"""
|
|
624
723
|
import base64 # noqa: PLC0415
|
|
625
|
-
import
|
|
724
|
+
import os # noqa: PLC0415
|
|
626
725
|
|
|
627
726
|
try:
|
|
628
727
|
import cairosvg # noqa: PLC0415
|
|
629
|
-
except ImportError:
|
|
630
|
-
return
|
|
728
|
+
except (ImportError, OSError):
|
|
729
|
+
return False
|
|
631
730
|
|
|
632
731
|
try:
|
|
633
732
|
# Insert a white background rect so icons show on any terminal bg
|
|
634
733
|
bg_rect = '<rect width="100%" height="100%" fill="white" rx="3"/>'
|
|
635
734
|
padded_svg = svg_content.replace(">", f">{bg_rect}", 1)
|
|
735
|
+
# Rendered larger than displayed; the terminal downscales, which
|
|
736
|
+
# keeps strokes crisp on hidpi displays
|
|
636
737
|
png_data: bytes = cairosvg.svg2png(
|
|
637
738
|
bytestring=padded_svg.encode("utf-8"),
|
|
638
|
-
output_width=
|
|
639
|
-
output_height=
|
|
739
|
+
output_width=96,
|
|
740
|
+
output_height=96,
|
|
640
741
|
)
|
|
641
742
|
b64 = base64.b64encode(png_data).decode("ascii")
|
|
642
|
-
|
|
743
|
+
seq = f"\033_Gf=100,t=d,a=T,r=1,c=2,C=1,q=2;{b64}\033\\"
|
|
744
|
+
if os.environ.get("TMUX"):
|
|
745
|
+
seq = "\033Ptmux;" + seq.replace("\033", "\033\033") + "\033\\"
|
|
746
|
+
sys.stdout.write(seq)
|
|
643
747
|
sys.stdout.flush()
|
|
644
748
|
except Exception:
|
|
645
|
-
|
|
749
|
+
return False
|
|
750
|
+
return True
|
|
646
751
|
|
|
647
752
|
|
|
648
753
|
def _cmd_cluster(args: argparse.Namespace) -> int:
|
|
@@ -35,8 +35,10 @@ class EmbeddingModelConfig:
|
|
|
35
35
|
pooling: transformers.js pooling mode ("mean" or "cls").
|
|
36
36
|
web_dtype: transformers.js quantization. Document vectors always
|
|
37
37
|
come from fastembed (fp32), so a quantized browser model only
|
|
38
|
-
perturbs the query vector — q8 stays ~0.
|
|
39
|
-
which is ranking-equivalent
|
|
38
|
+
perturbs the query vector — q8 stays ~0.985-0.996 cosine to
|
|
39
|
+
fp32 across our models, which is ranking-equivalent (top-1
|
|
40
|
+
changes are swaps between near-tied results), and roughly
|
|
41
|
+
quarters the download.
|
|
40
42
|
query_prefix: Prefix prepended to queries (asymmetric retrieval).
|
|
41
43
|
document_prefix: Prefix prepended to documents at build time.
|
|
42
44
|
label: Human-facing label for the web UI model toggle.
|
|
@@ -53,23 +55,20 @@ class EmbeddingModelConfig:
|
|
|
53
55
|
label: str = ""
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
# Two models, not three: at q8 bge-small is 33 MB, close enough to
|
|
59
|
+
# all-MiniLM's 22 MB that a separate "Faster" tier bought nothing —
|
|
60
|
+
# evaluated against bge-base and arctic-embed-m (both ~104 MB q8) too,
|
|
61
|
+
# and neither beat bge-small on this corpus.
|
|
56
62
|
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
63
|
"bge-small": EmbeddingModelConfig(
|
|
66
64
|
id="bge-small",
|
|
67
65
|
fastembed_model="BAAI/bge-small-en-v1.5",
|
|
68
66
|
web_model="Xenova/bge-small-en-v1.5",
|
|
69
67
|
dim=384,
|
|
70
68
|
pooling="cls",
|
|
69
|
+
web_dtype="q8",
|
|
71
70
|
query_prefix="Represent this sentence for searching relevant passages: ",
|
|
72
|
-
label="
|
|
71
|
+
label="English",
|
|
73
72
|
),
|
|
74
73
|
"multilingual": EmbeddingModelConfig(
|
|
75
74
|
id="multilingual",
|
|
@@ -280,7 +280,7 @@ def search_icons(
|
|
|
280
280
|
"""Search for icons by natural language query.
|
|
281
281
|
|
|
282
282
|
On the first call the search database is downloaded and cached in
|
|
283
|
-
``~/.cache/python-lucide/``. The embedding model (~
|
|
283
|
+
``~/.cache/python-lucide/``. The embedding model (~67-220 MB depending
|
|
284
284
|
on *model*) is also downloaded once by *fastembed*.
|
|
285
285
|
|
|
286
286
|
Args:
|
|
@@ -223,3 +223,108 @@ class TestMainSubcommands:
|
|
|
223
223
|
):
|
|
224
224
|
result = cli.main()
|
|
225
225
|
assert result == 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestInlineIcons:
|
|
229
|
+
GRAPHICS_ENV_VARS = (
|
|
230
|
+
"TERM_PROGRAM",
|
|
231
|
+
"GHOSTTY_RESOURCES_DIR",
|
|
232
|
+
"KITTY_WINDOW_ID",
|
|
233
|
+
"TERM",
|
|
234
|
+
"TMUX",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
@pytest.fixture(autouse=True)
|
|
238
|
+
def clean_env(self, monkeypatch):
|
|
239
|
+
for var in self.GRAPHICS_ENV_VARS:
|
|
240
|
+
monkeypatch.delenv(var, raising=False)
|
|
241
|
+
|
|
242
|
+
def test_plain_terminal_no_icons_no_tip(self, monkeypatch):
|
|
243
|
+
monkeypatch.setenv("TERM", "xterm-256color")
|
|
244
|
+
show, tip = cli._graphics_support()
|
|
245
|
+
assert show is False
|
|
246
|
+
assert tip is None
|
|
247
|
+
|
|
248
|
+
def test_ghostty_enables_icons(self, monkeypatch):
|
|
249
|
+
monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
250
|
+
show, tip = cli._graphics_support()
|
|
251
|
+
assert show is True
|
|
252
|
+
assert tip is None
|
|
253
|
+
|
|
254
|
+
def test_tmux_without_passthrough_tips_instead(self, monkeypatch):
|
|
255
|
+
monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
256
|
+
monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
|
|
257
|
+
with mock.patch.object(cli, "_tmux_allows_passthrough", return_value=False):
|
|
258
|
+
show, tip = cli._graphics_support()
|
|
259
|
+
assert show is False
|
|
260
|
+
assert "allow-passthrough" in tip
|
|
261
|
+
|
|
262
|
+
def test_tmux_with_passthrough_enables_icons(self, monkeypatch):
|
|
263
|
+
monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
264
|
+
monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
|
|
265
|
+
with mock.patch.object(cli, "_tmux_allows_passthrough", return_value=True):
|
|
266
|
+
show, tip = cli._graphics_support()
|
|
267
|
+
assert show is True
|
|
268
|
+
assert tip is None
|
|
269
|
+
|
|
270
|
+
def test_missing_cairosvg_tips_instead(self, monkeypatch):
|
|
271
|
+
monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
272
|
+
with mock.patch.dict("sys.modules", {"cairosvg": None}):
|
|
273
|
+
show, tip = cli._graphics_support()
|
|
274
|
+
assert show is False
|
|
275
|
+
assert "cairosvg" in tip
|
|
276
|
+
|
|
277
|
+
def test_tmux_passthrough_query_handles_missing_tmux(self):
|
|
278
|
+
with mock.patch.object(cli.subprocess, "run", side_effect=FileNotFoundError):
|
|
279
|
+
assert cli._tmux_allows_passthrough() is False
|
|
280
|
+
|
|
281
|
+
SVG = (
|
|
282
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
|
|
283
|
+
'<circle cx="12" cy="12" r="9" stroke="black" fill="none"/></svg>'
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def test_image_escape_bare(self, capfd):
|
|
287
|
+
pytest.importorskip("cairosvg")
|
|
288
|
+
assert cli._display_kitty_image(self.SVG) is True
|
|
289
|
+
out = capfd.readouterr().out
|
|
290
|
+
assert out.startswith("\033_G")
|
|
291
|
+
assert out.endswith("\033\\")
|
|
292
|
+
assert "C=1" in out and "r=1,c=2" in out
|
|
293
|
+
|
|
294
|
+
def test_image_escape_tmux_passthrough(self, capfd, monkeypatch):
|
|
295
|
+
pytest.importorskip("cairosvg")
|
|
296
|
+
monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
|
|
297
|
+
assert cli._display_kitty_image(self.SVG) is True
|
|
298
|
+
out = capfd.readouterr().out
|
|
299
|
+
assert out.startswith("\033Ptmux;")
|
|
300
|
+
assert out.endswith("\033\\")
|
|
301
|
+
body = out[len("\033Ptmux;") : -2]
|
|
302
|
+
# Inside the passthrough envelope every ESC must be doubled
|
|
303
|
+
i = 0
|
|
304
|
+
while i < len(body):
|
|
305
|
+
if body[i] == "\033":
|
|
306
|
+
assert body[i + 1] == "\033"
|
|
307
|
+
i += 2
|
|
308
|
+
else:
|
|
309
|
+
i += 1
|
|
310
|
+
|
|
311
|
+
def test_missing_cairosvg_returns_false(self):
|
|
312
|
+
with mock.patch.dict("sys.modules", {"cairosvg": None}):
|
|
313
|
+
assert cli._display_kitty_image(self.SVG) is False
|
|
314
|
+
|
|
315
|
+
def test_broken_native_cairo_tips_system_library(self, monkeypatch):
|
|
316
|
+
import builtins # noqa: PLC0415
|
|
317
|
+
|
|
318
|
+
monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
319
|
+
real_import = builtins.__import__
|
|
320
|
+
|
|
321
|
+
def fake_import(name, *args, **kwargs):
|
|
322
|
+
# cairocffi raises OSError from dlopen when libcairo is absent
|
|
323
|
+
if name == "cairosvg":
|
|
324
|
+
raise OSError("no library called 'cairo-2' was found")
|
|
325
|
+
return real_import(name, *args, **kwargs)
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
328
|
+
show, tip = cli._graphics_support()
|
|
329
|
+
assert show is False
|
|
330
|
+
assert "cairo system library" in tip
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|